diff --git a/static/panels.js b/static/panels.js
index 93f28af5..f701cb15 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -45,7 +45,7 @@ const APP_TITLEBAR_KEYS = {
memory: 'tab_memory', workspaces: 'tab_workspaces',
profiles: 'tab_profiles', todos: 'tab_todos', insights: 'tab_insights', logs: 'tab_logs', settings: 'tab_settings',
};
-const MAIN_VIEW_PANELS = ['settings','skills','memory','tasks','kanban','workspaces','profiles','insights','logs','plugin'];
+const MAIN_VIEW_PANELS = ['settings','skills','memory','tasks','kanban','workspaces','profiles','insights','logs','plugin','selfmedia'];
const MAIN_VIEW_SIDEBAR_PANEL_FALLBACKS = { plugin: 'settings' };
/**
@@ -460,6 +460,7 @@ async function switchPanel(name, opts = {}) {
if (nextPanel === 'todos') loadTodos();
if (nextPanel === 'insights') await loadInsights();
if (nextPanel === 'logs') await loadLogs();
+ if (nextPanel === 'selfmedia') await loadSelfMediaPanel();
_syncLogsAutoRefresh();
if (typeof _syncSystemHealthMonitorVisibility === 'function') _syncSystemHealthMonitorVisibility();
if (nextPanel === 'settings') {
@@ -11026,6 +11027,298 @@ async function _loadPluginPage(path, label) {
_currentPluginPage = { path, label };
}
+// ── Self-Media Center panel ──────────────────────────────────────────────────
+// Native WebUI panel (no iframe): runs in the app origin so api() carries the
+// session cookie + CSRF token. Quick actions open a fresh conversation and send
+// the command there instead of hijacking the current chat.
+
+const SM_KEYWORDS = [
+ '公众号','wechat','weixin','文章','publish','发文',
+ '自媒体','content','选题','写作','writing','article',
+ '头条','toutiao','b站','bilibili','bili','值得买','smzdm',
+ '知乎','zhihu','小红书','xhs','redbook',
+ 'post','social','media','推广','营销','运营',
+ '自动','daily','日报','周报','ranking','排行',
+ '视频','video','图文','图片','image','cover','封面',
+ '评论','comment','回复','reply',
+ 'seo','关键词','keyword','软件','app',
+ 'wechat-oa','weixin-','douyin','multi-platform','cross-post',
+ 'aws-wechat','aws-'
+];
+
+function _smIsSelfMedia(job){
+ if(!job||!job.name) return false;
+ const n=String(job.name).toLowerCase();
+ return SM_KEYWORDS.some(k=>n.includes(k.toLowerCase()));
+}
+
+function _smAgo(t){
+ if(!t) return '';
+ const d=t instanceof Date?t:new Date(t);
+ if(isNaN(d.getTime())) return '';
+ const mins=Math.floor((Date.now()-d.getTime())/60000);
+ if(mins<1) return '刚刚';
+ if(mins<60) return mins+'分钟前';
+ const h=Math.floor(mins/60);
+ if(h<24) return h+'小时前';
+ const days=Math.floor(h/24);
+ if(days<7) return days+'天前';
+ return d.toLocaleDateString('zh-CN',{month:'short',day:'numeric'});
+}
+
+function _smEsc(s){
+ const d=document.createElement('div');
+ d.textContent=s==null?'':String(s);
+ return d.innerHTML;
+}
+
+// Open a brand-new conversation and send the command there.
+let _smLaunchInFlight=false;
+async function openSelfMediaCommand(command){
+ if(!command) return;
+ if(_smLaunchInFlight){ if(typeof showToast==='function') showToast('上一个操作还在启动中…',1800); return; }
+ _smLaunchInFlight=true;
+ try{
+ if(typeof newSession==='function') await newSession();
+ if(typeof switchPanel==='function') switchPanel('chat');
+ const box=document.getElementById('msg');
+ if(box){
+ box.value=command;
+ box.dispatchEvent(new Event('input',{bubbles:true}));
+ box.focus();
+ }
+ if(typeof send==='function'){
+ await new Promise(r=>setTimeout(r,400));
+ await send();
+ }
+ if(typeof showToast==='function') showToast('已在新对话中执行:'+command.slice(0,32)+'…',2400);
+ }catch(e){
+ if(typeof showToast==='function') showToast('启动失败:'+(e&&e.message||e),3000);
+ }finally{
+ setTimeout(()=>{_smLaunchInFlight=false;},1200);
+ }
+}
+
+function _smBindSidebarActions(){
+ const wrap=document.getElementById('smSidebarActions');
+ if(!wrap||wrap.dataset.smBound==='1') return;
+ wrap.dataset.smBound='1';
+ wrap.addEventListener('click',ev=>{
+ const btn=ev.target.closest('[data-smcmd]');
+ if(!btn) return;
+ openSelfMediaCommand(btn.dataset.smcmd);
+ });
+}
+
+async function loadSelfMediaPanel(){
+ const container=document.getElementById('selfmediaContainer');
+ if(!container) return;
+ _smBindSidebarActions();
+
+ container.innerHTML=`
+
+
+
📡 自媒体中心
+
Hermes 自媒体自动化管理面板
+
加载中…
+
+
+
+
⚡ 快捷操作
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const dash=container.querySelector('.sm-dash');
+
+ async function smLoad(){
+ const dot=dash.querySelector('.sm-dot');
+ const txt=dash.querySelector('.sm-conn-text');
+ if(dot) dot.className='sm-dot checking';
+ if(txt) txt.textContent='加载中…';
+
+ let jobs=[], recent=[];
+ try{
+ const data=await api('/api/crons');
+ jobs=Array.isArray(data.jobs)?data.jobs:[];
+ try{
+ const st=await api('/api/crons/status');
+ const running=(st&&st.running)||{};
+ jobs.forEach(j=>{j.running=!!running[j.id];});
+ }catch(_){}
+ try{
+ const rec=await api('/api/crons/recent');
+ recent=Array.isArray(rec.completions)?rec.completions:[];
+ }catch(_){}
+ }catch(e){
+ if(dot) dot.className='sm-dot error';
+ if(txt) txt.textContent='连接失败';
+ dash.querySelector('#smJobs').innerHTML='
⚠️ 加载失败:'+_smEsc(e&&e.message||e)+'
';
+ return;
+ }
+
+ const smJobs=jobs.filter(_smIsSelfMedia);
+ const dayStart=new Date(); dayStart.setHours(0,0,0,0);
+ const todayRuns=recent.filter(r=>r.completed_at&&new Date(r.completed_at*1000)>=dayStart);
+
+ dash.querySelector('#smStatTotal').textContent=jobs.length;
+ dash.querySelector('#smStatActive').textContent=jobs.filter(j=>j.running).length;
+ dash.querySelector('#smStatSm').textContent=smJobs.length+'/'+jobs.length;
+ dash.querySelector('#smStatToday').textContent=todayRuns.length;
+ dash.querySelector('#smCount').textContent='('+smJobs.length+'/'+jobs.length+')';
+ if(dot) dot.className='sm-dot connected';
+ if(txt) txt.textContent='已连接 · '+smJobs.length+' 个自媒体任务';
+
+ // jobs
+ const showAll=dash.querySelector('#smShowAll').checked;
+ const list=showAll?jobs:smJobs;
+ const jbox=dash.querySelector('#smJobs');
+ if(!list.length){
+ jbox.innerHTML='
'+(showAll?'暂无 cron 任务':'未识别到自媒体任务')+'
任务名含「公众号 / wechat / publish」等关键词会自动归类
';
+ }else{
+ jbox.innerHTML='
';
+ const wrap=jbox.querySelector('.sm-jobs');
+ list.forEach(job=>{
+ const isSm=_smIsSelfMedia(job);
+ const badge=job.paused?['paused','已暂停']:job.running?['running','运行中']:['idle','待命中'];
+ const el=document.createElement('div');
+ el.className='sm-job'+(isSm?' is-sm':'');
+ el.innerHTML='
'+(isSm?'📱 ':'')+_smEsc(job.name||'未命名')+
+ '
🕐 '+_smEsc(job.schedule||(job.no_agent?'脚本':'手动'))+
+ '📦 '+(job.no_agent?'脚本':'Agent')+''+(job.profile?'👤 '+_smEsc(job.profile)+'':'')+
+ '
● '+badge[1]+'
'+
+ '
';
+ wrap.appendChild(el);
+ });
+ wrap.querySelectorAll('.sm-run').forEach(btn=>{
+ btn.addEventListener('click',async function(){
+ const id=this.dataset.id;
+ this.disabled=true; this.textContent='⋯';
+ try{
+ await api('/api/crons/run',{method:'POST',body:JSON.stringify({job_id:id})});
+ this.textContent='✓';
+ if(typeof showToast==='function') showToast('任务已触发',1600);
+ setTimeout(()=>{this.textContent='▶';this.disabled=false;},2000);
+ setTimeout(smLoad,3000);
+ }catch(e){
+ this.textContent='✗'; this.disabled=false;
+ if(typeof showToast==='function') showToast('触发失败:'+(e&&e.message||e),3000);
+ }
+ });
+ });
+ }
+
+ // recent
+ const rbox=dash.querySelector('#smRecent');
+ if(!recent.length){
+ rbox.innerHTML='
暂无执行记录
';
+ }else{
+ rbox.innerHTML='
';
+ const wrap=rbox.querySelector('.sm-acts');
+ recent.slice(0,20).forEach(r=>{
+ const ok=r.status!=='failed'&&r.status!=='error';
+ const el=document.createElement('div');
+ el.className='sm-act';
+ el.innerHTML='
'+_smEsc(r.completed_at?_smAgo(new Date(r.completed_at*1000)):'进行中')+
+ ''+_smEsc(r.name||r.job_name||'未知')+
+ ''+(ok?'✓':'✗')+'';
+ wrap.appendChild(el);
+ });
+ }
+ }
+
+ const actions=dash.querySelector('#smActions');
+ actions.addEventListener('click',ev=>{
+ const btn=ev.target.closest('[data-cmd]');
+ if(!btn) return;
+ const cmd=btn.dataset.cmd;
+ if(cmd==='__refresh__'){ smLoad(); return; }
+ const label=btn.textContent;
+ btn.disabled=true; btn.textContent='⏳ 启动中…';
+ openSelfMediaCommand(cmd).finally(()=>{
+ setTimeout(()=>{btn.disabled=false;btn.textContent=label;},2200);
+ });
+ });
+ dash.querySelector('#smShowAll').addEventListener('change',smLoad);
+
+ await smLoad();
+ if(window._smTimer) clearInterval(window._smTimer);
+ window._smTimer=setInterval(()=>{
+ const el=document.getElementById('selfmediaContainer');
+ if(el&&el.offsetParent!==null) smLoad();
+ },30000);
+}
+
// ── Providers panel ─────────────────────────────────────────────────────────
const _providerCardEls = new Map(); // providerId → entry used by save/remove/test handlers
diff --git a/static/style.css b/static/style.css
index ef72a752..6f540a77 100644
--- a/static/style.css
+++ b/static/style.css
@@ -4941,7 +4941,7 @@ main.main > #mainWorkspaces,
main.main > #mainProfiles,
main.main > #mainInsights,
main.main > #mainLogs{display:none;}
-main.main:not(.showing-settings):not(.showing-skills):not(.showing-memory):not(.showing-tasks):not(.showing-kanban):not(.showing-workspaces):not(.showing-profiles):not(.showing-insights):not(.showing-logs):not(.showing-plugin) > #mainChat{display:flex;}
+main.main:not(.showing-settings):not(.showing-skills):not(.showing-memory):not(.showing-tasks):not(.showing-kanban):not(.showing-workspaces):not(.showing-profiles):not(.showing-insights):not(.showing-logs):not(.showing-plugin):not(.showing-selfmedia) > #mainChat{display:flex;}
main.main.showing-settings > #mainSettings{display:flex;overflow-y:auto;}
main.main.showing-skills > #mainSkills{display:flex;}
main.main.showing-memory > #mainMemory{display:flex;}
@@ -4951,8 +4951,11 @@ main.main.showing-workspaces > #mainWorkspaces{display:flex;}
main.main.showing-profiles > #mainProfiles{display:flex;}
main.main.showing-logs > #mainLogs{display:flex;}
main.main.showing-plugin > #mainPlugin{display:flex;}
-main.main > #mainPlugin{display:none;}
+main.main.showing-selfmedia > #mainSelfmedia{display:flex;}
+main.main > #mainPlugin,
+main.main > #mainSelfmedia{display:none;}
#mainPlugin .main-view-body{flex:1;display:flex;flex-direction:column;overflow:hidden;}
+#mainSelfmedia .main-view-body{flex:1;display:flex;flex-direction:column;overflow:hidden;}
#pluginPageContainer{flex:1;display:flex;flex-direction:column;min-height:0;}
#mainSettings{overflow-y:auto;}