diff --git a/VERSION b/VERSION
index e0a5db2..95300b3 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-v2026.07.19.10
+v2026.07.19.11
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
index 412c786..fbaadf5 100644
--- a/frontend/src/api/index.ts
+++ b/frontend/src/api/index.ts
@@ -894,6 +894,9 @@ export const adminAPI = {
// Phase 3.3: 多 Agent 编排图 (orchestration)
getAgentOrchestration: (id: string) => get(`/admin/agents/${id}/orchestration`),
setAgentOrchestration: (id: string, orchestration: any) => put(`/admin/agents/${id}/orchestration`, { orchestration }),
+ importYamlOrch: (id: string, yaml: string) => post(`/admin/agents/${id}/orchestration/yaml/import`, { yaml_text: yaml }),
+ exportYamlOrch: (id: string) => post(`/admin/agents/${id}/orchestration/yaml/export`),
+ getYamlTemplates: () => get('/admin/agents/orchestration/yaml/templates'),
getAdminAvailableSkills: (params?: any) => {
diff --git a/frontend/src/pages/admin/Agents.tsx b/frontend/src/pages/admin/Agents.tsx
index b99d1d0..0a6a798 100644
--- a/frontend/src/pages/admin/Agents.tsx
+++ b/frontend/src/pages/admin/Agents.tsx
@@ -1,905 +1,1837 @@
import React, { useState, useEffect } from 'react';
+
import { adminAPI } from '@/api';
+
+
interface Agent {
+
id: string;
+
name: string;
+
type: string;
+
source: string;
+
publisher_id: string;
+
publisher_name?: string;
+
description?: string;
+
icon?: string;
+
tutorial?: string;
+
price: number;
+
is_member_free: boolean;
+
join_promotion: boolean;
+
status: string;
+
rating_avg: number;
+
review_count: number;
+
install_count: number;
+
skill_content?: string;
+
agent_endpoint?: string;
+
config?: AgentConfig;
+
linked_skills?: LinkedSkill[];
+
created_at?: string;
+
updated_at?: string;
+
}
+
+
interface AgentConfig {
+
system_prompt?: string;
+
temperature?: number;
+
max_tokens?: number;
+
model_id?: string;
+
skills?: string[];
+
tools?: string[];
+
memory_enabled?: boolean;
+
reasoning_enabled?: boolean;
+
}
+
+
interface LinkedSkill {
+
id: string;
+
skill_name: string;
+
state: string;
+
version: number;
+
}
+
+
interface AgentStats {
+
total_agents: number;
+
published_agents: number;
+
pending_agents: number;
+
official_agents: number;
+
user_agents: number;
+
total_installs: number;
+
average_rating: number;
+
by_source: Record;
+
}
+
+
interface AvailableModel {
+
id: string;
+
name: string;
+
provider: string;
+
model_type?: string;
+
}
+
+
const AGENT_TYPES = [
+
{ id: 'skill', label: '技能' },
+
{ id: 'agent', label: 'Agent' },
+
{ id: 'mcp', label: 'MCP' },
+
];
+
+
const Agents: React.FC = () => {
+
const [agents, setAgents] = useState([]);
+
const [total, setTotal] = useState(0);
+
const [page, setPage] = useState(1);
+
const [loading, setLoading] = useState(false);
+
const [stats, setStats] = useState(null);
+
+
// Filters
+
const [keyword, setKeyword] = useState('');
+
const [filterStatus, setFilterStatus] = useState('');
+
const [filterSource, setFilterSource] = useState('');
+
+
// Modal
+
const [showModal, setShowModal] = useState(false);
+
const [editingAgent, setEditingAgent] = useState(null);
+
const [form, setForm] = useState({
+
name: '',
+
description: '',
+
icon: '',
+
price: '0',
+
is_member_free: false,
+
system_prompt: '',
+
temperature: '0.7',
+
max_tokens: '2048',
+
model_id: '',
+
skills: [] as string[],
+
memory_enabled: false,
+
reasoning_enabled: false,
+
});
+
+
// Detail drawer
+
const [showDetail, setShowDetail] = useState(false);
+
const [detailAgent, setDetailAgent] = useState(null);
+
+
// Available skills for linking
+
const [availableSkills, setAvailableSkills] = useState([]);
+
const [availableModels, setAvailableModels] = useState([]);
+
// Available MCP servers for tool_policy sandbox allowlist
+
const [availableMcps, setAvailableMcps] = useState([]);
+
// Tool policy (sandbox) — null means disabled / no policy
+
const [toolPolicy, setToolPolicy] = useState(null);
+
// Phase 3.3: orchestration graph editor (JSON text + error)
+
const [orchestrationText, setOrchestrationText] = useState('');
+
const [orchestrationError, setOrchestrationError] = useState('');
+
+
// Toast
+
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
+
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
+
setToast({ msg, type });
+
setTimeout(() => setToast(null), 3000);
+
};
+
+
// Load agents
+
const loadAgents = async () => {
+
setLoading(true);
+
try {
+
const params: any = { page, page_size: 15 };
+
if (keyword) params.keyword = keyword;
+
if (filterStatus) params.status = filterStatus;
+
if (filterSource) params.source = filterSource;
+
const result = await adminAPI.getAdminAgents(params) as any;
+
const data = result.data || result;
+
setAgents(data.items || []);
+
setTotal(data.total || 0);
+
} catch (e: any) {
+
showToast(`加载失败: ${e.message || e}`, 'error');
+
} finally {
+
setLoading(false);
+
}
+
};
+
+
// Load stats
+
const loadStats = async () => {
+
try {
+
const result = await adminAPI.getAdminAgentsStats() as any;
+
const data = result.data || result;
+
setStats(data);
+
} catch {}
+
};
+
+
// Load available skills & models
+
const loadAvailable = async () => {
+
try {
+
const [skillsResult, modelsResult, mcpsResult] = await Promise.all([
+
adminAPI.getAdminAvailableSkills({ page_size: 50 }) as any,
+
adminAPI.getAdminAvailableModels() as any,
+
adminAPI.getAdminMcpServers({ page_size: 50 }) as any,
+
]);
+
const skills = (skillsResult.data || skillsResult).items || [];
+
const models = (modelsResult.data || modelsResult) || [];
+
const mcps = (mcpsResult.data || mcpsResult).items || [];
+
setAvailableSkills(skills);
+
setAvailableModels(models);
+
setAvailableMcps(mcps);
+
} catch {}
+
};
+
+
useEffect(() => {
+
loadAgents();
+
}, [page, keyword, filterStatus, filterSource]);
+
+
useEffect(() => {
+
loadStats();
+
}, []);
+
+
// Search
+
const handleSearch = () => {
+
setPage(1);
+
loadAgents();
+
};
+
+
// Open create
+
const openCreate = async () => {
+
setEditingAgent(null);
+
await loadAvailable();
+
setForm({
+
name: '',
+
description: '',
+
icon: '',
+
price: '0',
+
is_member_free: false,
+
system_prompt: '你是一个智能助手。',
+
temperature: '0.7',
+
max_tokens: '2048',
+
model_id: '',
+
skills: [],
+
memory_enabled: false,
+
reasoning_enabled: false,
+
});
+
setToolPolicy(null);
+
setOrchestrationText('');
+
setOrchestrationError('');
+
setShowModal(true);
+
};
+
+
// Open edit
+
const openEdit = async (agent: Agent) => {
+
setEditingAgent(agent);
+
await loadAvailable();
+
await openEditLoadPolicy(agent.id);
+
await openEditLoadOrchestration(agent.id);
+
+
const cfg = agent.config || {};
+
setForm({
+
name: agent.name,
+
description: agent.description || '',
+
icon: agent.icon || '',
+
price: String(agent.price),
+
is_member_free: agent.is_member_free,
+
system_prompt: cfg.system_prompt || '',
+
temperature: String(cfg.temperature || 0.7),
+
max_tokens: String(cfg.max_tokens || 2048),
+
model_id: cfg.model_id || '',
+
skills: cfg.skills || [],
+
memory_enabled: cfg.memory_enabled || false,
+
reasoning_enabled: cfg.reasoning_enabled || false,
+
});
+
setShowModal(true);
+
};
+
+
// Fetch existing tool_policy (sandbox) for this agent
+
const openEditLoadPolicy = async (agentId: string) => {
+
try {
+
const tpRes = await adminAPI.getAgentToolPolicy(agentId) as any;
+
setToolPolicy(tpRes?.data?.tool_policy || null);
+
} catch {
+
setToolPolicy(null);
+
}
+
};
+
// Phase 3.3: 载入已有编排图
+
const openEditLoadOrchestration = async (agentId: string) => {
+
try {
+
const oRes = await adminAPI.getAgentOrchestration(agentId) as any;
+
const og = oRes?.data?.orchestration;
+
setOrchestrationText(og ? JSON.stringify(og, null, 2) : '');
+
setOrchestrationError('');
+
} catch {
+
setOrchestrationText('');
+
}
+
};
+
const openDetail = async (agent: Agent) => {
+
setDetailAgent(agent);
+
setShowDetail(true);
+
};
+
+
// Save agent
+
const handleSave = async () => {
+
if (!form.name.trim()) {
+
showToast('Agent 名称不能为空', 'error');
+
return;
+
}
+
+
const payload: any = {
+
name: form.name,
+
description: form.description,
+
icon: form.icon || undefined,
+
price: parseFloat(form.price) || 0,
+
is_member_free: form.is_member_free,
+
config: {
+
system_prompt: form.system_prompt,
+
temperature: parseFloat(form.temperature) || 0.7,
+
max_tokens: parseInt(form.max_tokens) || 2048,
+
model_id: form.model_id || undefined,
+
skills: form.skills,
+
memory_enabled: form.memory_enabled,
+
reasoning_enabled: form.reasoning_enabled,
+
},
+
};
+
+
try {
+
if (editingAgent) {
+
await adminAPI.updateAdminAgent(editingAgent.id, payload);
+
// Phase 3.1: persist tool_policy sandbox (null clears it)
+
try {
+
await adminAPI.setAgentToolPolicy(editingAgent.id, toolPolicy);
+
showToast('Agent 已更新');
+
} catch (pe: any) {
+
showToast(`Agent 已更新,但策略保存失败: ${pe.message || pe}`, 'error');
+
}
+
} else {
+
await adminAPI.createAdminAgent(payload);
+
showToast('Agent 创建成功');
+
}
+
setShowModal(false);
+
loadAgents();
+
loadStats();
+
} catch (e: any) {
+
showToast(`保存失败: ${e.message || e}`, 'error');
+
}
+
};
+
+
// Toggle agent
+
const handleToggle = async (agent: Agent) => {
+
const isActive = agent.status === 'published';
+
try {
+
await adminAPI.toggleAdminAgent(agent.id, !isActive);
+
showToast(`Agent 已${isActive ? '禁用' : '启用'}`);
+
loadAgents();
+
loadStats();
+
} catch (e: any) {
+
showToast(`操作失败: ${e.message || e}`, 'error');
+
}
+
};
+
+
// Delete agent
+
const handleDelete = async (agent: Agent) => {
+
if (!confirm(`确定要删除 Agent "${agent.name}" 吗?此操作不可恢复。`)) return;
+
try {
+
await adminAPI.deleteAdminAgent(agent.id);
+
showToast('Agent 已删除');
+
loadAgents();
+
loadStats();
+
} catch (e: any) {
+
showToast(`删除失败: ${e.message || e}`, 'error');
+
}
+
};
+
+
// Toggle skill in selection
+
const toggleSkill = (skillId: string) => {
+
setForm(f => ({
+
...f,
+
skills: f.skills.includes(skillId)
+
? f.skills.filter(s => s !== skillId)
+
: [...f.skills, skillId],
+
}));
+
};
+
+
const PAGE_SIZE = 15;
+
const totalPages = Math.ceil(total / PAGE_SIZE);
+
+
const statusColor = (status: string) => {
+
if (status === 'published') return { bg: '#D1FAE5', color: '#10B981' };
+
if (status === 'pending') return { bg: '#FEF3C7', color: '#F59E0B' };
+
return { bg: '#FEE2E2', color: '#EF4444' };
+
};
+
+
return (
+
+
{/* Toast */}
+
{toast && (
+
+
{toast.msg}
+
+
)}
+
+
{/* Header */}
+
+
+
Agent 管理
+
+
管理 AI Agent/Skill/MCP,支持配置、关联技能和模型
+
+
+
+
+
{/* Stats */}
+
{stats && (
+
+
+
{stats.total_agents}
+
Agent 总数
+
+
+
{stats.published_agents}
+
已发布
+
+
+
{stats.pending_agents}
+
待发布
+
+
+
{stats.total_installs.toLocaleString()}
+
总安装量
+
+
+
{stats.average_rating.toFixed(1)}
+
平均评分
+
+
+
{stats.official_agents} / {stats.user_agents}
+
官方 / 用户
+
+
+
)}
+
+
{/* Filters */}
+
+
setKeyword(e.target.value)}
+
onKeyDown={e => e.key === 'Enter' && handleSearch()}
+
style={{ maxWidth: 280 }} />
+
+
+
+
{(keyword || filterStatus || filterSource) && (
+
+
)}
+
+
+
{/* Table */}
+
+
+
+
+
| 智能体 |
+
类型 |
+
来源 |
+
状态 |
+
价格 |
+
安装量 |
+
评分 |
+
更新时间 |
+
操作 |
+
+
+
+
{loading ? (
+
| 加载中... |
+
) : agents.length === 0 ? (
+
| 暂无 Agent |
+
) : agents.map(agent => {
+
const sc = statusColor(agent.status);
+
return (
+
+
+
+
{agent.icon ? (
+

+
) : (
+
+
🤖
+
+
)}
+
+
{agent.name}
+
{agent.description && (
+
+
{agent.description}
+
+
)}
+
+
+
|
+
+
+
{AGENT_TYPES.find(t => t.id === agent.type)?.label || agent.type}
+
+
|
+
+
+
{agent.source === 'official' ? '官方' : '用户'}
+
+
|
+
+
+
{agent.status === 'published' ? '已发布' : agent.status === 'pending' ? '待发布' : '已下架'}
+
+
|
+
{agent.price === 0 ? 免费 : `¥${agent.price}`} |
+
{agent.install_count.toLocaleString()} |
+
{agent.rating_avg > 0 ? `★ ${agent.rating_avg}` : '-'} |
+
+
{agent.updated_at ? agent.updated_at.slice(0, 16).replace('T', ' ') : '-'}
+
|
+
+
+
+
+
+
+
+
|
+
+
);
+
})}
+
+
+
+
+
{/* Pagination */}
+
{totalPages > 1 && (
+
+
共 {total} 条,第 {page}/{totalPages} 页
+
+
+
{[...Array(Math.min(5, totalPages))].map((_, i) => {
+
const p = Math.max(1, Math.min(page - 2, totalPages - 4)) + i;
+
return p <= totalPages && (
+
+
);
+
})}
+
+
+
+
)}
+
+
{/* Create/Edit Modal */}
+
{showModal && (
+
setShowModal(false)}>
+
e.stopPropagation()}>
+
+
{editingAgent ? '编辑 Agent' : '创建 Agent'}
+
+
+
+
+
+
+
+
⚙️ Agent 配置
+
+
+
+
+
+
+
+
+
+
+
+
+
setForm(f => ({ ...f, temperature: e.target.value }))} />
+
+
+
+
setForm(f => ({ ...f, max_tokens: e.target.value }))} />
+
+
+
+
+
+
+
+
+
{/* Linked Skills */}
+
+
+
+
{availableSkills.length === 0 ? (
+
暂无技能
+
) : availableSkills.map(skill => (
+
+
))}
+
+
{form.skills.length > 0 && (
+
+
已选 {form.skills.length} 个技能
+
+
)}
+
+
{editingAgent && (
+
+
+
🛡️ 工具权限沙箱 (tool_policy)
+
+
{toolPolicy && (
+
+
+
+
+
{availableSkills.length === 0 ? (
+
暂无技能
+
) : availableSkills.map((skill: any) => {
+
const checked = (toolPolicy.allowed_tools?.skill_ids || []).includes(skill.id);
+
return (
+
+
);
+
})}
+
+
+
+
+
+
{availableMcps.length === 0 ? (
+
暂无 MCP 服务
+
) : availableMcps.map((m: any) => {
+
const checked = (toolPolicy.allowed_tools?.mcp_ids || []).includes(m.id);
+
return (
+
+
);
+
})}
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ )}
+
+ {editingAgent && (
+
+
+
+
+
+
🕸️ 多 Agent 编排图 (orchestration)
+
+
+
+ 用有向图描述多个智能体的协作:节点类型 llm / local / external / reduce,边 from→to 表示数据流。留空表示不启用编排(走原对话逻辑)。
+
+
+
+
+
)}
- {editingAgent && (
-
-
-
🕸️ 多 Agent 编排图 (orchestration)
-
- 用有向图描述多个智能体的协作:节点类型 llm / local / external / reduce,边 from→to 表示数据流。留空表示不启用编排(走原对话逻辑)。
-
-
- )}
+
+
+
+
+
+
+
+
)}
+
+
{/* Detail Drawer */}
+
{showDetail && detailAgent && (
+
setShowDetail(false)}>
+
e.stopPropagation()}>
+
+
{detailAgent.name}
+
+
+
+
+
类型{detailAgent.type}
+
来源{detailAgent.source === 'official' ? '官方' : '用户'}
+
状态{detailAgent.status}
+
价格¥{detailAgent.price}
+
安装量{detailAgent.install_count}
+
评分{detailAgent.rating_avg > 0 ? `★ ${detailAgent.rating_avg}` : '-'}
+
发布者{detailAgent.publisher_name || detailAgent.publisher_id}
+
创建时间{detailAgent.created_at?.slice(0, 16).replace('T', ' ')}
+
+
+
{detailAgent.description && (
+
+
描述
+
{detailAgent.description}
+
+
)}
+
+
{detailAgent.config && (
+
+
⚙️ 配置
+
+
{detailAgent.config.system_prompt && (
+
+
System Prompt:
+
+
{detailAgent.config.system_prompt}
+
+
+
)}
+
+
Temperature: {detailAgent.config.temperature}
+
Max Tokens: {detailAgent.config.max_tokens}
+
Model: {detailAgent.config.model_id || '默认'}
+
记忆: {detailAgent.config.memory_enabled ? '✓' : '✗'}
+
推理: {detailAgent.config.reasoning_enabled ? '✓' : '✗'}
+
关联技能: {detailAgent.config.skills?.length || 0} 个
+
+
+
+
)}
+
+
+
+
)}
+
+
+
+
);
+
};
+
+
export default Agents;
+
diff --git a/frontend/src/pages/front/ChatPage.css b/frontend/src/pages/front/ChatPage.css
index fc48a08..a8db290 100644
--- a/frontend/src/pages/front/ChatPage.css
+++ b/frontend/src/pages/front/ChatPage.css
@@ -1,1538 +1,1554 @@
-/* ===== Spin Animation ===== */
-@keyframes spin-icon-rotate {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
-}
-
-.spin-icon {
- animation: spin-icon-rotate 0.8s linear infinite;
-}
-
-/* ===== Chat Page Layout ===== */
-.chat-page {
- display: flex;
- height: 100%;
- overflow: hidden;
-}
-
-/* ===== Left Sidebar ===== */
-.chat-sidebar {
- width: 280px;
- min-width: 280px;
- background: var(--color-sidebar-bg);
- border-right: 1px solid var(--color-border-light);
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-
-.chat-sidebar-header {
- padding: var(--space-lg);
- border-bottom: 1px solid var(--color-border-light);
-}
-
-.new-chat-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: var(--space-sm);
- width: 100%;
- padding: var(--space-md) var(--space-lg);
- background: var(--color-accent);
- color: var(--color-text-inverse);
- border: none;
- border-radius: var(--radius-md);
- font-size: var(--text-base);
- font-weight: 600;
- cursor: pointer;
- transition: background var(--transition-fast), transform var(--transition-fast);
-}
-
-.new-chat-btn:hover {
- background: var(--color-accent-hover);
-}
-
-.new-chat-btn:active {
- transform: scale(0.98);
-}
-
-/* Search */
-.chat-sidebar-search {
- padding: var(--space-md) var(--space-lg);
- position: relative;
-}
-
-.search-icon-wrap {
- position: absolute;
- left: calc(var(--space-lg) + var(--space-md));
- top: 50%;
- transform: translateY(-50%);
- display: flex;
- align-items: center;
- color: var(--color-text-tertiary);
- pointer-events: none;
-}
-
-.search-input {
- width: 100%;
- padding: var(--space-sm) var(--space-md) var(--space-sm) calc(var(--space-2xl) + var(--space-sm));
- background: var(--color-bg-secondary);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-md);
- font-size: var(--text-sm);
- color: var(--color-text-primary);
- outline: none;
- transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
-}
-
-.search-input::placeholder {
- color: var(--color-text-disabled);
-}
-
-.search-input:focus {
- border-color: var(--color-accent);
- box-shadow: 0 0 0 3px var(--color-accent-light);
-}
-
-/* Conversation List */
-.chat-sidebar-list {
- flex: 1;
- overflow-y: auto;
- padding: var(--space-sm) var(--space-sm);
-}
-
-.conversation-item {
- padding: var(--space-md) var(--space-md);
- border-radius: var(--radius-md);
- cursor: pointer;
- transition: background var(--transition-fast);
- margin-bottom: 2px;
- position: relative;
-}
-
-.conversation-item:hover {
- background: var(--color-sidebar-hover);
-}
-
-.conversation-item.active {
- background: var(--color-sidebar-active);
-}
-
-.conversation-item.active .conv-title {
- color: var(--color-accent);
- font-weight: 600;
-}
-
-.conv-top {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: var(--space-xs);
- margin-bottom: 4px;
-}
-
-.conv-pin-icon {
- display: flex;
- align-items: center;
- color: var(--color-accent);
- flex-shrink: 0;
- transform: rotate(45deg);
-}
-
-.conversation-item.pinned {
- background: var(--color-accent-subtle);
- border-left: 3px solid var(--color-accent);
-}
-
-.conversation-item.pinned:hover {
- background: var(--color-accent-light);
-}
-
-.conversation-item.pinned.active {
- background: var(--color-sidebar-active);
- border-left-color: var(--color-accent);
-}
-
-.conv-title {
- font-size: var(--text-sm);
- font-weight: 500;
- color: var(--color-text-primary);
- flex: 1;
- min-width: 0;
- line-height: 1.4;
-}
-
-.conv-more-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px;
- border-radius: var(--radius-sm);
- color: var(--color-text-tertiary);
- cursor: pointer;
- opacity: 0.5;
- transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
- flex-shrink: 0;
-}
-
-.conversation-item:hover .conv-more-btn {
- opacity: 1;
- color: var(--color-text-secondary);
-}
-
-.conv-more-btn:hover {
- background: var(--color-bg-tertiary);
- color: var(--color-accent);
-}
-
-.conv-preview {
- font-size: var(--text-xs);
- color: var(--color-text-tertiary);
- line-height: 1.4;
- margin-bottom: 4px;
-}
-
-.conv-meta {
- display: flex;
- align-items: center;
- gap: var(--space-sm);
-}
-
-.conv-time {
- font-size: 11px;
- color: var(--color-text-disabled);
-}
-
-.conv-badge {
- font-size: 10px;
- color: var(--color-warning);
- background: var(--color-warning-light);
- padding: 1px 6px;
- border-radius: var(--radius-full);
- font-weight: 500;
-}
-
-.conv-empty {
- padding: var(--space-2xl) var(--space-lg);
- text-align: center;
- color: var(--color-text-tertiary);
- font-size: var(--text-sm);
-}
-
-/* ===== Right Main Area ===== */
-.chat-main {
- flex: 1;
- display: flex;
- flex-direction: column;
- min-width: 0;
- background: var(--color-bg-primary);
- overflow: hidden;
-}
-
-/* Chat Header */
-.chat-header {
- height: 52px;
- min-height: 52px;
- padding: 0 var(--space-xl);
- display: flex;
- align-items: center;
- justify-content: space-between;
- background: var(--color-bg-secondary);
- border-bottom: 1px solid var(--color-border-light);
-}
-
-.chat-header-left {
- display: flex;
- align-items: center;
- gap: var(--space-md);
- min-width: 0;
-}
-
-.model-selector {
- display: flex;
- align-items: center;
- gap: var(--space-sm);
- padding: var(--space-xs) var(--space-md);
- background: var(--color-bg-tertiary);
- border-radius: var(--radius-md);
- cursor: pointer;
- font-size: var(--text-sm);
- color: var(--color-text-secondary);
- transition: background var(--transition-fast);
- white-space: nowrap;
- flex-shrink: 0;
-}
-
-.model-selector:hover {
- background: var(--color-bg-hover);
-}
-
-.model-name {
- font-weight: 500;
- color: var(--color-text-primary);
-}
-
-.model-badge {
- font-size: 10px;
- color: var(--color-accent);
- background: var(--color-accent-light);
- padding: 1px 6px;
- border-radius: var(--radius-full);
- font-weight: 600;
-}
-
-.chat-header-divider {
- width: 1px;
- height: 20px;
- background: var(--color-border);
- flex-shrink: 0;
-}
-
-.chat-title {
- font-size: var(--text-base);
- font-weight: 500;
- color: var(--color-text-primary);
- min-width: 0;
-}
-
-/* Messages Area */
-.chat-messages {
- flex: 1;
- overflow-y: auto;
- padding: var(--space-xl);
- display: flex;
- flex-direction: column;
- gap: var(--space-lg);
-}
-
-.chat-empty-state,
-.chat-empty-full {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- height: 100%;
- color: var(--color-text-tertiary);
- gap: var(--space-md);
- user-select: none;
-}
-
-.empty-title {
- font-size: var(--text-lg);
- font-weight: 600;
- color: var(--color-text-secondary);
-}
-
-.empty-desc {
- font-size: var(--text-sm);
- color: var(--color-text-tertiary);
-}
-
-/* Message Row */
-.message-row {
- display: flex;
- flex-direction: column;
- max-width: 75%;
- animation: fadeIn var(--transition-normal);
-}
-
-.message-row.user {
- align-self: flex-end;
- align-items: flex-end;
-}
-
-.message-row.assistant {
- align-self: flex-start;
- align-items: flex-start;
-}
-
-/* Message Bubble */
-.message-bubble {
- padding: var(--space-md) var(--space-lg);
- border-radius: var(--radius-lg);
- font-size: var(--text-base);
- line-height: 1.7;
- word-break: break-word;
-}
-
-.message-bubble.user {
- background: var(--color-accent);
- color: var(--color-text-inverse);
- border-bottom-right-radius: var(--radius-sm);
-}
-
-.message-bubble.assistant {
- background: var(--color-bg-secondary);
- color: var(--color-text-primary);
- border: 1px solid var(--color-border-light);
- border-bottom-left-radius: var(--radius-sm);
- box-shadow: var(--shadow-xs);
-}
-
-/* Message Meta */
-.message-meta {
- display: flex;
- gap: var(--space-sm);
- padding: 4px var(--space-sm) 0;
- font-size: 11px;
- color: var(--color-text-disabled);
-}
-
-.message-meta.user {
- justify-content: flex-end;
-}
-
-/* Markdown Rendering */
-.markdown-body {
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.message-bubble.user .markdown-body {
- gap: 4px;
-}
-
-.md-code-block {
- background: rgba(0, 0, 0, 0.08);
- border-radius: var(--radius-sm);
- padding: var(--space-md);
- overflow-x: auto;
- font-family: var(--font-mono);
- font-size: var(--text-sm);
- line-height: 1.5;
- margin: 4px 0;
-}
-
-.message-bubble.user .md-code-block {
- background: rgba(255, 255, 255, 0.15);
-}
-
-.md-inline-code {
- background: rgba(0, 0, 0, 0.07);
- padding: 1px 5px;
- border-radius: 3px;
- font-family: var(--font-mono);
- font-size: var(--text-sm);
-}
-
-.message-bubble.user .md-inline-code {
- background: rgba(255, 255, 255, 0.15);
-}
-
-.md-h2 {
- font-size: var(--text-lg);
- font-weight: 700;
- margin-top: 8px;
- color: inherit;
-}
-
-.md-h3 {
- font-size: var(--text-md);
- font-weight: 600;
- margin-top: 6px;
- color: inherit;
-}
-
-.md-h4 {
- font-size: var(--text-base);
- font-weight: 600;
- color: inherit;
-}
-
-.md-li {
- margin-left: 1.2em;
- line-height: 1.6;
-}
-
-.md-table {
- width: 100%;
- border-collapse: collapse;
- margin: 12px 0;
- font-size: var(--text-sm);
- background: #fff;
- border-radius: var(--radius-md);
- overflow: hidden;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
-}
-
-.md-table thead {
- background: linear-gradient(135deg, #5B5FED 0%, #7B7FEE 100%);
- color: #fff;
-}
-
-.md-table th {
- padding: 10px 14px;
- font-weight: 600;
- text-align: left;
- font-size: var(--text-xs);
- letter-spacing: 0.3px;
-}
-
-.md-table td {
- padding: 10px 14px;
- border-bottom: 1px solid #EEF0F4;
- color: var(--color-text-primary);
- vertical-align: top;
-}
-
-.md-table tbody tr:last-child td {
- border-bottom: none;
-}
-
-.md-table tbody tr:hover {
- background: #F8F9FC;
-}
-
-.md-table tbody tr:nth-child(even) {
- background: #FAFBFD;
-}
-
-.md-table tbody tr:nth-child(even):hover {
- background: #F0F2F7;
-}
-
-.message-bubble.user .md-table {
- background: rgba(255, 255, 255, 0.08);
- color: inherit;
-}
-.message-bubble.user .md-table th,
-.message-bubble.user .md-table td {
- color: inherit;
- border-color: rgba(255, 255, 255, 0.15);
-}
-.message-bubble.user .md-table tbody tr:hover,
-.message-bubble.user .md-table tbody tr:nth-child(even) {
- background: rgba(255, 255, 255, 0.05);
-}
-
-/* 数学公式 */
-.md-math-block {
- display: block;
- overflow-x: auto;
- padding: 10px 0;
- text-align: center;
-}
-.md-math-inline {
- display: inline;
- padding: 0 2px;
-}
-
-/* 代码块包装器 + 复制按钮 */
-.md-code-wrapper {
- position: relative;
- margin: 12px 0;
-}
-.md-code-wrapper .md-code-block {
- margin: 0;
- border-radius: var(--radius-md);
-}
-.md-code-copy {
- position: absolute;
- top: 8px;
- right: 8px;
- padding: 4px 10px;
- font-size: 12px;
- color: #6B7280;
- background: #F3F4F6;
- border: 1px solid #E5E7EB;
- border-radius: 4px;
- cursor: pointer;
- opacity: 0;
- transition: opacity 0.2s;
- z-index: 2;
-}
-.md-code-wrapper:hover .md-code-copy {
- opacity: 1;
-}
-
-/* 删除线 */
-del {
- text-decoration: line-through;
- opacity: 0.6;
-}
-
-.md-hr {
- border: none;
- border-top: 1px solid var(--color-border);
- margin: 8px 0;
-}
-
-.message-bubble.user .md-hr {
- border-color: rgba(255, 255, 255, 0.2);
-}
-
-.message-bubble.user .md-code-block {
- background: rgba(0, 0, 0, 0.2);
-}
-
-/* ===== Input Area ===== */
-.chat-input-area {
- padding: var(--space-md) var(--space-xl) var(--space-xl);
- background: var(--color-bg-primary);
- overflow: visible;
-}
-
-.chat-input-container {
- background: var(--color-bg-secondary);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-lg);
- padding: var(--space-sm);
- transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
-}
-
-.chat-input-container:focus-within {
- border-color: var(--color-accent);
- box-shadow: 0 0 0 3px var(--color-accent-light);
-}
-
-.input-attachments {
- display: flex;
- gap: 4px;
- padding: 0 var(--space-sm) 4px;
-}
-
-.attach-btn {
- display: flex;
- align-items: center;
- gap: 4px;
- padding: 4px 10px;
- border-radius: var(--radius-sm);
- font-size: var(--text-xs);
- color: var(--color-text-secondary);
- background: var(--color-bg-tertiary);
- border: 1px solid var(--color-border);
- cursor: pointer;
- transition: all var(--transition-fast);
- font-weight: 500;
-}
-
-.attach-btn:hover {
- background: var(--color-accent-subtle);
- border-color: var(--color-accent);
- color: var(--color-accent);
-}
-
-.input-row {
- display: flex;
- align-items: flex-end;
- gap: var(--space-sm);
- padding: 0 var(--space-sm);
-}
-
-.chat-textarea {
- flex: 1;
- border: none;
- outline: none;
- resize: none;
- font-size: var(--text-base);
- line-height: 1.6;
- color: var(--color-text-primary);
- background: transparent;
- padding: var(--space-xs) 0;
- max-height: 160px;
- font-family: var(--font-sans);
-}
-
-.chat-textarea::placeholder {
- color: var(--color-text-disabled);
-}
-
-.send-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 34px;
- height: 34px;
- border-radius: var(--radius-md);
- background: var(--color-accent);
- color: var(--color-text-inverse);
- border: none;
- cursor: pointer;
- transition: all var(--transition-fast);
- flex-shrink: 0;
- margin-bottom: 2px;
-}
-
-.send-btn:hover:not(:disabled) {
- background: var(--color-accent-hover);
- transform: scale(1.05);
-}
-
-.send-btn:active:not(:disabled) {
- transform: scale(0.95);
-}
-
-.send-btn:disabled {
- background: var(--color-bg-tertiary);
- color: var(--color-text-disabled);
- cursor: not-allowed;
-}
-
-/* ===== Dropdown ===== */
-.dropdown-wrapper {
- position: relative;
- display: inline-flex;
-}
-
-.dropdown-trigger {
- cursor: pointer;
-}
-
-.dropdown-menu {
- position: absolute;
- top: calc(100% + 4px);
- z-index: 9999;
- background: var(--color-bg-secondary);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-md);
- box-shadow: var(--shadow-lg);
- padding: var(--space-xs);
- min-width: 180px;
- max-height: 320px;
- overflow-y: auto;
- animation: fadeIn var(--transition-fast);
-}
-
-/* Upward-opening dropdown for bottom-of-page elements */
-.dropdown-menu.dropup {
- top: auto;
- bottom: calc(100% + 4px);
-}
-
-.dropdown-menu.align-left {
- left: 0;
-}
-
-.dropdown-menu.align-right {
- right: 0;
-}
-
-.dropdown-item {
- display: flex;
- align-items: center;
- gap: var(--space-sm);
- width: 100%;
- padding: var(--space-sm) var(--space-md);
- border-radius: var(--radius-sm);
- font-size: var(--text-sm);
- color: var(--color-text-secondary);
- background: none;
- border: none;
- cursor: pointer;
- transition: background var(--transition-fast);
- text-align: left;
- white-space: nowrap;
-}
-
-.dropdown-item:hover {
- background: var(--color-bg-hover);
- color: var(--color-text-primary);
-}
-
-.dropdown-item.selected {
- color: var(--color-accent);
- background: var(--color-accent-subtle);
- font-weight: 500;
-}
-
-.dropdown-item.danger {
- color: var(--color-error);
-}
-
-.dropdown-item.danger:hover {
- background: var(--color-error-light);
-}
-
-.dropdown-item.disabled {
- color: var(--color-text-disabled);
- cursor: default;
- pointer-events: none;
-}
-
-.dropdown-divider {
- height: 1px;
- background: var(--color-border-light);
- margin: var(--space-xs) 0;
-}
-
-.model-dropdown-item {
- display: flex;
- align-items: center;
- gap: var(--space-sm);
- width: 100%;
-}
-
-.model-provider-tag {
- font-size: 10px;
- color: var(--color-text-tertiary);
- margin-left: auto;
- background: var(--color-bg-tertiary);
- padding: 1px 6px;
- border-radius: var(--radius-full);
-}
-
-.attach-dropdown-title {
- padding: var(--space-xs) var(--space-md) var(--space-sm);
- font-size: 11px;
- font-weight: 600;
- color: var(--color-text-tertiary);
- text-transform: uppercase;
- letter-spacing: 0.05em;
-}
-
-.attach-count {
- margin-left: auto;
- font-size: 11px;
- color: var(--color-text-disabled);
-}
-
-/* ===== Responsive ===== */
-@media (max-width: 768px) {
- .chat-sidebar {
- position: fixed;
- left: 0;
- top: 0;
- bottom: 0;
- z-index: var(--z-overlay);
- transform: translateX(-100%);
- transition: transform var(--transition-slow);
- box-shadow: var(--shadow-lg);
- }
-
- .message-row {
- max-width: 90%;
- }
-
- .chat-header {
- padding: 0 var(--space-md);
- }
-
- .chat-messages {
- padding: var(--space-md);
- }
-
- .chat-input-area {
- padding: var(--space-sm) var(--space-md) var(--space-md);
- }
-}
-
-/* ===== Knowledge Graph Conversion Modal ===== */
-.kg-overlay {
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background: rgba(0, 0, 0, 0.6);
- backdrop-filter: blur(4px);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: var(--z-overlay);
- animation: fadeIn 0.2s ease;
-}
-
-.kg-modal {
- background: var(--color-card-bg);
- border-radius: var(--radius-xl);
- box-shadow: var(--shadow-xl);
- width: 420px;
- max-width: 90vw;
- overflow: hidden;
-}
-
-.kg-modal-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: var(--space-xl) var(--space-2xl);
- border-bottom: 1px solid var(--color-border-light);
-}
-
-.kg-modal-header h3 {
- font-size: var(--text-lg);
- font-weight: 700;
- color: var(--color-text-primary);
-}
-
-.kg-modal-close {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 32px;
- height: 32px;
- border-radius: var(--radius-full);
- border: none;
- background: transparent;
- color: var(--color-text-tertiary);
- cursor: pointer;
- transition: all var(--transition-fast);
-}
-
-.kg-modal-close:hover {
- background: var(--color-bg-hover);
- color: var(--color-text-primary);
-}
-
-.kg-modal-body {
- padding: var(--space-2xl);
-}
-
-/* Progress State */
-.kg-progress-state {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: var(--space-xl);
-}
-
-.kg-steps {
- display: flex;
- align-items: center;
- gap: 0;
- width: 100%;
-}
-
-.kg-step {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: var(--space-sm);
-}
-
-.kg-step-indicator {
- width: 36px;
- height: 36px;
- border-radius: var(--radius-full);
- border: 2px solid var(--color-border);
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: var(--text-sm);
- font-weight: 600;
- color: var(--color-text-tertiary);
- background: var(--color-card-bg);
- transition: all var(--transition-normal);
-}
-
-.kg-step.active .kg-step-indicator {
- border-color: var(--color-accent);
- color: var(--color-accent);
- background: var(--color-accent-light);
-}
-
-.kg-step.done .kg-step-indicator {
- border-color: var(--color-success);
- color: var(--color-success);
- background: var(--color-success-light, rgba(34, 197, 94, 0.1));
-}
-
-.kg-step-label {
- font-size: var(--text-xs);
- color: var(--color-text-tertiary);
- white-space: nowrap;
-}
-
-.kg-step.active .kg-step-label {
- color: var(--color-accent);
- font-weight: 600;
-}
-
-.kg-step.done .kg-step-label {
- color: var(--color-success);
-}
-
-.kg-step-connector {
- flex: 1;
- padding-bottom: 20px;
-}
-
-.kg-connector-line {
- height: 2px;
- background: var(--color-border);
- border-radius: 1px;
- position: relative;
- overflow: hidden;
-}
-
-.kg-connector-line.done {
- background: var(--color-success);
-}
-
-.kg-connector-progress {
- position: absolute;
- top: 0;
- left: 0;
- height: 100%;
- width: 70%;
- background: var(--color-accent);
- border-radius: 1px;
- animation: kgProgressPulse 1s ease-in-out infinite;
-}
-
-@keyframes kgProgressPulse {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.5; }
-}
-
-.kg-progress-tip {
- font-size: var(--text-sm);
- color: var(--color-text-secondary);
-}
-
-/* Spinner */
-.kg-spinner {
- width: 16px;
- height: 16px;
- border: 2px solid transparent;
- border-top-color: var(--color-accent);
- border-radius: var(--radius-full);
- animation: spin 0.8s linear infinite;
-}
-
-@keyframes spin {
- to { transform: rotate(360deg); }
-}
-
-/* Success State */
-.kg-success-state {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: var(--space-lg);
-}
-
-.kg-success-icon {
- margin-bottom: var(--space-sm);
-}
-
-.kg-success-title {
- font-size: var(--text-lg);
- font-weight: 700;
- color: var(--color-text-primary);
-}
-
-.kg-result-stats {
- display: flex;
- align-items: center;
- gap: var(--space-xl);
- padding: var(--space-lg) var(--space-2xl);
- background: var(--color-bg-subtle);
- border-radius: var(--radius-lg);
-}
-
-.kg-stat-item {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: var(--space-xs);
-}
-
-.kg-stat-value {
- font-size: var(--text-2xl);
- font-weight: 800;
- color: var(--color-accent);
-}
-
-.kg-stat-label {
- font-size: var(--text-xs);
- color: var(--color-text-tertiary);
-}
-
-.kg-stat-divider {
- width: 1px;
- height: 32px;
- background: var(--color-border);
-}
-
-.kg-result-kb {
- font-size: var(--text-sm);
- color: var(--color-text-secondary);
-}
-
-.kg-actions {
- display: flex;
- gap: var(--space-md);
- margin-top: var(--space-sm);
-}
-
-/* Error State */
-.kg-error-state {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: var(--space-lg);
-}
-
-.kg-error-icon {
- margin-bottom: var(--space-xs);
-}
-
-.kg-error-text {
- font-size: var(--text-sm);
- color: var(--color-error);
- text-align: center;
-}
-
-/* Buttons */
-.kg-btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: var(--space-sm) var(--space-xl);
- border-radius: var(--radius-md);
- font-size: var(--text-sm);
- font-weight: 600;
- cursor: pointer;
- transition: all var(--transition-fast);
- border: none;
-}
-
-.kg-btn-primary {
- background: var(--color-accent);
- color: var(--color-text-inverse);
-}
-
-.kg-btn-primary:hover {
- background: var(--color-accent-hover);
-}
-
-.kg-btn-secondary {
- background: var(--color-bg-hover);
- color: var(--color-text-secondary);
-}
-
-.kg-btn-secondary:hover {
- background: var(--color-border);
- color: var(--color-text-primary);
-}
-
-/* ===== Evolution Skill Tags (below assistant messages) ===== */
-.evo-skill-tags-row {
- display: flex;
- flex-wrap: wrap;
- gap: var(--space-xs);
- margin-top: var(--space-xs);
- padding-left: 4px;
-}
-
-.evo-skill-tag {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 3px 10px;
- border-radius: var(--radius-full);
- background: var(--color-accent-subtle);
- color: var(--color-accent);
- font-size: 11px;
- font-weight: 500;
- cursor: pointer;
- transition: all var(--transition-fast);
- border: 1px solid transparent;
- font-family: var(--font-mono);
-}
-
-.evo-skill-tag:hover {
- background: var(--color-accent-light);
- border-color: var(--color-accent);
- transform: translateY(-1px);
-}
-
-.evo-skill-tag-more {
- background: var(--color-bg-tertiary);
- color: var(--color-text-tertiary);
- cursor: default;
- font-family: var(--font-sans);
-}
-
-.evo-skill-tag-more:hover {
- background: var(--color-bg-tertiary);
- border-color: transparent;
- transform: none;
-}
-
-/* ===== AI 生成媒体:固定缩略图 + 下载 ===== */
-.md-text-seg {
- display: block;
- width: 100%;
-}
-
-.chat-media-wrap {
- position: relative;
- display: inline-block;
- max-width: 100%;
- margin: 2px 0;
-}
-
-.chat-media-img {
- display: block;
- max-width: 320px;
- max-height: 320px;
- width: auto;
- height: auto;
- object-fit: contain;
- border-radius: var(--radius-md);
- background: var(--color-bg-tertiary);
- cursor: zoom-in;
- box-shadow: var(--shadow-xs);
-}
-
-.chat-media-video {
- display: block;
- max-width: 360px;
- max-height: 320px;
- width: auto;
- height: auto;
- border-radius: var(--radius-md);
- background: #000;
- cursor: zoom-in;
- box-shadow: var(--shadow-xs);
-}
-
-.chat-media-bar {
- margin-top: 6px;
-}
-
-.chat-media-link {
- font-size: var(--text-xs);
- color: var(--color-accent);
- text-decoration: none;
-}
-
-.chat-media-link:hover {
- text-decoration: underline;
-}
-
-/* 下载按钮 */
-.chat-media-dl-btn {
- position: absolute;
- top: 8px;
- right: 8px;
- width: 30px;
- height: 30px;
- display: flex;
- align-items: center;
- justify-content: center;
- border: none;
- border-radius: var(--radius-full);
- background: rgba(0, 0, 0, 0.55);
- color: #fff;
- cursor: pointer;
- opacity: 0;
- transition: opacity var(--transition-fast), background var(--transition-fast);
- z-index: 2;
-}
-
-.chat-media-wrap:hover .chat-media-dl-btn {
- opacity: 1;
-}
-
-.chat-media-dl-btn:hover {
- background: rgba(0, 0, 0, 0.75);
-}
-
-/* 下载弹层 */
-.chat-media-dl-pop {
- position: absolute;
- top: 44px;
- right: 8px;
- z-index: 20;
- width: 220px;
- padding: var(--space-md);
- background: var(--color-card-bg);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-md);
- box-shadow: var(--shadow-lg);
- animation: fadeIn var(--transition-fast);
-}
-
-.chat-media-dl-title {
- font-size: var(--text-sm);
- font-weight: 600;
- color: var(--color-text-primary);
- margin-bottom: var(--space-sm);
-}
-
-.chat-media-dl-row {
- display: flex;
- align-items: center;
- gap: var(--space-sm);
- margin-bottom: var(--space-xs);
-}
-
-.chat-media-dl-label {
- font-size: var(--text-xs);
- color: var(--color-text-tertiary);
- margin-bottom: 4px;
-}
-
-.chat-media-dl-fmts {
- display: flex;
- gap: 4px;
-}
-
-.chat-media-dl-fmt {
- flex: 1;
- padding: 4px 0;
- font-size: var(--text-xs);
- font-weight: 600;
- color: var(--color-text-secondary);
- background: var(--color-bg-tertiary);
- border: 1px solid transparent;
- border-radius: var(--radius-sm);
- cursor: pointer;
- transition: all var(--transition-fast);
-}
-
-.chat-media-dl-fmt.active {
- color: var(--color-text-inverse);
- background: var(--color-accent);
- border-color: var(--color-accent);
-}
-
-.chat-media-dl-sizes {
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.chat-media-dl-size {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 8px 10px;
- background: var(--color-bg-secondary);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-sm);
- cursor: pointer;
- transition: all var(--transition-fast);
-}
-
-.chat-media-dl-size:hover {
- border-color: var(--color-accent);
- background: var(--color-accent-subtle);
-}
-
-.chat-media-dl-size-label {
- font-size: var(--text-sm);
- font-weight: 500;
- color: var(--color-text-primary);
-}
-
-.chat-media-dl-size-px {
- font-size: var(--text-xs);
- color: var(--color-text-tertiary);
- font-family: var(--font-mono);
-}
-
-/* 灯箱预览 */
-.chat-lightbox {
- position: fixed;
- inset: 0;
- background: rgba(0, 0, 0, 0.78);
- backdrop-filter: blur(4px);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: var(--z-overlay);
- animation: fadeIn 0.2s ease;
-}
-
-.chat-lightbox-close {
- position: absolute;
- top: 18px;
- right: 22px;
- width: 40px;
- height: 40px;
- border-radius: var(--radius-full);
- border: none;
- background: rgba(255, 255, 255, 0.15);
- color: #fff;
- font-size: 24px;
- line-height: 1;
- cursor: pointer;
- transition: background var(--transition-fast);
-}
-
-.chat-lightbox-close:hover {
- background: rgba(255, 255, 255, 0.3);
-}
-
-.chat-lightbox-content {
- 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);
-}
+/* ===== Spin Animation ===== */
+@keyframes spin-icon-rotate {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+.spin-icon {
+ animation: spin-icon-rotate 0.8s linear infinite;
+}
+
+/* ===== Chat Page Layout ===== */
+.chat-page {
+ display: flex;
+ height: 100%;
+ overflow: hidden;
+}
+
+/* ===== Left Sidebar ===== */
+.chat-sidebar {
+ width: 280px;
+ min-width: 280px;
+ background: var(--color-sidebar-bg);
+ border-right: 1px solid var(--color-border-light);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.chat-sidebar-header {
+ padding: var(--space-lg);
+ border-bottom: 1px solid var(--color-border-light);
+}
+
+.new-chat-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-sm);
+ width: 100%;
+ padding: var(--space-md) var(--space-lg);
+ background: var(--color-accent);
+ color: var(--color-text-inverse);
+ border: none;
+ border-radius: var(--radius-md);
+ font-size: var(--text-base);
+ font-weight: 600;
+ cursor: pointer;
+ transition: background var(--transition-fast), transform var(--transition-fast);
+}
+
+.new-chat-btn:hover {
+ background: var(--color-accent-hover);
+}
+
+.new-chat-btn:active {
+ transform: scale(0.98);
+}
+
+/* Search */
+.chat-sidebar-search {
+ padding: var(--space-md) var(--space-lg);
+ position: relative;
+}
+
+.search-icon-wrap {
+ position: absolute;
+ left: calc(var(--space-lg) + var(--space-md));
+ top: 50%;
+ transform: translateY(-50%);
+ display: flex;
+ align-items: center;
+ color: var(--color-text-tertiary);
+ pointer-events: none;
+}
+
+.search-input {
+ width: 100%;
+ padding: var(--space-sm) var(--space-md) var(--space-sm) calc(var(--space-2xl) + var(--space-sm));
+ background: var(--color-bg-secondary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ font-size: var(--text-sm);
+ color: var(--color-text-primary);
+ outline: none;
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
+}
+
+.search-input::placeholder {
+ color: var(--color-text-disabled);
+}
+
+.search-input:focus {
+ border-color: var(--color-accent);
+ box-shadow: 0 0 0 3px var(--color-accent-light);
+}
+
+/* Conversation List */
+.chat-sidebar-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: var(--space-sm) var(--space-sm);
+}
+
+.conversation-item {
+ padding: var(--space-md) var(--space-md);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: background var(--transition-fast);
+ margin-bottom: 2px;
+ position: relative;
+}
+
+.conversation-item:hover {
+ background: var(--color-sidebar-hover);
+}
+
+.conversation-item.active {
+ background: var(--color-sidebar-active);
+}
+
+.conversation-item.active .conv-title {
+ color: var(--color-accent);
+ font-weight: 600;
+}
+
+.conv-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-xs);
+ margin-bottom: 4px;
+}
+
+.conv-pin-icon {
+ display: flex;
+ align-items: center;
+ color: var(--color-accent);
+ flex-shrink: 0;
+ transform: rotate(45deg);
+}
+
+.conversation-item.pinned {
+ background: var(--color-accent-subtle);
+ border-left: 3px solid var(--color-accent);
+}
+
+.conversation-item.pinned:hover {
+ background: var(--color-accent-light);
+}
+
+.conversation-item.pinned.active {
+ background: var(--color-sidebar-active);
+ border-left-color: var(--color-accent);
+}
+
+.conv-title {
+ font-size: var(--text-sm);
+ font-weight: 500;
+ color: var(--color-text-primary);
+ flex: 1;
+ min-width: 0;
+ line-height: 1.4;
+}
+
+.conv-more-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border-radius: var(--radius-sm);
+ color: var(--color-text-tertiary);
+ cursor: pointer;
+ opacity: 0.5;
+ transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
+ flex-shrink: 0;
+}
+
+.conversation-item:hover .conv-more-btn {
+ opacity: 1;
+ color: var(--color-text-secondary);
+}
+
+.conv-more-btn:hover {
+ background: var(--color-bg-tertiary);
+ color: var(--color-accent);
+}
+
+.conv-preview {
+ font-size: var(--text-xs);
+ color: var(--color-text-tertiary);
+ line-height: 1.4;
+ margin-bottom: 4px;
+}
+
+.conv-meta {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+}
+
+.conv-time {
+ font-size: 11px;
+ color: var(--color-text-disabled);
+}
+
+.conv-badge {
+ font-size: 10px;
+ color: var(--color-warning);
+ background: var(--color-warning-light);
+ padding: 1px 6px;
+ border-radius: var(--radius-full);
+ font-weight: 500;
+}
+
+.conv-empty {
+ padding: var(--space-2xl) var(--space-lg);
+ text-align: center;
+ color: var(--color-text-tertiary);
+ font-size: var(--text-sm);
+}
+
+/* ===== Right Main Area ===== */
+.chat-main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ background: var(--color-bg-primary);
+ overflow: hidden;
+}
+
+/* Chat Header */
+.chat-header {
+ height: 52px;
+ min-height: 52px;
+ padding: 0 var(--space-xl);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background: var(--color-bg-secondary);
+ border-bottom: 1px solid var(--color-border-light);
+}
+
+.chat-header-left {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+ min-width: 0;
+}
+
+.model-selector {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ padding: var(--space-xs) var(--space-md);
+ background: var(--color-bg-tertiary);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ font-size: var(--text-sm);
+ color: var(--color-text-secondary);
+ transition: background var(--transition-fast);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.model-selector:hover {
+ background: var(--color-bg-hover);
+}
+
+.model-name {
+ font-weight: 500;
+ color: var(--color-text-primary);
+}
+
+.model-badge {
+ font-size: 10px;
+ color: var(--color-accent);
+ background: var(--color-accent-light);
+ padding: 1px 6px;
+ border-radius: var(--radius-full);
+ font-weight: 600;
+}
+
+.chat-header-divider {
+ width: 1px;
+ height: 20px;
+ background: var(--color-border);
+ flex-shrink: 0;
+}
+
+.chat-title {
+ font-size: var(--text-base);
+ font-weight: 500;
+ color: var(--color-text-primary);
+ min-width: 0;
+}
+
+/* Messages Area */
+.chat-messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: var(--space-xl);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-lg);
+}
+
+.chat-empty-state,
+.chat-empty-full {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: var(--color-text-tertiary);
+ gap: var(--space-md);
+ user-select: none;
+}
+
+.empty-title {
+ font-size: var(--text-lg);
+ font-weight: 600;
+ color: var(--color-text-secondary);
+}
+
+.empty-desc {
+ font-size: var(--text-sm);
+ color: var(--color-text-tertiary);
+}
+
+/* Message Row */
+.message-row {
+ display: flex;
+ flex-direction: column;
+ max-width: 75%;
+ animation: fadeIn var(--transition-normal);
+}
+
+.message-row.user {
+ align-self: flex-end;
+ align-items: flex-end;
+}
+
+.message-row.assistant {
+ align-self: flex-start;
+ align-items: flex-start;
+}
+
+/* Message Bubble */
+.message-bubble {
+ padding: var(--space-md) var(--space-lg);
+ border-radius: var(--radius-lg);
+ font-size: var(--text-base);
+ line-height: 1.7;
+ word-break: break-word;
+}
+
+.message-bubble.user {
+ background: var(--color-accent);
+ color: var(--color-text-inverse);
+ border-bottom-right-radius: var(--radius-sm);
+}
+
+.message-bubble.assistant {
+ background: var(--color-bg-secondary);
+ color: var(--color-text-primary);
+ border: 1px solid var(--color-border-light);
+ border-bottom-left-radius: var(--radius-sm);
+ box-shadow: var(--shadow-xs);
+}
+
+/* Message Meta */
+.message-meta {
+ display: flex;
+ gap: var(--space-sm);
+ padding: 4px var(--space-sm) 0;
+ font-size: 11px;
+ color: var(--color-text-disabled);
+}
+
+.message-meta.user {
+ justify-content: flex-end;
+}
+
+/* Markdown Rendering */
+.markdown-body {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.message-bubble.user .markdown-body {
+ gap: 4px;
+}
+
+.md-code-block {
+ background: rgba(0, 0, 0, 0.08);
+ border-radius: var(--radius-sm);
+ padding: var(--space-md);
+ overflow-x: auto;
+ font-family: var(--font-mono);
+ font-size: var(--text-sm);
+ line-height: 1.5;
+ margin: 4px 0;
+}
+
+.message-bubble.user .md-code-block {
+ background: rgba(255, 255, 255, 0.15);
+}
+
+.md-inline-code {
+ background: rgba(0, 0, 0, 0.07);
+ padding: 1px 5px;
+ border-radius: 3px;
+ font-family: var(--font-mono);
+ font-size: var(--text-sm);
+}
+
+.message-bubble.user .md-inline-code {
+ background: rgba(255, 255, 255, 0.15);
+}
+
+.md-h2 {
+ font-size: var(--text-lg);
+ font-weight: 700;
+ margin-top: 8px;
+ color: inherit;
+}
+
+.md-h3 {
+ font-size: var(--text-md);
+ font-weight: 600;
+ margin-top: 6px;
+ color: inherit;
+}
+
+.md-h4 {
+ font-size: var(--text-base);
+ font-weight: 600;
+ color: inherit;
+}
+
+.md-li {
+ margin-left: 1.2em;
+ line-height: 1.6;
+}
+
+.md-table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 12px 0;
+ font-size: var(--text-sm);
+ background: #fff;
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
+}
+
+.md-table thead {
+ background: linear-gradient(135deg, #5B5FED 0%, #7B7FEE 100%);
+ color: #fff;
+}
+
+.md-table th {
+ padding: 10px 14px;
+ font-weight: 600;
+ text-align: left;
+ font-size: var(--text-xs);
+ letter-spacing: 0.3px;
+}
+
+.md-table td {
+ padding: 10px 14px;
+ border-bottom: 1px solid #EEF0F4;
+ color: var(--color-text-primary);
+ vertical-align: top;
+}
+
+.md-table tbody tr:last-child td {
+ border-bottom: none;
+}
+
+.md-table tbody tr:hover {
+ background: #F8F9FC;
+}
+
+.md-table tbody tr:nth-child(even) {
+ background: #FAFBFD;
+}
+
+.md-table tbody tr:nth-child(even):hover {
+ background: #F0F2F7;
+}
+
+.message-bubble.user .md-table {
+ background: rgba(255, 255, 255, 0.08);
+ color: inherit;
+}
+.message-bubble.user .md-table th,
+.message-bubble.user .md-table td {
+ color: inherit;
+ border-color: rgba(255, 255, 255, 0.15);
+}
+.message-bubble.user .md-table tbody tr:hover,
+.message-bubble.user .md-table tbody tr:nth-child(even) {
+ background: rgba(255, 255, 255, 0.05);
+}
+
+/* 数学公式 */
+.md-math-block {
+ display: block;
+ overflow-x: auto;
+ padding: 10px 0;
+ text-align: center;
+}
+.md-math-inline {
+ display: inline;
+ padding: 0 2px;
+}
+
+/* Mermaid 图表 */
+.md-mermaid {
+ display: flex;
+ justify-content: center;
+ margin: 16px 0;
+ padding: 12px;
+ background: #f8f9fb;
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--color-border);
+ overflow-x: auto;
+}
+.md-mermaid-rendered svg {
+ max-width: 100%;
+ height: auto;
+}
+
+/* 代码块包装器 + 复制按钮 */
+.md-code-wrapper {
+ position: relative;
+ margin: 12px 0;
+}
+.md-code-wrapper .md-code-block {
+ margin: 0;
+ border-radius: var(--radius-md);
+}
+.md-code-copy {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ padding: 4px 10px;
+ font-size: 12px;
+ color: #6B7280;
+ background: #F3F4F6;
+ border: 1px solid #E5E7EB;
+ border-radius: 4px;
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity 0.2s;
+ z-index: 2;
+}
+.md-code-wrapper:hover .md-code-copy {
+ opacity: 1;
+}
+
+/* 删除线 */
+del {
+ text-decoration: line-through;
+ opacity: 0.6;
+}
+
+.md-hr {
+ border: none;
+ border-top: 1px solid var(--color-border);
+ margin: 8px 0;
+}
+
+.message-bubble.user .md-hr {
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
+.message-bubble.user .md-code-block {
+ background: rgba(0, 0, 0, 0.2);
+}
+
+/* ===== Input Area ===== */
+.chat-input-area {
+ padding: var(--space-md) var(--space-xl) var(--space-xl);
+ background: var(--color-bg-primary);
+ overflow: visible;
+}
+
+.chat-input-container {
+ background: var(--color-bg-secondary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ padding: var(--space-sm);
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
+}
+
+.chat-input-container:focus-within {
+ border-color: var(--color-accent);
+ box-shadow: 0 0 0 3px var(--color-accent-light);
+}
+
+.input-attachments {
+ display: flex;
+ gap: 4px;
+ padding: 0 var(--space-sm) 4px;
+}
+
+.attach-btn {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 10px;
+ border-radius: var(--radius-sm);
+ font-size: var(--text-xs);
+ color: var(--color-text-secondary);
+ background: var(--color-bg-tertiary);
+ border: 1px solid var(--color-border);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ font-weight: 500;
+}
+
+.attach-btn:hover {
+ background: var(--color-accent-subtle);
+ border-color: var(--color-accent);
+ color: var(--color-accent);
+}
+
+.input-row {
+ display: flex;
+ align-items: flex-end;
+ gap: var(--space-sm);
+ padding: 0 var(--space-sm);
+}
+
+.chat-textarea {
+ flex: 1;
+ border: none;
+ outline: none;
+ resize: none;
+ font-size: var(--text-base);
+ line-height: 1.6;
+ color: var(--color-text-primary);
+ background: transparent;
+ padding: var(--space-xs) 0;
+ max-height: 160px;
+ font-family: var(--font-sans);
+}
+
+.chat-textarea::placeholder {
+ color: var(--color-text-disabled);
+}
+
+.send-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 34px;
+ height: 34px;
+ border-radius: var(--radius-md);
+ background: var(--color-accent);
+ color: var(--color-text-inverse);
+ border: none;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ flex-shrink: 0;
+ margin-bottom: 2px;
+}
+
+.send-btn:hover:not(:disabled) {
+ background: var(--color-accent-hover);
+ transform: scale(1.05);
+}
+
+.send-btn:active:not(:disabled) {
+ transform: scale(0.95);
+}
+
+.send-btn:disabled {
+ background: var(--color-bg-tertiary);
+ color: var(--color-text-disabled);
+ cursor: not-allowed;
+}
+
+/* ===== Dropdown ===== */
+.dropdown-wrapper {
+ position: relative;
+ display: inline-flex;
+}
+
+.dropdown-trigger {
+ cursor: pointer;
+}
+
+.dropdown-menu {
+ position: absolute;
+ top: calc(100% + 4px);
+ z-index: 9999;
+ background: var(--color-bg-secondary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-lg);
+ padding: var(--space-xs);
+ min-width: 180px;
+ max-height: 320px;
+ overflow-y: auto;
+ animation: fadeIn var(--transition-fast);
+}
+
+/* Upward-opening dropdown for bottom-of-page elements */
+.dropdown-menu.dropup {
+ top: auto;
+ bottom: calc(100% + 4px);
+}
+
+.dropdown-menu.align-left {
+ left: 0;
+}
+
+.dropdown-menu.align-right {
+ right: 0;
+}
+
+.dropdown-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ width: 100%;
+ padding: var(--space-sm) var(--space-md);
+ border-radius: var(--radius-sm);
+ font-size: var(--text-sm);
+ color: var(--color-text-secondary);
+ background: none;
+ border: none;
+ cursor: pointer;
+ transition: background var(--transition-fast);
+ text-align: left;
+ white-space: nowrap;
+}
+
+.dropdown-item:hover {
+ background: var(--color-bg-hover);
+ color: var(--color-text-primary);
+}
+
+.dropdown-item.selected {
+ color: var(--color-accent);
+ background: var(--color-accent-subtle);
+ font-weight: 500;
+}
+
+.dropdown-item.danger {
+ color: var(--color-error);
+}
+
+.dropdown-item.danger:hover {
+ background: var(--color-error-light);
+}
+
+.dropdown-item.disabled {
+ color: var(--color-text-disabled);
+ cursor: default;
+ pointer-events: none;
+}
+
+.dropdown-divider {
+ height: 1px;
+ background: var(--color-border-light);
+ margin: var(--space-xs) 0;
+}
+
+.model-dropdown-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ width: 100%;
+}
+
+.model-provider-tag {
+ font-size: 10px;
+ color: var(--color-text-tertiary);
+ margin-left: auto;
+ background: var(--color-bg-tertiary);
+ padding: 1px 6px;
+ border-radius: var(--radius-full);
+}
+
+.attach-dropdown-title {
+ padding: var(--space-xs) var(--space-md) var(--space-sm);
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--color-text-tertiary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.attach-count {
+ margin-left: auto;
+ font-size: 11px;
+ color: var(--color-text-disabled);
+}
+
+/* ===== Responsive ===== */
+@media (max-width: 768px) {
+ .chat-sidebar {
+ position: fixed;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ z-index: var(--z-overlay);
+ transform: translateX(-100%);
+ transition: transform var(--transition-slow);
+ box-shadow: var(--shadow-lg);
+ }
+
+ .message-row {
+ max-width: 90%;
+ }
+
+ .chat-header {
+ padding: 0 var(--space-md);
+ }
+
+ .chat-messages {
+ padding: var(--space-md);
+ }
+
+ .chat-input-area {
+ padding: var(--space-sm) var(--space-md) var(--space-md);
+ }
+}
+
+/* ===== Knowledge Graph Conversion Modal ===== */
+.kg-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(4px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: var(--z-overlay);
+ animation: fadeIn 0.2s ease;
+}
+
+.kg-modal {
+ background: var(--color-card-bg);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-xl);
+ width: 420px;
+ max-width: 90vw;
+ overflow: hidden;
+}
+
+.kg-modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-xl) var(--space-2xl);
+ border-bottom: 1px solid var(--color-border-light);
+}
+
+.kg-modal-header h3 {
+ font-size: var(--text-lg);
+ font-weight: 700;
+ color: var(--color-text-primary);
+}
+
+.kg-modal-close {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: var(--radius-full);
+ border: none;
+ background: transparent;
+ color: var(--color-text-tertiary);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.kg-modal-close:hover {
+ background: var(--color-bg-hover);
+ color: var(--color-text-primary);
+}
+
+.kg-modal-body {
+ padding: var(--space-2xl);
+}
+
+/* Progress State */
+.kg-progress-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-xl);
+}
+
+.kg-steps {
+ display: flex;
+ align-items: center;
+ gap: 0;
+ width: 100%;
+}
+
+.kg-step {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-sm);
+}
+
+.kg-step-indicator {
+ width: 36px;
+ height: 36px;
+ border-radius: var(--radius-full);
+ border: 2px solid var(--color-border);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: var(--text-sm);
+ font-weight: 600;
+ color: var(--color-text-tertiary);
+ background: var(--color-card-bg);
+ transition: all var(--transition-normal);
+}
+
+.kg-step.active .kg-step-indicator {
+ border-color: var(--color-accent);
+ color: var(--color-accent);
+ background: var(--color-accent-light);
+}
+
+.kg-step.done .kg-step-indicator {
+ border-color: var(--color-success);
+ color: var(--color-success);
+ background: var(--color-success-light, rgba(34, 197, 94, 0.1));
+}
+
+.kg-step-label {
+ font-size: var(--text-xs);
+ color: var(--color-text-tertiary);
+ white-space: nowrap;
+}
+
+.kg-step.active .kg-step-label {
+ color: var(--color-accent);
+ font-weight: 600;
+}
+
+.kg-step.done .kg-step-label {
+ color: var(--color-success);
+}
+
+.kg-step-connector {
+ flex: 1;
+ padding-bottom: 20px;
+}
+
+.kg-connector-line {
+ height: 2px;
+ background: var(--color-border);
+ border-radius: 1px;
+ position: relative;
+ overflow: hidden;
+}
+
+.kg-connector-line.done {
+ background: var(--color-success);
+}
+
+.kg-connector-progress {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ width: 70%;
+ background: var(--color-accent);
+ border-radius: 1px;
+ animation: kgProgressPulse 1s ease-in-out infinite;
+}
+
+@keyframes kgProgressPulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+}
+
+.kg-progress-tip {
+ font-size: var(--text-sm);
+ color: var(--color-text-secondary);
+}
+
+/* Spinner */
+.kg-spinner {
+ width: 16px;
+ height: 16px;
+ border: 2px solid transparent;
+ border-top-color: var(--color-accent);
+ border-radius: var(--radius-full);
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* Success State */
+.kg-success-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-lg);
+}
+
+.kg-success-icon {
+ margin-bottom: var(--space-sm);
+}
+
+.kg-success-title {
+ font-size: var(--text-lg);
+ font-weight: 700;
+ color: var(--color-text-primary);
+}
+
+.kg-result-stats {
+ display: flex;
+ align-items: center;
+ gap: var(--space-xl);
+ padding: var(--space-lg) var(--space-2xl);
+ background: var(--color-bg-subtle);
+ border-radius: var(--radius-lg);
+}
+
+.kg-stat-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-xs);
+}
+
+.kg-stat-value {
+ font-size: var(--text-2xl);
+ font-weight: 800;
+ color: var(--color-accent);
+}
+
+.kg-stat-label {
+ font-size: var(--text-xs);
+ color: var(--color-text-tertiary);
+}
+
+.kg-stat-divider {
+ width: 1px;
+ height: 32px;
+ background: var(--color-border);
+}
+
+.kg-result-kb {
+ font-size: var(--text-sm);
+ color: var(--color-text-secondary);
+}
+
+.kg-actions {
+ display: flex;
+ gap: var(--space-md);
+ margin-top: var(--space-sm);
+}
+
+/* Error State */
+.kg-error-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-lg);
+}
+
+.kg-error-icon {
+ margin-bottom: var(--space-xs);
+}
+
+.kg-error-text {
+ font-size: var(--text-sm);
+ color: var(--color-error);
+ text-align: center;
+}
+
+/* Buttons */
+.kg-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-sm) var(--space-xl);
+ border-radius: var(--radius-md);
+ font-size: var(--text-sm);
+ font-weight: 600;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ border: none;
+}
+
+.kg-btn-primary {
+ background: var(--color-accent);
+ color: var(--color-text-inverse);
+}
+
+.kg-btn-primary:hover {
+ background: var(--color-accent-hover);
+}
+
+.kg-btn-secondary {
+ background: var(--color-bg-hover);
+ color: var(--color-text-secondary);
+}
+
+.kg-btn-secondary:hover {
+ background: var(--color-border);
+ color: var(--color-text-primary);
+}
+
+/* ===== Evolution Skill Tags (below assistant messages) ===== */
+.evo-skill-tags-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-xs);
+ margin-top: var(--space-xs);
+ padding-left: 4px;
+}
+
+.evo-skill-tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 10px;
+ border-radius: var(--radius-full);
+ background: var(--color-accent-subtle);
+ color: var(--color-accent);
+ font-size: 11px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ border: 1px solid transparent;
+ font-family: var(--font-mono);
+}
+
+.evo-skill-tag:hover {
+ background: var(--color-accent-light);
+ border-color: var(--color-accent);
+ transform: translateY(-1px);
+}
+
+.evo-skill-tag-more {
+ background: var(--color-bg-tertiary);
+ color: var(--color-text-tertiary);
+ cursor: default;
+ font-family: var(--font-sans);
+}
+
+.evo-skill-tag-more:hover {
+ background: var(--color-bg-tertiary);
+ border-color: transparent;
+ transform: none;
+}
+
+/* ===== AI 生成媒体:固定缩略图 + 下载 ===== */
+.md-text-seg {
+ display: block;
+ width: 100%;
+}
+
+.chat-media-wrap {
+ position: relative;
+ display: inline-block;
+ max-width: 100%;
+ margin: 2px 0;
+}
+
+.chat-media-img {
+ display: block;
+ max-width: 320px;
+ max-height: 320px;
+ width: auto;
+ height: auto;
+ object-fit: contain;
+ border-radius: var(--radius-md);
+ background: var(--color-bg-tertiary);
+ cursor: zoom-in;
+ box-shadow: var(--shadow-xs);
+}
+
+.chat-media-video {
+ display: block;
+ max-width: 360px;
+ max-height: 320px;
+ width: auto;
+ height: auto;
+ border-radius: var(--radius-md);
+ background: #000;
+ cursor: zoom-in;
+ box-shadow: var(--shadow-xs);
+}
+
+.chat-media-bar {
+ margin-top: 6px;
+}
+
+.chat-media-link {
+ font-size: var(--text-xs);
+ color: var(--color-accent);
+ text-decoration: none;
+}
+
+.chat-media-link:hover {
+ text-decoration: underline;
+}
+
+/* 下载按钮 */
+.chat-media-dl-btn {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ width: 30px;
+ height: 30px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ border-radius: var(--radius-full);
+ background: rgba(0, 0, 0, 0.55);
+ color: #fff;
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity var(--transition-fast), background var(--transition-fast);
+ z-index: 2;
+}
+
+.chat-media-wrap:hover .chat-media-dl-btn {
+ opacity: 1;
+}
+
+.chat-media-dl-btn:hover {
+ background: rgba(0, 0, 0, 0.75);
+}
+
+/* 下载弹层 */
+.chat-media-dl-pop {
+ position: absolute;
+ top: 44px;
+ right: 8px;
+ z-index: 20;
+ width: 220px;
+ padding: var(--space-md);
+ background: var(--color-card-bg);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-lg);
+ animation: fadeIn var(--transition-fast);
+}
+
+.chat-media-dl-title {
+ font-size: var(--text-sm);
+ font-weight: 600;
+ color: var(--color-text-primary);
+ margin-bottom: var(--space-sm);
+}
+
+.chat-media-dl-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ margin-bottom: var(--space-xs);
+}
+
+.chat-media-dl-label {
+ font-size: var(--text-xs);
+ color: var(--color-text-tertiary);
+ margin-bottom: 4px;
+}
+
+.chat-media-dl-fmts {
+ display: flex;
+ gap: 4px;
+}
+
+.chat-media-dl-fmt {
+ flex: 1;
+ padding: 4px 0;
+ font-size: var(--text-xs);
+ font-weight: 600;
+ color: var(--color-text-secondary);
+ background: var(--color-bg-tertiary);
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.chat-media-dl-fmt.active {
+ color: var(--color-text-inverse);
+ background: var(--color-accent);
+ border-color: var(--color-accent);
+}
+
+.chat-media-dl-sizes {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.chat-media-dl-size {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 10px;
+ background: var(--color-bg-secondary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.chat-media-dl-size:hover {
+ border-color: var(--color-accent);
+ background: var(--color-accent-subtle);
+}
+
+.chat-media-dl-size-label {
+ font-size: var(--text-sm);
+ font-weight: 500;
+ color: var(--color-text-primary);
+}
+
+.chat-media-dl-size-px {
+ font-size: var(--text-xs);
+ color: var(--color-text-tertiary);
+ font-family: var(--font-mono);
+}
+
+/* 灯箱预览 */
+.chat-lightbox {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.78);
+ backdrop-filter: blur(4px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: var(--z-overlay);
+ animation: fadeIn 0.2s ease;
+}
+
+.chat-lightbox-close {
+ position: absolute;
+ top: 18px;
+ right: 22px;
+ width: 40px;
+ height: 40px;
+ border-radius: var(--radius-full);
+ border: none;
+ background: rgba(255, 255, 255, 0.15);
+ color: #fff;
+ font-size: 24px;
+ line-height: 1;
+ cursor: pointer;
+ transition: background var(--transition-fast);
+}
+
+.chat-lightbox-close:hover {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+.chat-lightbox-content {
+ 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);
+}
diff --git a/frontend/src/pages/front/ChatPage.tsx b/frontend/src/pages/front/ChatPage.tsx
index 6857996..58a402e 100644
--- a/frontend/src/pages/front/ChatPage.tsx
+++ b/frontend/src/pages/front/ChatPage.tsx
@@ -1,1719 +1,1751 @@
-import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
-import { useParams, useNavigate } from 'react-router-dom';
-import { chatAPI, knowledgeAPI, marketAPI, evolutionAPI } from '@/api';
-import type { Conversation, Message } from '@/types';
-import './ChatPage.css';
-
-// ===== 增强渲染依赖 =====
-import hljs from 'highlight.js';
-import 'highlight.js/styles/github.css';
-import katex from 'katex';
-import 'katex/dist/katex.min.css';
-
-// ===== Enhanced Markdown Renderer =====
-// 媒体(图片/视频)以受限缩略图呈现,并提供下载(格式+尺寸)选项
-interface MediaInfo { type: 'img' | 'video'; src: string; alt?: string; }
-
-// 全屏灯箱预览
-const ChatLightbox: React.FC<{ src: string; type: 'img' | 'video'; onClose: () => void }> = ({ src, type, onClose }) => {
- useEffect(() => {
- const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
- document.addEventListener('keydown', h);
- return () => document.removeEventListener('keydown', h);
- }, [onClose]);
- return (
-
-
-
e.stopPropagation()}>
- {type === 'video'
- ?
- :

}
-
-
- );
-};
-
-// 根据原始尺寸推算 3 个建议下载尺寸(保持宽高比)
-function computeSizes(natural: { w: number; h: number }) {
- if (natural.w && natural.h) {
- const ratio = natural.w / natural.h;
- const fit = (maxDim: number) => ratio >= 1
- ? { w: maxDim, h: Math.round(maxDim / ratio) }
- : { w: Math.round(maxDim * ratio), h: maxDim };
- return [
- { label: '小图', ...fit(512) },
- { label: '中图', ...fit(1024) },
- { label: '原始', w: natural.w, h: natural.h },
- ];
- }
- // 未知原始尺寸时按生成默认 4:3 给出三档
- return [
- { label: '小图', w: 512, h: 384 },
- { label: '中图', w: 1024, h: 768 },
- { label: '大图', w: 2048, h: 1536 },
- ];
-}
-
-// 客户端画布转换格式+尺寸后下载;跨域受限时回退到新标签页打开原图
-function downloadImage(src: string, format: 'png' | 'jpg' | 'webp', w: number, h: number) {
- const fallback = () => window.open(src, '_blank');
- const img = new Image();
- img.crossOrigin = 'anonymous';
- img.onload = () => {
- try {
- const canvas = document.createElement('canvas');
- canvas.width = w; canvas.height = h;
- const ctx = canvas.getContext('2d');
- if (!ctx) { fallback(); return; }
- if (format === 'jpg') { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, w, h); }
- ctx.drawImage(img, 0, 0, w, h);
- const mime = format === 'jpg' ? 'image/jpeg' : format === 'webp' ? 'image/webp' : 'image/png';
- canvas.toBlob((blob) => {
- if (!blob) { fallback(); return; }
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url; a.download = `aiflow-image-${w}x${h}.${format}`;
- document.body.appendChild(a); a.click(); a.remove();
- setTimeout(() => URL.revokeObjectURL(url), 1000);
- }, mime, 0.92);
- } catch (e) {
- fallback();
- }
- };
- img.onerror = fallback;
- img.src = src;
-}
-
-// 媒体渲染组件:固定尺寸缩略图 + 点击放大 + 下载(格式/尺寸)
-const ChatMedia: React.FC<{ info: MediaInfo }> = ({ info }) => {
- const [open, setOpen] = useState(false);
- const [dlOpen, setDlOpen] = useState(false);
- const [fmt, setFmt] = useState<'png' | 'jpg' | 'webp'>('png');
- const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 0, h: 0 });
-
- if (info.type === 'video') {
- return (
-
-
- );
- }
-
- const sizes = computeSizes(natural);
- return (
-
-

setOpen(true)}
- onLoad={(e) => {
- const im = e.currentTarget;
- if (im.naturalWidth && im.naturalHeight) setNatural({ w: im.naturalWidth, h: im.naturalHeight });
- }}
- />
-
- {dlOpen && (
-
e.stopPropagation()}>
-
下载图片
-
-
格式
-
- {(['png', 'jpg', 'webp'] as const).map((f) => (
-
- ))}
-
-
-
尺寸
-
- {sizes.map((s, i) => (
-
- ))}
-
-
- )}
- {open &&
setOpen(false)} />}
-
- );
-};
-
-const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
- const nodes = useMemo(() => {
- let text = content;
-
- // 1. Extract code blocks first (protect from other replacements)
- const codeBlocks: string[] = [];
- text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_m: string, _lang: string, code: string) => {
- const idx = codeBlocks.length;
- codeBlocks.push('' + escapeHtml(code.trim()) + '
');
- return '\x00CODEBLOCK' + idx + '\x00';
- });
-
- // 2. Extract inline code
- const inlineCodes: string[] = [];
- text = text.replace(/`([^`]+)`/g, (_m: string, code: string) => {
- const idx = inlineCodes.length;
- inlineCodes.push('' + escapeHtml(code) + '');
- return '\x00INLINE' + idx + '\x00';
- });
-
- // 2.5 Extract media blocks (
/
');
- text = text.replace(/\n/g, '
');
-
- // 10. Restore code blocks and inline codes
- text = text.replace(/\x00CODEBLOCK(\d+)\x00/g, (_m: string, idx: string) => codeBlocks[parseInt(idx)] || '');
- text = text.replace(/\x00INLINE(\d+)\x00/g, (_m: string, idx: string) => inlineCodes[parseInt(idx)] || '');
- text = text.replace(/\x00TABLE(\d+)\x00/g, (_m: string, idx: string) => tableBlocks[parseInt(idx)] || '');
- text = text.replace(/\x00MATHBLOCK(\d+)\x00/g, (_m: string, idx: string) => mathBlocks[parseInt(idx)] || '');
-
- // 10.5 Apply syntax highlighting to code blocks + add copy button
- text = text.replace(
- /
([\s\S]*?)<\/code><\/pre>/g,
- (_m: string, code: string) => {
- const decoded = code.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
- let highlighted: string;
- try { highlighted = hljs.highlightAuto(decoded).value; } catch { highlighted = code; }
- return ``;
- }
- );
-
- // 11. Wrap in paragraph and clean up
- text = '' + text + '
';
- text = text.replace(/<\/p>/g, '');
- text = text.replace(/
()<\/p>/g, '$1');
- text = text.replace(/(
)<\/p>/g, '$1');
- text = text.replace(/(
)<\/p>/g, '$1');
- text = text.replace(/(
/g, '$1');
- text = text.replace(/(
)<\/p>/g, '$1');
- text = text.replace(/(
)<\/p>/g, '$1');
-
- // 12. 拆分为文本片段与媒体组件
- const parts = text.split(/(\x00MEDIA\d+\x00)/g);
- return parts.map((part: string, i: number) => {
- const m = part.match(/^\x00MEDIA(\d+)\x00$/);
- if (m) {
- const info = mediaBlocks[parseInt(m[1])];
- if (info && info.src) return ;
- return null;
- }
- if (!part) return null;
- // 仅清除被媒体切分产生的悬空 标签,保留段落内部的成对
- let s = part.replace(/^<\/p>/, '').replace(/
$/, '');
- if (!s.trim()) return null;
- return
;
- });
- }, [content]);
-
- return {nodes}
;
-};
-
-// 解析后端返回的「模糊创作意图」确认指令:<>{json}<>
-function parseClarify(content: string): { prompt: string; options: any[] } | null {
- if (!content || typeof content !== 'string') return null;
- const m = content.match(/^<>([\s\S]+)<>$/);
- 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 (
-
-
🤔 这个请求的方向还不太明确,您希望以哪种形式呈现?
-
「{data.prompt}」
-
- {data.options.map((opt: any) => (
-
- ))}
-
-
- );
-};
-
-
-function escapeHtml(text: string): string {
- return text
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"');
-}
-
-// ===== Time Formatting =====
-function formatTime(iso: string): string {
- const date = new Date(iso);
- const now = new Date();
- const diff = now.getTime() - date.getTime();
- const oneDay = 24 * 60 * 60 * 1000;
-
- if (diff < oneDay) {
- return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
- } else if (diff < 7 * oneDay) {
- const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
- return days[date.getDay()];
- } else if (date.getFullYear() === now.getFullYear()) {
- return `${date.getMonth() + 1}/${date.getDate()}`;
- } else {
- return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
- }
-}
-
-// ===== Helpers: normalize API responses =====
-function unwrapList(res: any): any[] {
- const data = res?.data !== undefined ? res.data : res;
- if (Array.isArray(data)) return data;
- if (data?.items) return data.items;
- return [];
-}
-
-function unwrapData(res: any): any {
- return res?.data !== undefined ? res.data : res;
-}
-
-// ===== Icons =====
-const IconSearch = () => (
-
-);
-
-const IconPlus = () => (
-
-);
-
-const IconMore = () => (
-
-);
-
-const IconSend = () => (
-
-);
-
-const IconChevronDown = () => (
-
-);
-
-const IconEdit = () => (
-
-);
-
-const IconArchive = () => (
-
-);
-
-const IconTrash = () => (
-
-);
-
-const IconPin = () => (
-
-);
-
-const IconGraph = () => (
-
-);
-
-const IconKnowledge = () => (
-
-);
-
-const IconAgent = () => (
-
-);
-
-const IconSkill = () => (
-
-);
-
-const IconMcp = () => (
-
-);
-
-const IconPaperclip = () => (
-
-);
-
-const IconMic = () => (
-
-);
-
-const IconStop = () => (
-
-);
-
-const IconVolume = () => (
-
-);
-
-const IconModel = () => (
-
-);
-
-const IconEmpty = () => (
-
-);
-
-const IconSpinner = () => (
-
-);
-
-// ===== Dropdown Component =====
-interface DropdownProps {
- trigger: React.ReactNode;
- children: React.ReactNode;
- className?: string;
- align?: 'left' | 'right';
- dropup?: boolean;
- closeOnClick?: boolean;
-}
-
-const Dropdown: React.FC = ({ trigger, children, className = '', align = 'left', dropup = false, closeOnClick = true }) => {
- const [open, setOpen] = useState(false);
- const ref = useRef(null);
- const menuRef = useRef(null);
- const openRef = useRef(open);
- openRef.current = open;
-
- useEffect(() => {
- const handleClickOutside = (e: MouseEvent) => {
- const target = e.target as Node;
- // 如果点击在wrapper内部,不关闭
- if (ref.current?.contains(target)) return;
- setOpen(false);
- };
- document.addEventListener('mousedown', handleClickOutside, true);
- return () => document.removeEventListener('mousedown', handleClickOutside, true);
- }, []);
-
- // 打开时处理z-index
- useEffect(() => {
- if (open && dropup) {
- const menu = menuRef.current;
- if (menu) {
- menu.style.zIndex = '9999';
- }
- }
- }, [open, dropup]);
-
- const alignClass = align === 'right' ? 'align-right' : 'align-left';
- const dropupClass = dropup ? 'dropup' : '';
-
- const handleTriggerMouseDown = (e: React.MouseEvent) => {
- // 阻止冒泡到document,防止被handleClickOutside误关
- e.stopPropagation();
- // 基于当前状态切换(在mousedown阶段决定,避免与click冲突)
- setOpen(prev => !prev);
- };
-
- const handleTriggerClick = (e: React.MouseEvent) => {
- // 阻止click冒泡到父级的导航onClick
- e.stopPropagation();
- e.preventDefault();
- };
-
- return (
-
-
- {trigger}
-
- {open && (
-
e.stopPropagation()}
- onClick={(e) => { e.stopPropagation(); if (closeOnClick) setOpen(false); }}
- >
- {children}
-
- )}
-
- );
-};
-
-// ===== Main Component =====
-const ChatPage: React.FC = () => {
- const { id } = useParams<{ id?: string }>();
- const navigate = useNavigate();
- const [searchQuery, setSearchQuery] = useState('');
- const [selectedModelId, setSelectedModelId] = useState('auto');
- const [chatModels, setChatModels] = useState([]);
- const [inputValue, setInputValue] = useState('');
- const [sending, setSending] = useState(false);
- const [conversations, setConversations] = useState([]);
- const [messages, setMessages] = useState([]);
- const [loadingConversations, setLoadingConversations] = useState(true);
- const [loadingMessages, setLoadingMessages] = useState(false);
- const [knowledgeBases, setKnowledgeBases] = useState([]);
- const [agents, setAgents] = useState([]);
- const [skills, setSkills] = useState([]);
- const [mcps, setMcps] = useState([]);
- // 多选组合模式
- const [selectedAgentIds, setSelectedAgentIds] = useState([]);
- const [selectedSkillIds, setSelectedSkillIds] = useState([]);
- const [selectedKbIds, setSelectedKbIds] = useState([]);
- const [selectedMcpIds, setSelectedMcpIds] = useState([]);
- // 文件附件
- const [attachments, setAttachments] = useState<{ name: string; file_type: string; content?: string }[]>([]);
- const fileInputRef = useRef(null);
- const recognitionRef = useRef(null);
- const [recording, setRecording] = useState(false);
- const messagesEndRef = useRef(null);
- const inputRef = useRef(null);
-
- // Knowledge graph conversion state
- const [kgModalOpen, setKgModalOpen] = useState(false);
- const [kgStep, setKgStep] = useState<1 | 2 | 3>(1);
- const [kgResult, setKgResult] = useState<{ nodes: number; edges: number; kbName: string; kbId: string } | null>(null);
- const [kgError, setKgError] = useState(null);
- const [kgConvId, setKgConvId] = useState(null);
-
- // Load models from backend API
- useEffect(() => {
- chatAPI.getModels().then((res: any) => {
- const data = res.data || res;
- const models = Array.isArray(data) ? data : (data?.items || []);
- setChatModels(models);
- if (models.length > 0 && !selectedModelId) {
- setSelectedModelId('auto');
- }
- }).catch(() => {
- setChatModels([]);
- });
- }, []);
-
- // Load conversations from API
- const loadConversations = useCallback(async () => {
- try {
- setLoadingConversations(true);
- const res = await chatAPI.getConversations();
- const convs = unwrapList(res);
- // Sort: pinned first, then by updatedAt desc
- const sorted = [...convs].sort((a: any, b: any) => {
- if (a.pinned && !b.pinned) return -1;
- if (!a.pinned && b.pinned) return 1;
- return new Date(b.updatedAt || b.updated_at || 0).getTime() - new Date(a.updatedAt || a.updated_at || 0).getTime();
- });
- // Normalize field names
- const mapped: Conversation[] = sorted.map((c: any) => ({
- id: c.id,
- userId: c.userId || c.user_id || '',
- title: c.title || '未命名对话',
- modelId: c.modelId || c.model_id || '',
- status: c.status || 'active',
- pinned: c.pinned || false,
- knowledgeGraphCount: c.knowledgeGraphCount ?? c.knowledge_graph_count ?? 0,
- lastMessage: c.lastMessage || c.last_message || '',
- createdAt: c.createdAt || c.created_at || new Date().toISOString(),
- updatedAt: c.updatedAt || c.updated_at || new Date().toISOString(),
- }));
- setConversations(mapped);
- } catch (err) {
- console.error('加载对话列表失败', err);
- } finally {
- setLoadingConversations(false);
- }
- }, []);
-
- // Load conversations on mount
- useEffect(() => {
- loadConversations();
- }, [loadConversations]);
-
- // Load messages for selected conversation
- useEffect(() => {
- if (!id) {
- setMessages([]);
- return;
- }
- const loadMessages = async () => {
- try {
- setLoadingMessages(true);
- const res = await chatAPI.getMessages(id);
- const msgs = unwrapList(res);
- const mapped: Message[] = msgs.map((m: any) => ({
- id: m.id,
- conversationId: m.conversationId || m.conversation_id || id,
- role: m.role,
- content: m.content,
- tokenUsed: m.tokenUsed ?? m.token_used ?? 0,
- createdAt: m.createdAt || m.created_at || new Date().toISOString(),
- }));
- setMessages(mapped);
- // 页面刷新时若仍有「⏳」占位的媒体消息,继续轮询后端回写结果
- mapped.forEach((m) => {
- if (m.role === 'assistant' && typeof m.content === 'string' && m.content.startsWith('⏳')) {
- startMediaPolling(id, m.id);
- }
- });
- } catch (err) {
- console.error('加载消息失败', err);
- setMessages([]);
- } finally {
- setLoadingMessages(false);
- }
- };
- loadMessages();
- }, [id]);
-
- // 媒体(图像/视频)异步生成轮询:后端先返回「⏳」占位,前端定时拉取该消息最新内容并回写
- const pollMediaRef = useRef | null>>({});
-
- const stopMediaPolling = (msgId: string) => {
- const t = pollMediaRef.current[msgId];
- if (t) {
- clearInterval(t);
- pollMediaRef.current[msgId] = null;
- }
- };
-
- const startMediaPolling = (convId: string, msgId: string) => {
- if (pollMediaRef.current[msgId]) return; // 已在轮询
- let ticks = 0;
- const timer = setInterval(async () => {
- ticks += 1;
- try {
- const res = await chatAPI.getMessages(convId);
- const msgs: any[] = unwrapList(res);
- const updated = msgs.find((m: any) => m.id === msgId);
- if (!updated) {
- stopMediaPolling(msgId);
- return;
- }
- const content: string = updated.content || '';
- if (content.startsWith('⏳')) {
- if (ticks > 120) stopMediaPolling(msgId); // ~6 分钟兜底
- return;
- }
- // 生成完成:回写消息内容
- setMessages(prev => prev.map(m => m.id === msgId ? {
- ...m,
- content,
- tokenUsed: updated.tokenUsed ?? updated.token_used ?? m.tokenUsed,
- createdAt: updated.createdAt || updated.created_at || m.createdAt,
- used_skills: updated.usedSkills ?? updated.used_skills ?? m.used_skills,
- used_mcp: updated.usedMcp ?? updated.used_mcp ?? m.used_mcp,
- } : m));
- stopMediaPolling(msgId);
- } catch (e) {
- if (ticks > 120) stopMediaPolling(msgId); // 网络异常兜底
- }
- }, 3000);
- pollMediaRef.current[msgId] = timer;
- };
-
- // 卸载时清理所有轮询定时器
- useEffect(() => {
- return () => {
- Object.values(pollMediaRef.current).forEach(t => t && clearInterval(t));
- };
- }, []);
-
- // Load knowledge bases, agents, skills for attachment dropdowns
- useEffect(() => {
- knowledgeAPI.getBases().then((res: any) => {
- const data = res?.data !== undefined ? res.data : res;
- if (data?.bases && Array.isArray(data.bases)) {
- setKnowledgeBases(data.bases);
- } else if (Array.isArray(data)) {
- setKnowledgeBases(data);
- } else if (data?.items) {
- setKnowledgeBases(data.items);
- } else {
- setKnowledgeBases([]);
- }
- }).catch(() => {});
- marketAPI.getApps({ type: 'agent' }).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;
- // 显示所有已发布的Agent(不需要安装即可在聊天中使用)
- setAgents(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status));
- }).catch(() => {});
- marketAPI.getApps({ type: 'skill' }).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;
- // 显示所有已发布的技能(不需要安装即可在聊天中使用)
- 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
- const selectedConversation = useMemo(() => {
- if (id) {
- return conversations.find(c => c.id === id) || null;
- }
- return conversations[0] || null;
- }, [id, conversations]);
-
- // Filter conversations by search
- const filteredConversations = useMemo(() => {
- const pinned = conversations.filter(c => c.pinned);
- const unpinned = conversations.filter(c => !c.pinned);
- if (!searchQuery.trim()) return [...pinned, ...unpinned];
- const q = searchQuery.toLowerCase();
- return [...pinned.filter(c => c.title.toLowerCase().includes(q)), ...unpinned.filter(c => c.title.toLowerCase().includes(q))];
- }, [conversations, searchQuery]);
-
- // Selected model info(Auto 自动:按对话意图自动匹配后台各能力默认模型)
- const isAutoModel = selectedModelId === 'auto';
- const AUTO_MODEL = { id: 'auto', name: 'Auto 自动', status: 'free', provider_name: '按能力智能匹配', capabilities: [], price_per_million_tokens: 0 };
- const selectedModel = isAutoModel
- ? AUTO_MODEL
- : (chatModels.find(m => m.id === selectedModelId) || chatModels[0] || AUTO_MODEL);
-
- // Scroll to bottom when messages change
- useEffect(() => {
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
- }, [messages]);
-
- // Select first conversation if no id param
- useEffect(() => {
- if (!id && conversations.length > 0) {
- navigate(`/chat/${conversations[0].id}`, { replace: true });
- }
- }, [id, conversations, navigate]);
-
- // Handle new conversation
- const handleNewConversation = async () => {
- try {
- const res = await chatAPI.createConversation({ title: '新对话', model_id: selectedModelId === 'auto' ? (chatModels[0]?.id || '') : selectedModelId });
- const newConv = unwrapData(res);
- const conv: Conversation = {
- id: newConv.id,
- userId: newConv.userId || newConv.user_id || '',
- title: newConv.title || '新对话',
- modelId: newConv.modelId || newConv.model_id || selectedModelId,
- status: 'active',
- pinned: false,
- knowledgeGraphCount: 0,
- createdAt: newConv.createdAt || newConv.created_at || new Date().toISOString(),
- updatedAt: newConv.updatedAt || newConv.updated_at || new Date().toISOString(),
- };
- setConversations(prev => [conv, ...prev]);
- navigate(`/chat/${conv.id}`);
- } catch (err: any) {
- alert('创建对话失败: ' + (err.message || '未知错误'));
- }
- };
-
- // Handle rename
- const handleRename = async (convId: string, e: React.MouseEvent) => {
- e.stopPropagation();
- const newTitle = prompt('请输入新名称:');
- if (newTitle && newTitle.trim()) {
- try {
- await chatAPI.updateConversation(convId, { title: newTitle.trim() });
- setConversations(prev => prev.map(c => c.id === convId ? { ...c, title: newTitle.trim() } : c));
- } catch (err: any) {
- alert('重命名失败: ' + (err.message || '未知错误'));
- }
- }
- };
-
- // Handle archive
- const handleArchive = async (convId: string, e: React.MouseEvent) => {
- e.stopPropagation();
- const conv = conversations.find(c => c.id === convId);
- if (!conv) return;
- const newStatus = conv.status === 'archived' ? 'active' : 'archived';
- try {
- await chatAPI.updateConversation(convId, { status: newStatus });
- setConversations(prev => prev.map(c => c.id === convId ? { ...c, status: newStatus } as Conversation : c));
- } catch (err: any) {
- alert('操作失败: ' + (err.message || '未知错误'));
- }
- };
-
- // Handle delete
- const handleDelete = async (convId: string, e: React.MouseEvent) => {
- e.stopPropagation();
- if (!confirm('确定要删除这个对话吗?')) return;
- try {
- await chatAPI.deleteConversation(convId);
- const next = conversations.filter(c => c.id !== convId);
- setConversations(next);
- if (selectedConversation?.id === convId) {
- if (next.length > 0) {
- navigate(`/chat/${next[0].id}`);
- } else {
- navigate('/chat');
- }
- }
- } catch (err: any) {
- alert('删除失败: ' + (err.message || '未知错误'));
- }
- };
-
- // Handle pin / unpin
- const handlePin = async (convId: string, e: React.MouseEvent) => {
- e.stopPropagation();
- const conv = conversations.find(c => c.id === convId);
- if (!conv) return;
- const newPinned = !conv.pinned;
- try {
- await chatAPI.updateConversation(convId, { pinned: newPinned });
- setConversations(prev => {
- const updated = prev.map(c => c.id === convId ? { ...c, pinned: newPinned } : c);
- const pinned = updated.filter(c => c.pinned);
- const unpinned = updated.filter(c => !c.pinned).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
- return [...pinned, ...unpinned];
- });
- } catch (err: any) {
- alert('操作失败: ' + (err.message || '未知错误'));
- }
- };
-
- // Handle convert to knowledge graph
- const handleKnowledgeGraph = async (convId: string, e: React.MouseEvent) => {
- e.stopPropagation();
- setKgModalOpen(true);
- setKgStep(1);
- setKgResult(null);
- setKgError(null);
- setKgConvId(convId);
-
- try {
- await new Promise(r => setTimeout(r, 600));
- setKgStep(2);
-
- const res = await chatAPI.convertToKnowledgeGraph(convId);
- await new Promise(r => setTimeout(r, 400));
- setKgStep(3);
-
- if (res.code === 0) {
- const graph = res.data.graph;
- setKgResult({
- nodes: graph.stats.total_nodes,
- edges: graph.stats.total_edges,
- kbName: res.data.knowledge_base_name,
- kbId: res.data.knowledge_base_id,
- });
- // 使用后端返回的真实计数(幂等,始终为 1),避免前端重复累加导致计数虚高
- const newCount = res.data.knowledge_graph_count ?? 1;
- setConversations(prev =>
- prev.map(c => c.id === convId ? { ...c, knowledgeGraphCount: newCount } : c)
- );
- } else {
- setKgError(res.message || '转换失败');
- }
- } catch (err) {
- setKgError((err as Error).message || '转换失败,请稍后重试');
- }
- };
-
- // Handle send message - CALLS REAL API
- // 抽出公共发送逻辑,供输入框发送与「模糊创作意图」方向选择复用
- const sendText = async (content: string) => {
- if (!content.trim() || sending || !selectedConversation) return;
- setSending(true);
-
- // Add user message optimistically
- const tempUserMsg: Message = {
- id: `temp_${Date.now()}`,
- conversationId: selectedConversation?.id || '',
- role: 'user',
- content,
- tokenUsed: 0,
- createdAt: new Date().toISOString(),
- };
- setMessages(prev => [...prev, tempUserMsg]);
-
- try {
- const res = await chatAPI.sendMessage(selectedConversation!.id, {
- content,
- model_id: selectedModelId || undefined,
- agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined,
- skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : undefined,
- knowledge_base_ids: selectedKbIds.length > 0 ? selectedKbIds : undefined,
- 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);
-
- // Replace temporary user message and add assistant response
- setMessages(prev => {
- const filtered = prev.filter(m => m.id !== tempUserMsg.id);
- const newMsgs: Message[] = [];
- if (data.user_message) {
- newMsgs.push({
- id: data.user_message.id,
- conversationId: selectedConversation?.id || '',
- role: 'user',
- content: data.user_message.content,
- tokenUsed: data.user_message.token_used || 0,
- createdAt: data.user_message.created_at || new Date().toISOString(),
- });
- }
- if (data.assistant_message) {
- newMsgs.push({
- id: data.assistant_message.id,
- conversationId: selectedConversation?.id || '',
- role: 'assistant',
- content: data.assistant_message.content,
- tokenUsed: data.assistant_message.token_used || 0,
- createdAt: data.assistant_message.created_at || new Date().toISOString(),
- used_skills: data.assistant_message.used_skills || [],
- used_mcp: data.assistant_message.used_mcp || [],
- });
- }
- return [...filtered, ...newMsgs];
- });
-
- // 媒体异步生成:启动轮询,待后端回写后自动刷新
- if (data.assistant_message?.generating) {
- startMediaPolling(selectedConversation!.id, data.assistant_message.id);
- }
-
- // Refresh conversations to update lastMessage
- loadConversations();
- // 发送后清空本次附件(选择状态仍保留)
- setAttachments([]);
-
- // GAP-07: 对话结束后异步提交轨迹到进化引擎(后台静默,不阻塞UI)
- if (selectedConversation?.id && data.assistant_message) {
- const recentMsgs = [
- ...(data.user_message ? [{ role: 'user', content: data.user_message.content, tokens: data.user_message.token_used || 0 }] : []),
- { role: 'assistant', content: data.assistant_message.content, tokens: data.assistant_message.token_used || 0 },
- ];
- evolutionAPI.submitTrajectory({
- conversation_id: selectedConversation.id,
- messages: recentMsgs,
- }).catch(() => {});
- }
- } catch (err: any) {
- // Remove optimistic message on error
- setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id));
- alert('发送失败: ' + (err.message || err.detail || '未知错误'));
- } finally {
- setSending(false);
- inputRef.current?.focus();
- }
- };
-
- // 文件上传:读取文本类文件内容作为附件上下文
- const handleFileChange = (e: React.ChangeEvent) => {
- 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) {
- e.preventDefault();
- handleSend();
- }
- };
-
- return (
-
- {/* Left Sidebar - Conversation List */}
-
-
- {/* Right Main Chat Area */}
-
- {selectedConversation ? (
- <>
- {/* Chat Header */}
-
-
-
-
- {selectedModel.name}
- {(selectedModel.status === 'free' || selectedModel.price_per_million_tokens === 0) ? '免费' : '付费'}
-
-
- }
- >
-
- {chatModels.filter(m => m.is_enabled !== false).map(model => (
-
- ))}
-
-
-
{selectedConversation.title}
-
-
-
- {/* Messages Area */}
-
- {loadingMessages ? (
-
- ) : messages.length === 0 ? (
-
-
-
开始一段新对话
-
- 向 {selectedModel.name} 发送消息,开始对话
-
-
- ) : (
- messages.map(msg => {
- // Real used_skills from API response
- const usedSkills: string[] = (msg.role === 'assistant' && msg.used_skills) ? msg.used_skills : [];
- const usedMcp: string[] = (msg.role === 'assistant' && msg.used_mcp) ? msg.used_mcp : [];
- // 模糊创作意图确认卡片
- const clarify = msg.role === 'assistant' ? parseClarify(msg.content) : null;
- return (
-
-
- {clarify ? (
- sendText(`${opt.medium}:${clarify.prompt}`)}
- />
- ) : msg.role === 'assistant' ? (
-
- ) : (
- {msg.content}
- )}
-
- {!clarify && msg.role === 'assistant' && msg.content && (
-
- )}
- {/* Skill usage tags */}
- {msg.role === 'assistant' && usedSkills.length > 0 && (
-
- {usedSkills.slice(0, 3).map(skillName => (
-
{ window.location.href = '/evolution?skill=' + encodeURIComponent(skillName); }}
- title={`使用了专属技能:${skillName}`}
- >
-
- {skillName}
-
- ))}
- {usedSkills.length > 3 && (
-
+{usedSkills.length - 3}
- )}
-
- )}
- {msg.role === 'assistant' && usedMcp.length > 0 && (
-
- {usedMcp.slice(0, 3).map(mcpName => (
-
-
- {mcpName}
-
- ))}
- {usedMcp.length > 3 && (
- +{usedMcp.length - 3}
- )}
-
- )}
-
- {formatTime(msg.createdAt)}
- {msg.tokenUsed} tokens
-
-
- );
- })
- )}
- {sending && (
-
- )}
-
-
-
- {/* Input Area */}
-
-
-
-
-
- {selectedKbIds.length > 0 ? `知识库 (${selectedKbIds.length})` : '知识库'}
-
- }
- >
- 选择知识库(多选)
- {knowledgeBases.map((kb: any) => (
-
- ))}
- {knowledgeBases.length > 0 && selectedKbIds.length > 0 && (
-
- )}
- {selectedKbIds.length > 0 && (
-
- )}
- {knowledgeBases.length === 0 && (
- 暂无可用知识库
- )}
-
-
-
-
- {selectedAgentIds.length > 0 ? `Agent (${selectedAgentIds.length})` : 'Agent'}
-
- }
- >
- 选择 Agent(多选)
- {agents.map((agent: any) => (
-
- ))}
- {agents.length > 0 && selectedAgentIds.length > 0 && (
-
- )}
- {selectedAgentIds.length > 0 && (
-
- )}
- {agents.length === 0 && (
- 暂无可用 Agent
- )}
-
-
-
-
- {selectedSkillIds.length > 0 ? `技能 (${selectedSkillIds.length})` : '技能'}
-
- }
- >
- 选择技能(多选)
- {skills.map((skill: any) => (
-
- ))}
- {skills.length > 0 && selectedSkillIds.length > 0 && (
-
- )}
- {selectedSkillIds.length > 0 && (
-
- )}
- {skills.length === 0 && (
- 暂无可用技能
- )}
-
-
-
-
- {selectedMcpIds.length > 0 ? `MCP (${selectedMcpIds.length})` : 'MCP'}
-
- }
- >
- 选择 MCP 应用(多选)
- {mcps.map((mcp: any) => (
-
- ))}
- {mcps.length > 0 && selectedMcpIds.length > 0 && (
-
- )}
- {selectedMcpIds.length > 0 && (
-
- )}
- {mcps.length === 0 && (
- 暂无可用 MCP 应用
- )}
-
-
-
-
-
-
- {attachments.length > 0 && (
-
- {attachments.map((att, idx) => (
-
-
- {att.name}
-
-
- ))}
-
- )}
-
-
-
-
-
- >
- ) : (
-
-
-
选择或创建对话
-
从左侧列表选择一个对话,或点击上方按钮创建新对话
-
- )}
-
-
- {/* Knowledge Graph Conversion Progress Modal */}
- {kgModalOpen && (
- !kgError && kgStep !== 2 && setKgModalOpen(false)}>
-
e.stopPropagation()}>
-
-
转换知识图谱
-
-
-
- {kgError ? (
-
-
-
-
-
{kgError}
-
-
- ) : kgStep === 3 && kgResult ? (
-
-
-
知识图谱生成完成
-
-
- {kgResult.nodes}
- 节点
-
-
-
- {kgResult.edges}
- 条边
-
-
-
已存入知识库:{kgResult.kbName}
-
-
-
-
-
- ) : (
-
-
-
= 1 ? 'active' : ''} ${kgStep > 1 ? 'done' : ''}`}>
-
- {kgStep > 1 ? (
-
- ) : kgStep === 1 ? (
-
- ) : (
-
1
- )}
-
-
提取对话内容
-
-
-
1 ? 'done' : ''}`}>
- {kgStep === 2 &&
}
-
-
-
= 2 ? 'active' : ''} ${kgStep > 2 ? 'done' : ''}`}>
-
- {kgStep > 2 ? (
-
- ) : kgStep === 2 ? (
-
- ) : (
-
2
- )}
-
-
构建知识图谱
-
-
-
2 ? 'done' : ''}`} />
-
-
= 3 ? 'active' : ''} ${kgStep > 3 ? 'done' : ''}`}>
-
- 3
-
-
完成
-
-
-
- {kgStep === 1 ? '正在提取对话内容...' : kgStep === 2 ? '正在构建知识图谱...' : '处理完成'}
-
-
- )}
-
-
-
- )}
-
- );
-};
-
-export default ChatPage;
+import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { chatAPI, knowledgeAPI, marketAPI, evolutionAPI } from '@/api';
+import type { Conversation, Message } from '@/types';
+import './ChatPage.css';
+
+// ===== 增强渲染依赖 =====
+import hljs from 'highlight.js';
+import 'highlight.js/styles/github.css';
+import katex from 'katex';
+import 'katex/dist/katex.min.css';
+import mermaid from 'mermaid';
+
+mermaid.initialize({ startOnLoad: false, theme: 'default' });
+
+// ===== Enhanced Markdown Renderer =====
+// 媒体(图片/视频)以受限缩略图呈现,并提供下载(格式+尺寸)选项
+interface MediaInfo { type: 'img' | 'video'; src: string; alt?: string; }
+
+// 全屏灯箱预览
+const ChatLightbox: React.FC<{ src: string; type: 'img' | 'video'; onClose: () => void }> = ({ src, type, onClose }) => {
+ useEffect(() => {
+ const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
+ document.addEventListener('keydown', h);
+ return () => document.removeEventListener('keydown', h);
+ }, [onClose]);
+ return (
+
+
+
e.stopPropagation()}>
+ {type === 'video'
+ ?
+ :

}
+
+
+ );
+};
+
+// 根据原始尺寸推算 3 个建议下载尺寸(保持宽高比)
+function computeSizes(natural: { w: number; h: number }) {
+ if (natural.w && natural.h) {
+ const ratio = natural.w / natural.h;
+ const fit = (maxDim: number) => ratio >= 1
+ ? { w: maxDim, h: Math.round(maxDim / ratio) }
+ : { w: Math.round(maxDim * ratio), h: maxDim };
+ return [
+ { label: '小图', ...fit(512) },
+ { label: '中图', ...fit(1024) },
+ { label: '原始', w: natural.w, h: natural.h },
+ ];
+ }
+ // 未知原始尺寸时按生成默认 4:3 给出三档
+ return [
+ { label: '小图', w: 512, h: 384 },
+ { label: '中图', w: 1024, h: 768 },
+ { label: '大图', w: 2048, h: 1536 },
+ ];
+}
+
+// 客户端画布转换格式+尺寸后下载;跨域受限时回退到新标签页打开原图
+function downloadImage(src: string, format: 'png' | 'jpg' | 'webp', w: number, h: number) {
+ const fallback = () => window.open(src, '_blank');
+ const img = new Image();
+ img.crossOrigin = 'anonymous';
+ img.onload = () => {
+ try {
+ const canvas = document.createElement('canvas');
+ canvas.width = w; canvas.height = h;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) { fallback(); return; }
+ if (format === 'jpg') { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, w, h); }
+ ctx.drawImage(img, 0, 0, w, h);
+ const mime = format === 'jpg' ? 'image/jpeg' : format === 'webp' ? 'image/webp' : 'image/png';
+ canvas.toBlob((blob) => {
+ if (!blob) { fallback(); return; }
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url; a.download = `aiflow-image-${w}x${h}.${format}`;
+ document.body.appendChild(a); a.click(); a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ }, mime, 0.92);
+ } catch (e) {
+ fallback();
+ }
+ };
+ img.onerror = fallback;
+ img.src = src;
+}
+
+// 媒体渲染组件:固定尺寸缩略图 + 点击放大 + 下载(格式/尺寸)
+const ChatMedia: React.FC<{ info: MediaInfo }> = ({ info }) => {
+ const [open, setOpen] = useState(false);
+ const [dlOpen, setDlOpen] = useState(false);
+ const [fmt, setFmt] = useState<'png' | 'jpg' | 'webp'>('png');
+ const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 0, h: 0 });
+
+ if (info.type === 'video') {
+ return (
+
+
+ );
+ }
+
+ const sizes = computeSizes(natural);
+ return (
+
+

setOpen(true)}
+ onLoad={(e) => {
+ const im = e.currentTarget;
+ if (im.naturalWidth && im.naturalHeight) setNatural({ w: im.naturalWidth, h: im.naturalHeight });
+ }}
+ />
+
+ {dlOpen && (
+
e.stopPropagation()}>
+
下载图片
+
+
格式
+
+ {(['png', 'jpg', 'webp'] as const).map((f) => (
+
+ ))}
+
+
+
尺寸
+
+ {sizes.map((s, i) => (
+
+ ))}
+
+
+ )}
+ {open &&
setOpen(false)} />}
+
+ );
+};
+
+const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
+ const nodes = useMemo(() => {
+ let text = content;
+
+ // 1. Extract code blocks first (protect from other replacements)
+ const codeBlocks: string[] = [];
+ const mermaidBlocks: string[] = [];
+ text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_m: string, _lang: string, code: string) => {
+ if (_lang === 'mermaid') {
+ const idx = mermaidBlocks.length;
+ mermaidBlocks.push('');
+ return '\x00MERMAID' + idx + '\x00';
+ }
+ const idx = codeBlocks.length;
+ codeBlocks.push('' + escapeHtml(code.trim()) + '
');
+ return '\x00CODEBLOCK' + idx + '\x00';
+ });
+
+ // 2. Extract inline code
+ const inlineCodes: string[] = [];
+ text = text.replace(/`([^`]+)`/g, (_m: string, code: string) => {
+ const idx = inlineCodes.length;
+ inlineCodes.push('' + escapeHtml(code) + '');
+ return '\x00INLINE' + idx + '\x00';
+ });
+
+ // 2.5 Extract media blocks (
/