chore(align): sync Git with running container as authoritative source
One-time alignment to close drift between the running container (qihu-aiflow / image qihuaiflow:with-deps) and this Git repository. Container hotfixes previously persisted only via `docker commit` are now captured in Git so Git becomes the single source of truth. Backend: - chat_service.py: accumulated fixes incl. EigenFlux README guidance - chat_context.py / chat_media.py: URL/web-repo context injection - api/url_reader.py: NEW SSRF-safe web/README reader (Issue #725) - skills_admin.py & Skills.tsx: 14-scene capability unification - pgvector_search.py, orchestrator.py, platform_knowledge.py, market.py, admin/models.py: drift sync Frontend: - ChatPage.tsx: collapsible AI think/act block (Issue #724) - MarketPage / EvolutionDashboard / TokenDashboard styling sync Migrations: - 018_normalize_marketapp_scene.sql: fixed capability allowlist (prevents capability wipe on restart) Alignment performed 2026-07-23 05:42:47. No deletions; secrets/venv/node_modules/ caches were not touched (tracked-source-only copy).
This commit is contained in:
1 parent
49f7503978
commit
a08ec87b22
19 files changed
+7474
-7196
No files matched your search
+682
-676
File diff suppressed because it is too large.
Load diff
@@ -457,6 +457,64 @@ const Dropdown: React.FC<DropdownProps> = ({ trigger, children, className = '',
|
||||
);
|
||||
};
|
||||
|
||||
// ===== AI 思考 / 执行过程面板(顶部展开 + 自动收拢) =====
|
||||
// autoExpand=true 时(本轮刚产生的助手消息)首屏展开呈现思考链路,
|
||||
// 2.6s 后若用户未手动干预则自动收拢为一行摘要,避免与回复内容冲突。
|
||||
const AiProcessBlock: React.FC<{ steps: any[]; autoExpand: boolean }> = ({ steps, autoExpand }) => {
|
||||
const [open, setOpen] = useState(autoExpand);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const touchedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoExpand) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setOpen(true);
|
||||
touchedRef.current = false;
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
if (!touchedRef.current) setOpen(false);
|
||||
}, 2600);
|
||||
return () => {
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [autoExpand]);
|
||||
|
||||
if (!steps || steps.length === 0) return null;
|
||||
|
||||
const toggle = () => {
|
||||
touchedRef.current = true;
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setOpen(o => !o);
|
||||
};
|
||||
|
||||
return (
|
||||
<details className="ai-process" open={open}>
|
||||
<summary className="ai-process-summary" onClick={(e) => { e.preventDefault(); toggle(); }}>
|
||||
<span className="ai-process-badge">🧠</span>
|
||||
思考 / 执行过程({steps.length} 步,{open ? '点击收起' : '点击展开'})
|
||||
</summary>
|
||||
<div className="ai-process-list">
|
||||
{steps.map((s: any, i: number) => (
|
||||
<div key={i} className={`ai-process-item ai-process-${s.type}`}>
|
||||
<span className="ai-process-ico">
|
||||
{s.type === 'thinking' ? '💡' : s.type === 'skill' ? '🛠' : '🔌'}
|
||||
</span>
|
||||
<div className="ai-process-body">
|
||||
{s.type === 'thinking' && (<><b className="ai-process-title">思考</b><p className="ai-process-text">{s.text}</p></>)}
|
||||
{s.type === 'skill' && (<><b className="ai-process-title">调用技能:{s.name}</b><p className="ai-process-text">{(s.result || '').slice(0, 400)}</p></>)}
|
||||
{s.type === 'mcp' && (<><b className="ai-process-title">MCP · {s.server}:{s.name}</b><p className="ai-process-text">{(s.result || '').slice(0, 400)}</p></>)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== Main Component =====
|
||||
const ChatPage: React.FC = () => {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
@@ -470,6 +528,8 @@ const ChatPage: React.FC = () => {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loadingConversations, setLoadingConversations] = useState(true);
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
// 本轮对话新产生的助手消息 id 集合,用于触发"思考过程"首屏展开
|
||||
const [freshMsgIds, setFreshMsgIds] = useState<Set<string>>(new Set());
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<any[]>([]);
|
||||
const [agents, setAgents] = useState<any[]>([]);
|
||||
const [skills, setSkills] = useState<any[]>([]);
|
||||
@@ -901,6 +961,11 @@ const ChatPage: React.FC = () => {
|
||||
return [...filtered, ...newMsgs];
|
||||
});
|
||||
|
||||
// 标记本轮新产生的助手消息,触发"思考过程"首屏展开
|
||||
if (data.assistant_message) {
|
||||
setFreshMsgIds(prev => new Set(prev).add(data.assistant_message.id));
|
||||
}
|
||||
|
||||
// 媒体异步生成:启动轮询,待后端回写后自动刷新
|
||||
if (data.assistant_message?.generating) {
|
||||
startMediaPolling(selectedConversation!.id, data.assistant_message.id);
|
||||
@@ -1174,6 +1239,9 @@ const ChatPage: React.FC = () => {
|
||||
const clarify = msg.role === 'assistant' ? parseClarify(msg.content) : null;
|
||||
return (
|
||||
<div key={msg.id} className={`message-row ${msg.role}`}>
|
||||
{msg.role === 'assistant' && steps.length > 0 && (
|
||||
<AiProcessBlock steps={steps} autoExpand={freshMsgIds.has(msg.id)} />
|
||||
)}
|
||||
<div className={`message-bubble ${msg.role}`}>
|
||||
{clarify ? (
|
||||
<ClarifyCard
|
||||
@@ -1237,34 +1305,6 @@ const ChatPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{msg.role === 'assistant' && steps.length > 0 && (
|
||||
<details className="ai-process" open={false}>
|
||||
<summary className="ai-process-summary">
|
||||
<span className="ai-process-badge">🧠</span>
|
||||
思考 / 执行过程({steps.length} 步,点击展开)
|
||||
</summary>
|
||||
<div className="ai-process-list">
|
||||
{steps.map((s: any, i: number) => (
|
||||
<div key={i} className={`ai-process-item ai-process-${s.type}`}>
|
||||
<span className="ai-process-ico">
|
||||
{s.type === 'thinking' ? '💡' : s.type === 'skill' ? '🛠' : '🔌'}
|
||||
</span>
|
||||
<div className="ai-process-body">
|
||||
{s.type === 'thinking' && (
|
||||
<><b className="ai-process-title">思考</b><p className="ai-process-text">{s.text}</p></>
|
||||
)}
|
||||
{s.type === 'skill' && (
|
||||
<><b className="ai-process-title">调用技能:{s.name}</b><p className="ai-process-text">{(s.result || '').slice(0, 400)}</p></>
|
||||
)}
|
||||
{s.type === 'mcp' && (
|
||||
<><b className="ai-process-title">MCP · {s.server}:{s.name}</b><p className="ai-process-text">{(s.result || '').slice(0, 400)}</p></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
<div className={`message-meta ${msg.role}`}>
|
||||
<span className="message-time">{formatTime(msg.createdAt)}</span>
|
||||
<span className="message-tokens">{msg.tokenUsed} tokens</span>
|
||||
|
||||
@@ -1000,51 +1000,7 @@
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.evo-anim-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.evo-anim-toggle input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Baby sprout animation */
|
||||
.evo-skill-node.baby.anim-on .evo-node-dot {
|
||||
animation: baby-sprout 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.evo-skill-node.baby.anim-on {
|
||||
animation: baby-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes baby-sprout {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.15); }
|
||||
}
|
||||
|
||||
@keyframes baby-glow {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.3); }
|
||||
50% { box-shadow: 0 0 16px 4px rgba(245, 158, 11, 0); }
|
||||
}
|
||||
|
||||
/* Staging breathe animation */
|
||||
.evo-skill-node.staging.anim-on {
|
||||
animation: staging-breathe 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes staging-breathe {
|
||||
0%, 100% { transform: scale(1); opacity: 0.85; }
|
||||
50% { transform: scale(1.06); opacity: 1; }
|
||||
}
|
||||
/* 技能图谱动画已在 #717 中移除(边框光晕溢出且无实际用途) */
|
||||
|
||||
/* Toast */
|
||||
.evo-toast {
|
||||
@@ -1535,47 +1491,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== v2: Baby Sprout Animation (GAP-03) ===== */
|
||||
.evo-skill-node.state-baby.anim-on {
|
||||
animation: baby-sprout 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes baby-sprout {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(255, 182, 193, 0.4);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 0 20px 4px rgba(255, 182, 193, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Baby skill sprout icon */
|
||||
.evo-skill-node.state-baby.anim-on .evo-node-dot::after {
|
||||
content: '🌱';
|
||||
position: absolute;
|
||||
top: -18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 14px;
|
||||
animation: sprout-wiggle 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes sprout-wiggle {
|
||||
0%, 100% { transform: translateX(-50%) rotate(-5deg); }
|
||||
50% { transform: translateX(-50%) rotate(5deg); }
|
||||
}
|
||||
|
||||
/* Staging breathe animation */
|
||||
.evo-skill-node.state-staging.anim-on {
|
||||
animation: staging-breathe 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes staging-breathe {
|
||||
0%, 100% { transform: scale(1); opacity: 0.8; }
|
||||
50% { transform: scale(1.08); opacity: 1; }
|
||||
}
|
||||
/* ===== v2: 技能图谱动画已在 #717 中移除(边框溢出且无实际用途) ===== */
|
||||
|
||||
/* ===== v2: Toast messages ===== */
|
||||
.evo-toast {
|
||||
|
||||
@@ -62,9 +62,7 @@ const shareThemes = [
|
||||
// ==================== Panel A: Skill Tree ====================
|
||||
const SkillTreePanel: React.FC<{
|
||||
skills: EvolutionSkill[];
|
||||
animEnabled: boolean;
|
||||
onToggleAnim: () => void;
|
||||
}> = ({ skills, animEnabled, onToggleAnim }) => {
|
||||
}> = ({ skills }) => {
|
||||
const [hoveredSkill, setHoveredSkill] = useState<string | null>(null);
|
||||
const [selectedSkill, setSelectedSkill] = useState<EvolutionSkill | null>(null);
|
||||
const [showVersionHistory, setShowVersionHistory] = useState(false);
|
||||
@@ -136,14 +134,6 @@ const SkillTreePanel: React.FC<{
|
||||
{/* Publish toast */}
|
||||
{publishMsg && <div className="evo-toast evo-toast-info">{publishMsg}</div>}
|
||||
|
||||
{/* Anim Toggle */}
|
||||
<div className="evo-panel-toolbar">
|
||||
<label className="evo-anim-toggle">
|
||||
<input type="checkbox" checked={animEnabled} onChange={onToggleAnim} />
|
||||
<span>动画效果</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Skill detail modal */}
|
||||
{selectedSkill && !showVersionHistory && (
|
||||
<div className="evo-skill-detail-modal" onClick={() => setSelectedSkill(null)}>
|
||||
@@ -305,7 +295,7 @@ const SkillTreePanel: React.FC<{
|
||||
<React.Fragment key={skill.id}>
|
||||
{idx > 0 && <div className="evo-node-connector" />}
|
||||
<div
|
||||
className={`evo-skill-node ${skill.state} ${isHovered ? 'hovered' : ''} ${animEnabled ? 'anim-on' : ''}`}
|
||||
className={`evo-skill-node ${skill.state} ${isHovered ? 'hovered' : ''}`}
|
||||
onMouseEnter={() => setHoveredSkill(skill.id)}
|
||||
onMouseLeave={() => setHoveredSkill(null)}
|
||||
onClick={() => setSelectedSkill(skill)}
|
||||
@@ -944,22 +934,10 @@ const EvolutionDashboard: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [globalSelectedSkill, setGlobalSelectedSkill] = useState<EvolutionSkill | null>(null);
|
||||
const [animEnabled, setAnimEnabled] = useState(() => {
|
||||
return localStorage.getItem('evo_anim_enabled') !== 'false';
|
||||
});
|
||||
|
||||
const totalSkills = skills.length;
|
||||
const mainCount = skills.filter(s => s.state === 'main').length;
|
||||
const babyCount = skills.filter(s => s.state === 'baby').length;
|
||||
|
||||
const toggleAnim = useCallback(() => {
|
||||
setAnimEnabled(prev => {
|
||||
const next = !prev;
|
||||
localStorage.setItem('evo_anim_enabled', String(next));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleViewSkill = (skillName: string) => {
|
||||
const skill = skills.find(s => s.skill_name === skillName);
|
||||
if (skill) {
|
||||
@@ -1071,7 +1049,7 @@ const EvolutionDashboard: React.FC = () => {
|
||||
))}
|
||||
</aside>
|
||||
<main className="evo-main">
|
||||
{activePanel === 'skill-tree' && <SkillTreePanel skills={skills} animEnabled={animEnabled} onToggleAnim={toggleAnim} />}
|
||||
{activePanel === 'skill-tree' && <SkillTreePanel skills={skills} />}
|
||||
{activePanel === 'timeline' && <TimelinePanel events={events} onViewSkill={handleViewSkill} />}
|
||||
{activePanel === 'statistics' && (stats ? <StatisticsPanel stats={stats} onViewSkill={handleViewSkill} /> : <div className="evo-panel-content"><p>加载中...</p></div>)}
|
||||
{activePanel === 'profile' && (profile ? <ProfilePanel profile={profile} skills={skills} /> : <div className="evo-panel-content"><p>加载中...</p></div>)}
|
||||
|
||||
@@ -1805,3 +1805,23 @@
|
||||
font-weight: 400;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* 官方内置技能徽标(见 #716:官方导入并发布的技能默认全局已安装) */
|
||||
.modal-official-badge,
|
||||
.market-card-official-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
border-radius: 8px;
|
||||
color: var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.modal-official-badge {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -337,6 +337,9 @@ const AppDetailModal: React.FC<AppDetailModalProps> = ({ app, onClose, onAppChan
|
||||
setInstalled(!!app.installed);
|
||||
}, [app.id, app.installed]);
|
||||
|
||||
// 官方导入并发布的技能默认全局已安装,前端展示为「官方内置」(见 #716)
|
||||
const isOfficialBuiltin = app.source === 'official' && app.status === 'published';
|
||||
|
||||
useEffect(() => {
|
||||
// 加载评论
|
||||
marketAPI.getReviews(app.id).then((res: any) => {
|
||||
@@ -463,7 +466,9 @@ const AppDetailModal: React.FC<AppDetailModalProps> = ({ app, onClose, onAppChan
|
||||
<span className="modal-price">{app.price} 元/月</span>
|
||||
)}
|
||||
</div>
|
||||
{installed ? (
|
||||
{isOfficialBuiltin ? (
|
||||
<span className="modal-official-badge">官方内置</span>
|
||||
) : installed ? (
|
||||
<button className="modal-install-btn uninstall" onClick={handleUninstall} disabled={uninstalling}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
@@ -1949,7 +1954,9 @@ const MarketPage: React.FC = () => {
|
||||
<div className="market-card-bottom">
|
||||
<StarRating rating={app.ratingAvg} count={app.reviewCount} />
|
||||
<div className="market-card-right">
|
||||
{app.installed ? (
|
||||
{app.source === 'official' && app.status === 'published' ? (
|
||||
<span className="market-card-official-badge">官方内置</span>
|
||||
) : app.installed ? (
|
||||
<button
|
||||
className="market-card-uninstall-btn"
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
padding: var(--space-3xl) var(--space-4xl);
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
/* 见 #714:FrontLayout 的 .front-main 裁剪了主内容区,
|
||||
此处让页面自身在可用高度内纵向滚动,避免内容被截断无法上下查阅 */
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
animation: fadeIn var(--transition-normal);
|
||||
}
|
||||
|
||||
@@ -329,6 +335,8 @@
|
||||
|
||||
.tkd-table-scroll {
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
max-height: 60vh;
|
||||
padding: var(--space-lg) var(--space-xl) var(--space-sm);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user