auto-sync: 2026-05-17 11:21:29

This commit is contained in:
cfdaily
2026-05-17 11:21:29 +08:00
parent cf32491bc6
commit dd69fe823f
30 changed files with 8569 additions and 816 deletions
+552 -97
View File
@@ -1,125 +1,580 @@
// API 客户端
/**
* API 层 — 对接 dashboard/server.py
* 生产环境从同源 (port 7891) 请求,开发环境可通过 VITE_API_URL 指定
*/
import type {
Task,
Project,
Observation,
Event,
DaemonStatus,
} from './types';
const API_BASE = import.meta.env.VITE_API_URL || '';
const API_BASE = '/api';
// ── 通用请求 ──
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
const resp = await fetch(`${API_BASE}${url}`, {
async function fetchJ<T>(url: string): Promise<T> {
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) throw new Error(String(res.status));
return res.json();
}
async function postJ<T>(url: string, data: unknown): Promise<T> {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!resp.ok) {
throw new Error(`API error: ${resp.status} ${resp.statusText}`);
}
return resp.json();
}
// ---- Projects ----
export async function listProjects(): Promise<Project[]> {
const data = await fetchJSON<unknown>('/projects');
if (Array.isArray(data)) return data as Project[];
if (data && typeof data === 'object') {
const obj = data as Record<string, unknown>;
const projectsObj = ('projects' in obj ? obj.projects : obj) as Record<string, Project>;
return Object.values(projectsObj);
}
return [];
}
export async function getProject(projectId: string): Promise<Project> {
return fetchJSON(`/projects/${projectId}`);
}
export async function createProject(data: Partial<Project>): Promise<Project> {
return fetchJSON('/projects', {
method: 'POST',
body: JSON.stringify(data),
});
return res.json();
}
// ---- Tasks ----
// ── API 接口 ──
export async function listTasks(projectId: string): Promise<Task[]> {
return fetchJSON(`/projects/${projectId}/tasks`);
export const api = {
// 核心数据
liveStatus: () => fetchJ<LiveStatus>(`${API_BASE}/api/live-status`),
agentConfig: () => fetchJ<AgentConfig>(`${API_BASE}/api/agent-config`),
modelChangeLog: () => fetchJ<ChangeLogEntry[]>(`${API_BASE}/api/model-change-log`).catch(() => []),
officialsStats: () => fetchJ<OfficialsData>(`${API_BASE}/api/officials-stats`),
morningBrief: () => fetchJ<MorningBrief>(`${API_BASE}/api/morning-brief`),
morningConfig: () => fetchJ<SubConfig>(`${API_BASE}/api/morning-config`),
agentsStatus: () => fetchJ<AgentsStatusData>(`${API_BASE}/api/agents-status`),
// 任务实时动态
taskActivity: (id: string) =>
fetchJ<TaskActivityData>(`${API_BASE}/api/task-activity/${encodeURIComponent(id)}`),
schedulerState: (id: string) =>
fetchJ<SchedulerStateData>(`${API_BASE}/api/scheduler-state/${encodeURIComponent(id)}`),
// 技能内容
skillContent: (agentId: string, skillName: string) =>
fetchJ<SkillContentResult>(
`${API_BASE}/api/skill-content/${encodeURIComponent(agentId)}/${encodeURIComponent(skillName)}`
),
// 操作类
setModel: (agentId: string, model: string) =>
postJ<ActionResult>(`${API_BASE}/api/set-model`, { agentId, model }),
setDispatchChannel: (channel: string) =>
postJ<ActionResult>(`${API_BASE}/api/set-dispatch-channel`, { channel }),
agentWake: (agentId: string) =>
postJ<ActionResult>(`${API_BASE}/api/agent-wake`, { agentId }),
taskAction: (taskId: string, action: string, reason: string) =>
postJ<ActionResult>(`${API_BASE}/api/task-action`, { taskId, action, reason }),
reviewAction: (taskId: string, action: string, comment: string) =>
postJ<ActionResult>(`${API_BASE}/api/review-action`, { taskId, action, comment }),
advanceState: (taskId: string, comment: string) =>
postJ<ActionResult>(`${API_BASE}/api/advance-state`, { taskId, comment }),
archiveTask: (taskId: string, archived: boolean) =>
postJ<ActionResult>(`${API_BASE}/api/archive-task`, { taskId, archived }),
archiveAllDone: () =>
postJ<ActionResult & { count?: number }>(`${API_BASE}/api/archive-task`, { archiveAllDone: true }),
schedulerScan: (thresholdSec = 180) =>
postJ<ActionResult & { count?: number; actions?: ScanAction[]; checkedAt?: string }>(
`${API_BASE}/api/scheduler-scan`,
{ thresholdSec }
),
schedulerRetry: (taskId: string, reason: string) =>
postJ<ActionResult>(`${API_BASE}/api/scheduler-retry`, { taskId, reason }),
schedulerEscalate: (taskId: string, reason: string) =>
postJ<ActionResult>(`${API_BASE}/api/scheduler-escalate`, { taskId, reason }),
schedulerRollback: (taskId: string, reason: string) =>
postJ<ActionResult>(`${API_BASE}/api/scheduler-rollback`, { taskId, reason }),
refreshMorning: () =>
postJ<ActionResult>(`${API_BASE}/api/morning-brief/refresh`, {}),
saveMorningConfig: (config: SubConfig) =>
postJ<ActionResult>(`${API_BASE}/api/morning-config`, config),
addSkill: (agentId: string, skillName: string, description: string, trigger: string) =>
postJ<ActionResult>(`${API_BASE}/api/add-skill`, { agentId, skillName, description, trigger }),
// 远程 Skills 管理
addRemoteSkill: (agentId: string, skillName: string, sourceUrl: string, description?: string) =>
postJ<ActionResult & { skillName?: string; agentId?: string; source?: string; localPath?: string; size?: number; addedAt?: string }>(
`${API_BASE}/api/add-remote-skill`, { agentId, skillName, sourceUrl, description: description || '' }
),
remoteSkillsList: () =>
fetchJ<RemoteSkillsListResult>(`${API_BASE}/api/remote-skills-list`),
updateRemoteSkill: (agentId: string, skillName: string) =>
postJ<ActionResult>(`${API_BASE}/api/update-remote-skill`, { agentId, skillName }),
removeRemoteSkill: (agentId: string, skillName: string) =>
postJ<ActionResult>(`${API_BASE}/api/remove-remote-skill`, { agentId, skillName }),
createTask: (data: CreateTaskPayload) =>
postJ<ActionResult & { taskId?: string }>(`${API_BASE}/api/tasks`, data),
// ── M3: 成果物 ──
artifacts: (taskId: string) =>
fetchJ<ArtifactsResult>(`${API_BASE}/api/tasks/${encodeURIComponent(taskId)}/artifacts`),
artifactPreview: (taskId: string, path: string) =>
fetchJ<ArtifactPreviewResult>(`${API_BASE}/api/tasks/${encodeURIComponent(taskId)}/artifacts/preview?path=${encodeURIComponent(path)}`),
artifactDownloadUrl: (taskId: string, path: string) =>
`${API_BASE}/api/tasks/${encodeURIComponent(taskId)}/artifacts/download?path=${encodeURIComponent(path)}`,
// ── M3: 人工干预 ──
humanInput: (taskId: string) =>
fetchJ<HumanInputResult>(`${API_BASE}/api/tasks/${encodeURIComponent(taskId)}/human-input`),
humanInputRespond: (taskId: string, data: HumanInputRespondPayload) =>
postJ<ActionResult>(`${API_BASE}/api/tasks/${encodeURIComponent(taskId)}/human-input/respond`, data),
// ── 朝堂议政 ──
courtDiscussStart: (topic: string, officials: string[], taskId?: string) =>
postJ<CourtDiscussResult>(`${API_BASE}/api/court-discuss/start`, { topic, officials, taskId }),
courtDiscussAdvance: (sessionId: string, userMessage?: string, decree?: string) =>
postJ<CourtDiscussResult>(`${API_BASE}/api/court-discuss/advance`, { sessionId, userMessage, decree }),
courtDiscussConclude: (sessionId: string) =>
postJ<ActionResult & { summary?: string }>(`${API_BASE}/api/court-discuss/conclude`, { sessionId }),
courtDiscussDestroy: (sessionId: string) =>
postJ<ActionResult>(`${API_BASE}/api/court-discuss/destroy`, { sessionId }),
courtDiscussFate: () =>
fetchJ<{ ok: boolean; event: string }>(`${API_BASE}/api/court-discuss/fate`),
};
// ── Types ──
export interface ActionResult {
ok: boolean;
message?: string;
error?: string;
}
export async function getTask(projectId: string, taskId: string): Promise<Task> {
return fetchJSON(`/projects/${projectId}/tasks/${taskId}`);
export interface FlowEntry {
at: string;
from: string;
to: string;
remark: string;
}
export async function createTask(
projectId: string,
data: Partial<Task>
): Promise<Task> {
return fetchJSON(`/projects/${projectId}/tasks`, {
method: 'POST',
body: JSON.stringify(data),
});
export interface TodoItem {
id: string | number;
title: string;
status: 'not-started' | 'in-progress' | 'completed';
detail?: string;
}
export async function updateTask(
projectId: string,
taskId: string,
data: Partial<Task>
): Promise<Task> {
return fetchJSON(`/projects/${projectId}/tasks/${taskId}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
export interface Heartbeat {
status: 'active' | 'warn' | 'stalled' | 'unknown' | 'idle';
label: string;
}
// ---- Observations ----
export async function listObservations(
projectId: string,
taskId: string
): Promise<Observation[]> {
return fetchJSON(`/projects/${projectId}/tasks/${taskId}/observations`);
export interface Task {
id: string;
title: string;
state: string;
org: string;
now: string;
eta: string;
block: string;
ac: string;
output: string;
heartbeat: Heartbeat;
flow_log: FlowEntry[];
todos: TodoItem[];
review_round: number;
archived: boolean;
archivedAt?: string;
updatedAt?: string;
updated_at?: string; // moziplus backend returns snake_case
// moziplus fields
status?: string; // moziplus task status: created/planning/executing/completed/failed/cancelled/paused
plan_status?: string; // moziplus plan status: pending/ready/challenging/approved/rejected
nodes?: TaskNode[]; // moziplus DAG nodes
latest_event?: TaskEvent; // moziplus latest event
sourceMeta?: Record<string, unknown>;
activity?: ActivityEntry[];
_prev_state?: string;
}
// ---- Events ----
export async function listEvents(
projectId: string,
taskId: string
): Promise<Event[]> {
return fetchJSON(`/projects/${projectId}/tasks/${taskId}/events`);
export interface TaskNode {
node_id: string;
name: string;
agent_id: string;
status: string;
depends_on?: string[];
output_summary?: string;
output?: string;
// Guard 字段
guard_status?: 'not_checked' | 'entry_passed' | 'entry_failed' | 'exit_passed' | 'exit_failed' | 'exit_warned';
guard_checks?: GuardCheck[];
rollback_count?: number;
}
// ---- Daemon ----
export async function getDaemonStatus(): Promise<DaemonStatus> {
return fetchJSON('/daemon/status');
export interface GuardCheck {
name: string;
passed: boolean;
message: string;
severity: 'error' | 'warn' | 'info';
}
export async function triggerTick(): Promise<{ tick: number }> {
return fetchJSON('/daemon/tick', { method: 'POST' });
export interface TaskEvent {
event_type: string;
reason: string;
timestamp: string;
}
// ---- SSE ----
export interface SyncStatus {
ok: boolean;
[key: string]: unknown;
}
export function createEventSource(
onEvent: (data: unknown) => void,
onError?: () => void
): EventSource {
const es = new EventSource(`${API_BASE}/events`);
es.onmessage = (e) => {
try {
onEvent(JSON.parse(e.data));
} catch {
onEvent(e.data);
}
export interface LiveStatus {
tasks: Task[];
syncStatus: SyncStatus;
}
export interface AgentInfo {
id: string;
label: string;
emoji: string;
role: string;
model: string;
skills: SkillInfo[];
}
export interface SkillInfo {
name: string;
description: string;
path: string;
}
export interface KnownModel {
id: string;
label: string;
provider: string;
}
export interface AgentConfig {
agents: AgentInfo[];
knownModels?: KnownModel[];
dispatchChannel?: string;
}
export interface ChangeLogEntry {
at: string;
agentId: string;
oldModel: string;
newModel: string;
rolledBack?: boolean;
}
export interface OfficialInfo {
id: string;
label: string;
emoji: string;
role: string;
rank: string;
model: string;
model_short: string;
tokens_in: number;
tokens_out: number;
cache_read: number;
cache_write: number;
cost_cny: number;
cost_usd: number;
sessions: number;
messages: number;
tasks_done: number;
tasks_active: number;
tasks_failed?: number; // moziplus: 失败任务数
memorySize?: number; // MEMORY.md 字数
memoryUpdatedAt?: string; // MEMORY.md 最后修改时间
flow_participations: number;
merit_score: number;
merit_rank: number;
last_active: string;
heartbeat: Heartbeat;
participated_edicts: { id: string; title: string; state: string; status?: string }[];
}
export interface OfficialsData {
officials: OfficialInfo[];
totals: { tasks_done: number; cost_cny: number };
top_official: string;
}
export interface AgentStatusInfo {
id: string;
label: string;
emoji: string;
role: string;
status: 'running' | 'idle' | 'offline' | 'unconfigured';
statusLabel: string;
lastActive?: string;
}
export interface GatewayStatus {
alive: boolean;
probe: boolean;
status: string;
}
export interface AgentsStatusData {
ok: boolean;
gateway: GatewayStatus;
agents: AgentStatusInfo[];
checkedAt: string;
}
export interface MorningNewsItem {
title: string;
summary?: string;
desc?: string;
link: string;
source: string;
image?: string;
pub_date?: string;
}
export interface MorningBrief {
date?: string;
generated_at?: string;
categories: Record<string, MorningNewsItem[]>;
}
export interface SubCategoryConfig {
name: string;
enabled: boolean;
}
export interface CustomFeed {
name: string;
url: string;
category: string;
}
export interface SubConfig {
categories: SubCategoryConfig[];
keywords: string[];
custom_feeds: CustomFeed[];
feishu_webhook: string;
}
export interface ActivityEntry {
kind: string;
at?: number | string;
text?: string;
thinking?: string;
agent?: string;
from?: string;
to?: string;
remark?: string;
tools?: { name: string; input_preview?: string }[];
tool?: string;
output?: string;
exitCode?: number | null;
items?: TodoItem[];
diff?: {
changed?: { id: string; from: string; to: string }[];
added?: { id: string; title: string }[];
removed?: { id: string; title: string }[];
};
es.onerror = () => {
onError?.();
};
return es;
}
export interface PhaseDuration {
phase: string;
durationSec: number;
durationText: string;
ongoing?: boolean;
}
export interface TodosSummary {
total: number;
completed: number;
inProgress: number;
notStarted: number;
percent: number;
}
export interface ResourceSummary {
totalTokens?: number;
totalCost?: number;
totalElapsedSec?: number;
}
export interface TimelineEntry {
time: string;
phase: string;
node_id: string;
node_name: string;
agent: string;
action: string;
verdict: string;
detail: string;
}
export interface TaskActivityData {
ok: boolean;
message?: string;
error?: string;
activity?: ActivityEntry[];
timeline?: TimelineEntry[];
relatedAgents?: string[];
agentLabel?: string;
lastActive?: string;
phaseDurations?: PhaseDuration[];
totalDuration?: string;
todosSummary?: TodosSummary;
resourceSummary?: ResourceSummary;
}
export interface SchedulerInfo {
retryCount?: number;
escalationLevel?: number;
lastDispatchStatus?: string;
stallThresholdSec?: number;
enabled?: boolean;
lastProgressAt?: string;
lastDispatchAt?: string;
lastDispatchAgent?: string;
autoRollback?: boolean;
}
export interface SchedulerStateData {
ok: boolean;
error?: string;
scheduler?: SchedulerInfo;
stalledSec?: number;
}
export interface SkillContentResult {
ok: boolean;
name?: string;
agent?: string;
content?: string;
path?: string;
error?: string;
}
export interface ScanAction {
taskId: string;
action: string;
to?: string;
toState?: string;
stalledSec?: number;
}
export interface CreateTaskPayload {
title: string;
org?: string;
targetDept?: string;
priority?: string;
templateId?: string;
params?: Record<string, string>;
// moziplus fields
requirement?: string;
project_root?: string;
project_type?: string;
}
export interface RemoteSkillItem {
skillName: string;
agentId: string;
sourceUrl: string;
description: string;
localPath: string;
addedAt: string;
lastUpdated: string;
status: 'valid' | 'not-found' | string;
}
export interface RemoteSkillsListResult {
ok: boolean;
remoteSkills?: RemoteSkillItem[];
count?: number;
listedAt?: string;
error?: string;
}
// ── 朝堂议政 ──
export interface CourtDiscussResult {
ok: boolean;
session_id?: string;
topic?: string;
round?: number;
new_messages?: Array<{
official_id: string;
name: string;
content: string;
emotion?: string;
action?: string;
}>;
scene_note?: string;
total_messages?: number;
error?: string;
}
// ── M3: 成果物 ──
export interface ArtifactEntry {
name: string;
path: string;
size: number;
type: string; // document | code | data | config | other
render_as: string; // markdown | code | table | binary
previewable: boolean;
}
export interface ArtifactNode {
node_id: string;
name: string;
agent_id: string;
status: string;
artifacts: ArtifactEntry[];
}
export interface ArtifactsResult {
ok: boolean;
deliverable: ArtifactEntry | null;
nodes: ArtifactNode[];
}
export interface ArtifactPreviewResult {
ok: boolean;
previewable: boolean;
name: string;
type: string;
render_as: string;
size: number;
content: string | null;
message?: string;
}
// ── M3: 人工干预 ──
export interface CheckpointInfo {
node_id: string;
node_name: string;
agent_id: string;
checkpoint: HumanInputCheckpoint;
triggered_at: string;
}
export interface HumanInputCheckpoint {
type: 'verify' | 'decision' | 'action';
title: string;
description?: string;
urgency?: string;
options?: Array<{
id: string;
label: string;
description?: string;
pros?: string[];
cons?: string[];
recommended?: boolean;
}>;
verificationSteps?: string[];
autoCheckResults?: Array<{ label: string; passed: boolean }>;
actionSteps?: Array<{ id: string; description: string; command?: string }>;
verificationCommand?: string;
artifacts?: Array<{ name: string; path: string }>;
}
export interface HumanInputResult {
ok: boolean;
active: boolean;
checkpoints: CheckpointInfo[];
}
export interface HumanInputRespondPayload {
node_id?: string;
action?: string;
reason?: string;
selected_option?: string;
completed_steps?: string[];
verification_note?: string;
free_text?: string;
}
@@ -0,0 +1,71 @@
import { useState, useEffect } from 'react';
import { api } from '../api';
import type { ArtifactsResult, ArtifactEntry } from '../api';
const TYPE_ICONS: Record<string, string> = {
document: '📄', code: '💻', data: '📊', config: '⚙️', other: '📁',
};
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
export default function ArtifactList({ taskId, isCompleted }: { taskId: string; isCompleted: boolean }) {
const [data, setData] = useState<ArtifactsResult | null>(null);
const [open, setOpen] = useState(false);
useEffect(() => {
api.artifacts(taskId).then(setData).catch(() => {});
}, [taskId]);
if (!data) return null;
const hasDeliverable = !!data.deliverable;
const nodeCount = data.nodes.length;
if (!hasDeliverable && nodeCount === 0) return null;
return (
<div style={{ borderTop: '1px solid var(--line)', paddingTop: 8, marginTop: 8 }}>
{/* 最终交付物 */}
{hasDeliverable && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--acc)' }}>📦 </span>
<div style={{ display: 'flex', gap: 4 }}>
<a className="btn-small" href={api.artifactDownloadUrl(taskId, data.deliverable!.path)} download
style={{ borderColor: 'var(--acc)', color: 'var(--acc)', textDecoration: 'none', fontSize: 10 }}>
📄 {formatSize(data.deliverable!.size)}
</a>
</div>
</div>
)}
{/* 节点产出折叠 */}
{nodeCount > 0 && (
<div>
<div onClick={() => setOpen(!open)} style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer', userSelect: 'none' }}>
<span style={{ fontSize: 11 }}>{open ? '▼' : '▶'}</span>
<span style={{ fontSize: 11, color: 'var(--muted)' }}>📁 {nodeCount}</span>
</div>
{open && (
<div style={{ marginTop: 4 }}>
{data.nodes.map((node) => (
<div key={node.node_id} style={{ padding: '4px 0 4px 12px', fontSize: 11, color: 'var(--muted)' }}>
<span>{node.status === 'done' ? '✅' : '🔄'}</span>{' '}
<span style={{ color: 'var(--fg)' }}>{node.name}</span>
{node.artifacts.map((a) => (
<span key={a.path} style={{ marginLeft: 8 }}>
{TYPE_ICONS[a.type]} {a.name} ({formatSize(a.size)})
</span>
))}
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,185 @@
import { useState, useEffect } from 'react';
import { api } from '../api';
import type { ArtifactsResult, ArtifactEntry, ArtifactPreviewResult } from '../api';
import { useStore } from '../store';
const TYPE_ICONS: Record<string, string> = {
document: '📄',
code: '💻',
data: '📊',
config: '⚙️',
other: '📁',
};
const AGENT_EMOJI: Record<string, string> = {
'pangtong-fujunshi': '🐦',
'simayi-challenger': '🦅',
'jiangwei-infra': '🔧',
'guanyu-dev': '⚔️',
'zhangfei-dev': '💪',
'zhaoyun-data': '📊',
};
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
// ── 预览模态框 ──
function ArtifactPreviewModal({
taskId,
artifact,
onClose,
}: {
taskId: string;
artifact: ArtifactEntry;
onClose: () => void;
}) {
const [preview, setPreview] = useState<ArtifactPreviewResult | null>(null);
const [loading, setLoading] = useState(true);
const toast = useStore((s) => s.toast);
useEffect(() => {
if (!artifact.previewable) {
setLoading(false);
return;
}
api.artifactPreview(taskId, artifact.path)
.then(setPreview)
.catch(() => toast('预览加载失败', 'err'))
.finally(() => setLoading(false));
}, [taskId, artifact.path]);
return (
<div className="modal-bg open" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ position: 'relative', maxWidth: 700 }}>
<button className="modal-close" onClick={onClose}></button>
<div style={{ padding: '12px 20px', borderBottom: '1px solid var(--line)', display: 'flex', alignItems: 'center', gap: 8 }}>
<span>{TYPE_ICONS[artifact.type] || '📁'}</span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{artifact.name}</span>
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{formatSize(artifact.size)}</span>
<a
href={api.artifactDownloadUrl(taskId, artifact.path)}
download
style={{ marginLeft: 'auto', fontSize: 11, padding: '4px 10px', borderRadius: 6, border: '1px solid var(--acc)', color: 'var(--acc)', textDecoration: 'none' }}
> </a>
</div>
<div style={{ padding: 20, maxHeight: '60vh', overflowY: 'auto' }}>
{loading && <div style={{ color: 'var(--muted)', textAlign: 'center' }}>...</div>}
{!loading && preview && !preview.previewable && (
<div style={{ textAlign: 'center', color: 'var(--muted)', padding: 40 }}>
<div style={{ fontSize: 24, marginBottom: 8 }}>📁</div>
<div>{preview.message || '文件过大或不可预览,请下载查看'}</div>
</div>
)}
{!loading && preview?.content && (
preview.render_as === 'markdown' ? (
<div className="md-preview" dangerouslySetInnerHTML={{ __html: simpleMarkdown(preview.content) }} />
) : preview.render_as === 'table' ? (
<pre style={{ fontSize: 12, overflow: 'auto' }}>{preview.content}</pre>
) : (
<pre style={{ background: '#0d1117', padding: 12, borderRadius: 8, fontSize: 12, overflow: 'auto', color: '#e6edf3', fontFamily: 'monospace' }}>{preview.content}</pre>
)
)}
</div>
</div>
</div>
);
}
// ── 简易 Markdown 渲染 ──
function simpleMarkdown(text: string): string {
return text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/^### (.+)$/gm, '<h3 style="font-size:14px;font-weight:700;margin:8px 0 4px">$1</h3>')
.replace(/^## (.+)$/gm, '<h2 style="font-size:15px;font-weight:700;margin:10px 0 6px">$1</h2>')
.replace(/^# (.+)$/gm, '<h1 style="font-size:17px;font-weight:700;margin:12px 0 8px">$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
.replace(/`([^`]+)`/g, '<code style="background:var(--panel2);padding:1px 4px;border-radius:3px;font-size:11px">$1</code>')
.replace(/\n/g, '<br/>');
}
// ── 主面板 ──
export default function ArtifactPanel({ taskId, isCompleted }: { taskId: string; isCompleted: boolean }) {
const [data, setData] = useState<ArtifactsResult | null>(null);
const [previewTarget, setPreviewTarget] = useState<ArtifactEntry | null>(null);
const toast = useStore((s) => s.toast);
useEffect(() => {
api.artifacts(taskId)
.then(setData)
.catch(() => {});
}, [taskId]);
if (!data) return <div style={{ color: 'var(--muted)', textAlign: 'center', padding: 20 }}>...</div>;
return (
<div>
{/* 最终交付物 */}
<div style={{ marginBottom: 16, padding: 16, borderRadius: 10, border: '1px solid var(--acc)', background: 'rgba(201,162,39,0.04)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span style={{ fontSize: 16 }}>📋</span>
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--acc)' }}></span>
</div>
{data.deliverable ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: 10, background: 'var(--panel2)', borderRadius: 8 }}>
<span style={{ fontSize: 20 }}>{TYPE_ICONS[data.deliverable.type]}</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{data.deliverable.name}</div>
<div style={{ fontSize: 11, color: 'var(--muted)' }}>{formatSize(data.deliverable.size)}</div>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<button className="btn-small" style={{ borderColor: 'var(--acc)', color: 'var(--acc)' }} onClick={() => setPreviewTarget(data.deliverable!)}></button>
<a className="btn-small" href={api.artifactDownloadUrl(taskId, data.deliverable.path)} download style={{ borderColor: 'var(--acc)', color: 'var(--acc)', textDecoration: 'none' }}></a>
</div>
</div>
) : (
<div style={{ fontSize: 12, color: 'var(--muted)', padding: 8 }}>
{isCompleted ? '无交付物' : '最终交付物将在任务完成后生成'}
</div>
)}
</div>
{/* 节点产出 */}
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--muted)', marginBottom: 10 }}>📁 </div>
{data.nodes.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--muted)', textAlign: 'center', padding: 20 }}></div>
) : data.nodes.map((node) => (
<div key={node.node_id} style={{ marginBottom: 8, padding: 12, borderRadius: 10, background: 'var(--panel)', border: '1px solid var(--line)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span>{node.status === 'done' ? '✅' : node.status === 'waiting_human' ? '🛐' : '🔄'}</span>
<span style={{ fontSize: 13, fontWeight: 600 }}>{node.name}</span>
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{AGENT_EMOJI[node.agent_id] || ''} {node.agent_id}</span>
<span className={`tag tag-${node.status === 'done' ? 'done' : node.status === 'waiting_human' ? 'waiting' : 'exec'}`} style={{ marginLeft: 'auto' }}>
{node.status}
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{node.artifacts.map((a) => (
<div key={a.path} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', background: 'var(--panel2)', borderRadius: 6 }}>
<span>{TYPE_ICONS[a.type] || '📁'}</span>
<span style={{ fontSize: 12 }}>{a.name}</span>
<span style={{ fontSize: 10, color: 'var(--muted)' }}>{formatSize(a.size)}</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 3 }}>
{a.previewable && (
<button className="btn-small" style={{ fontSize: 10, padding: '2px 6px' }} onClick={() => setPreviewTarget(a)}></button>
)}
<a className="btn-small" href={api.artifactDownloadUrl(taskId, a.path)} download style={{ fontSize: 10, padding: '2px 6px', textDecoration: 'none' }}></a>
</div>
</div>
))}
</div>
</div>
))}
{/* 预览模态框 */}
{previewTarget && (
<ArtifactPreviewModal taskId={taskId} artifact={previewTarget} onClose={() => setPreviewTarget(null)} />
)}
</div>
);
}
@@ -0,0 +1,350 @@
import { useState } from 'react';
import { api } from '../api';
import { useStore } from '../store';
import type { CheckpointInfo, HumanInputCheckpoint } from '../api';
const AGENT_EMOJI: Record<string, string> = {
'pangtong-fujunshi': '🐦', 'simayi-challenger': '🦅', 'jiangwei-infra': '🔧',
'guanyu-dev': '⚔️', 'zhangfei-dev': '💪', 'zhaoyun-data': '📊',
};
// ── Decision Checkpoint ──
function DecisionCheckpoint({
taskId,
info,
onDone,
}: {
taskId: string;
info: CheckpointInfo;
onDone: () => void;
}) {
const cp = info.checkpoint;
const [selected, setSelected] = useState<string | null>(null);
const [note, setNote] = useState('');
const [loading, setLoading] = useState(false);
const toast = useStore((s) => s.toast);
const handleSubmit = async () => {
if (!selected) return;
setLoading(true);
try {
const r = await api.humanInputRespond(taskId, {
node_id: info.node_id,
action: 'decide',
selected_option: selected,
reason: note,
});
if (r.ok) { toast('✅ 已御批', 'ok'); onDone(); }
else toast(r.error || '操作失败', 'err');
} catch { toast('服务器连接失败', 'err'); }
setLoading(false);
};
return (
<div>
<div style={{ fontSize: 13, color: 'var(--muted)', lineHeight: 1.6, marginBottom: 14, padding: 12, background: 'var(--panel2)', borderRadius: 8 }}>
{cp.description}
</div>
{/* 关联产出物 */}
{cp.artifacts?.length ? (
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 4 }}>📦 </div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{cp.artifacts.map((a, i) => (
<a key={i} href={api.artifactDownloadUrl(taskId, a.path)} target="_blank" rel="noreferrer"
style={{ fontSize: 10, padding: '2px 8px', borderRadius: 4, border: '1px solid var(--line)', color: 'var(--fg)', textDecoration: 'none' }}>
📄 {a.name}
</a>
))}
</div>
</div>
) : null}
{/* 方案 */}
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--muted)', marginBottom: 8 }}>📋 </div>
<div style={{ display: 'flex', gap: 8, marginBottom: 14, flexWrap: 'wrap' }}>
{cp.options?.map((opt) => (
<div key={opt.id}
onClick={() => setSelected(opt.id)}
style={{
flex: '1 1 180px', padding: 14, borderRadius: 10, cursor: 'pointer',
border: `2px solid ${selected === opt.id ? 'var(--acc)' : 'var(--line)'}`,
background: selected === opt.id ? 'rgba(201,162,39,0.08)' : 'var(--panel)',
transition: 'all 0.15s',
}}>
{opt.recommended && (
<div style={{ float: 'right', background: '#22c55e', color: '#fff', fontSize: 10, fontWeight: 700, padding: '1px 6px', borderRadius: 3 }}></div>
)}
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 4 }}>{opt.label}</div>
{opt.description && <div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 6 }}>{opt.description}</div>}
{opt.pros?.map((p, i) => <div key={i} style={{ fontSize: 11, color: '#22c55e' }}>👍 {p}</div>)}
{opt.cons?.map((c, i) => <div key={i} style={{ fontSize: 11, color: '#ef4444' }}>👎 {c}</div>)}
</div>
))}
</div>
{/* 兜底自由输入 */}
{(!cp.options || cp.options.length === 0) && (
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 4 }}></div>
<textarea style={{ width: '100%', height: 60, background: 'var(--panel2)', border: '1px solid var(--line)', borderRadius: 6, padding: 8, color: 'var(--fg)', fontSize: 12 }}
value={note} onChange={(e) => setNote(e.target.value)} placeholder="输入你的决策..." />
</div>
)}
{/* 御批备注 */}
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 4 }}>📝 </div>
<textarea style={{ width: '100%', height: 48, background: 'var(--panel2)', border: '1px solid var(--line)', borderRadius: 6, padding: 8, color: 'var(--fg)', fontSize: 12 }}
value={note} onChange={(e) => setNote(e.target.value)} placeholder="记录决策理由..." />
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button className="btn-primary" disabled={!selected || loading} onClick={handleSubmit}>
🎯
</button>
</div>
</div>
);
}
// ── Verify Checkpoint ──
function VerifyCheckpoint({
taskId,
info,
onDone,
}: {
taskId: string;
info: CheckpointInfo;
onDone: () => void;
}) {
const cp = info.checkpoint;
const [reason, setReason] = useState('');
const [showReject, setShowReject] = useState(false);
const [loading, setLoading] = useState(false);
const toast = useStore((s) => s.toast);
const handleAction = async (action: string) => {
if (action === 'reject' && !showReject) { setShowReject(true); return; }
setLoading(true);
try {
const r = await api.humanInputRespond(taskId, {
node_id: info.node_id,
action,
reason,
});
if (r.ok) { toast(action === 'approve' ? '✅ 已御批' : '❌ 已驳回', 'ok'); onDone(); }
else toast(r.error || '操作失败', 'err');
} catch { toast('服务器连接失败', 'err'); }
setLoading(false);
};
return (
<div>
<div style={{ fontSize: 13, color: 'var(--muted)', lineHeight: 1.6, marginBottom: 12, padding: 12, background: 'var(--panel2)', borderRadius: 8 }}>
{cp.description}
</div>
{/* 自动核验 */}
{cp.autoCheckResults?.length ? (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', marginBottom: 6 }}></div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{cp.autoCheckResults.map((r, i) => (
<span key={i} style={{ fontSize: 11, padding: '3px 10px', borderRadius: 4, background: r.passed ? '#22c55e22' : '#ef444422', color: r.passed ? '#22c55e' : '#ef4444' }}>
{r.passed ? '✅' : '❌'} {r.label}
</span>
))}
</div>
</div>
) : null}
{/* 人工核验步骤 */}
{cp.verificationSteps?.length ? (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', marginBottom: 6 }}></div>
<div style={{ fontSize: 12, lineHeight: 1.8, paddingLeft: 16, borderLeft: '2px solid var(--line)' }}>
{cp.verificationSteps.map((s, i) => <div key={i}>{i + 1}. {s}</div>)}
</div>
</div>
) : null}
{/* 关联产出物 */}
{cp.artifacts?.length ? (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 4 }}>📦 </div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{cp.artifacts.map((a, i) => (
<a key={i} href={api.artifactDownloadUrl(taskId, a.path)} target="_blank" rel="noreferrer"
style={{ fontSize: 10, padding: '2px 8px', borderRadius: 4, border: '1px solid var(--line)', color: 'var(--fg)', textDecoration: 'none' }}>
📄 {a.name}
</a>
))}
</div>
</div>
) : null}
{/* 驳回输入 */}
{showReject && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 4 }}></div>
<textarea style={{ width: '100%', height: 60, background: 'var(--panel2)', border: '1px solid var(--line)', borderRadius: 6, padding: 8, color: 'var(--fg)', fontSize: 12 }}
value={reason} onChange={(e) => setReason(e.target.value)} placeholder="请说明驳回原因..." />
</div>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="btn-ghost" style={{ color: '#ef4444', borderColor: '#ef444444' }} onClick={() => handleAction('reject')} disabled={loading}>
</button>
<button className="btn-primary" style={{ background: 'linear-gradient(135deg, #22c55e, #16a34a)' }} onClick={() => handleAction('approve')} disabled={loading}>
</button>
</div>
</div>
);
}
// ── Action Checkpoint ──
function ActionCheckpoint({
taskId,
info,
onDone,
}: {
taskId: string;
info: CheckpointInfo;
onDone: () => void;
}) {
const cp = info.checkpoint;
const steps = cp.actionSteps || [];
const [completedSteps, setCompletedSteps] = useState<Set<string>>(new Set());
const [note, setNote] = useState('');
const [loading, setLoading] = useState(false);
const toast = useStore((s) => s.toast);
const toggleStep = (id: string) => {
setCompletedSteps((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const allDone = completedSteps.size >= steps.length;
const handleSubmit = async () => {
if (!allDone) return;
setLoading(true);
try {
const r = await api.humanInputRespond(taskId, {
node_id: info.node_id,
action: 'complete',
completed_steps: Array.from(completedSteps),
verification_note: note,
});
if (r.ok) { toast('✅ 已确认', 'ok'); onDone(); }
else toast(r.error || '操作失败', 'err');
} catch { toast('服务器连接失败', 'err'); }
setLoading(false);
};
return (
<div>
<div style={{ fontSize: 13, color: 'var(--muted)', lineHeight: 1.6, marginBottom: 12, padding: 12, background: 'var(--panel2)', borderRadius: 8 }}>
{cp.description}
</div>
{/* 进度条 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
<div style={{ flex: 1, background: 'var(--panel2)', borderRadius: 4, height: 6, overflow: 'hidden' }}>
<div style={{ height: '100%', borderRadius: 4, background: '#f59e0b', transition: 'width 0.3s', width: `${steps.length ? (completedSteps.size / steps.length * 100) : 0}%` }} />
</div>
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{completedSteps.size}/{steps.length} </span>
</div>
{/* 步骤列表 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 14 }}>
{steps.map((s) => (
<div key={s.id} onClick={() => toggleStep(s.id)} style={{ display: 'flex', alignItems: 'flex-start', gap: 10, padding: 10, background: 'var(--panel2)', borderRadius: 8, cursor: 'pointer' }}>
<div style={{
width: 20, height: 20, borderRadius: 4, border: `2px solid ${completedSteps.has(s.id) ? '#22c55e' : 'var(--line)'}`,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, flexShrink: 0,
background: completedSteps.has(s.id) ? '#22c55e' : 'transparent', color: '#fff',
}}>{completedSteps.has(s.id) ? '✓' : ''}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, fontWeight: 600, textDecoration: completedSteps.has(s.id) ? 'line-through' : 'none' }}>{s.description}</div>
{s.command && (
<pre style={{ marginTop: 4, fontSize: 11, padding: '4px 8px', background: '#0d1117', borderRadius: 4, color: '#e6edf3', fontFamily: 'monospace' }}>{s.command}</pre>
)}
</div>
</div>
))}
</div>
{/* 验证命令 */}
{cp.verificationCommand && (
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 4 }}></div>
<pre style={{ fontSize: 11, padding: '6px 10px', background: '#0d1117', borderRadius: 6, color: '#e6edf3', fontFamily: 'monospace' }}>{cp.verificationCommand}</pre>
</div>
)}
{/* 验证说明 */}
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 4 }}></div>
<input style={{ width: '100%', background: 'var(--panel2)', border: '1px solid var(--line)', borderRadius: 6, padding: '6px 10px', color: 'var(--fg)', fontSize: 12 }}
value={note} onChange={(e) => setNote(e.target.value)} placeholder="如:返回 200 OK,服务正常运行" />
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="btn-primary" disabled={!allDone || loading} onClick={handleSubmit} title={allDone ? '' : '请完成所有步骤后提交'}>
</button>
</div>
</div>
);
}
// ── 主面板 ──
export default function CheckpointPanel({
taskId,
checkpoints,
onDone,
}: {
taskId: string;
checkpoints: CheckpointInfo[];
onDone: () => void;
}) {
const [activeIdx, setActiveIdx] = useState(0);
const info = checkpoints[activeIdx];
if (!info) return null;
const cp = info.checkpoint;
const typeIcon = cp.type === 'verify' ? '🔍' : cp.type === 'decision' ? '🎯' : '🔧';
const typeColor = cp.type === 'verify' ? '#6a9eff' : cp.type === 'decision' ? '#818cf8' : '#f59e0b';
const typeLabel = cp.type === 'verify' ? '验证 Checkpoint' : cp.type === 'decision' ? '决策 Checkpoint' : '执行 Checkpoint';
return (
<div>
{/* 多节点 Tab */}
{checkpoints.length > 1 && (
<div style={{ display: 'flex', borderBottom: '1px solid var(--line)', marginBottom: 12 }}>
{checkpoints.map((c, i) => (
<button key={c.node_id}
className={`tab-btn ${i === activeIdx ? 'active' : ''}`}
onClick={() => setActiveIdx(i)}
style={{ fontSize: 12, color: i === activeIdx ? typeColor : 'var(--muted)', borderBottomColor: i === activeIdx ? typeColor : 'transparent' }}
>🛐 {c.node_name}</button>
))}
</div>
)}
{/* 类型头部 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<div style={{ width: 36, height: 36, borderRadius: '50%', background: `${typeColor}22`, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16 }}>{typeIcon}</div>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: typeColor }}>{typeLabel}</div>
<div style={{ fontSize: 11, color: 'var(--muted)' }}> {info.node_name}{AGENT_EMOJI[info.agent_id] || ''} {info.agent_id}</div>
</div>
</div>
{/* 标题 */}
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>{cp.title}</div>
{/* 按类型渲染 */}
{cp.type === 'verify' && <VerifyCheckpoint taskId={taskId} info={info} onDone={onDone} />}
{cp.type === 'decision' && <DecisionCheckpoint taskId={taskId} info={info} onDone={onDone} />}
{cp.type === 'action' && <ActionCheckpoint taskId={taskId} info={info} onDone={onDone} />}
</div>
);
}
@@ -0,0 +1,36 @@
import { useState } from 'react';
interface Props {
title: string;
message: string;
okLabel: string;
okClass?: string;
onOk: (reason: string) => void;
onCancel: () => void;
}
export default function ConfirmDialog({ title, message, okLabel, okClass, onOk, onCancel }: Props) {
const [reason, setReason] = useState('');
return (
<div className="confirm-bg open" onClick={onCancel}>
<div className="confirm-box" onClick={(e) => e.stopPropagation()}>
<div className="confirm-title" dangerouslySetInnerHTML={{ __html: title }} />
<div className="confirm-msg" dangerouslySetInnerHTML={{ __html: message }} />
<textarea
className="confirm-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="输入原因(可留空)"
rows={2}
/>
<div className="confirm-btns">
<button className="btn btn-g" onClick={onCancel}></button>
<button className={`btn btn-action ${okClass || ''}`} onClick={() => onOk(reason)}>
{okLabel}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import { useStore, isEdict } from '../store';
export default function CourtCeremony() {
const liveStatus = useStore((s) => s.liveStatus);
const [show, setShow] = useState(false);
const [out, setOut] = useState(false);
useEffect(() => {
const lastOpen = localStorage.getItem('openclaw_court_date');
const today = new Date().toISOString().substring(0, 10);
const pref = JSON.parse(localStorage.getItem('openclaw_court_pref') || '{"enabled":true}');
if (!pref.enabled || lastOpen === today) return;
localStorage.setItem('openclaw_court_date', today);
setShow(true);
const timer = setTimeout(() => skip(), 3500);
return () => clearTimeout(timer);
}, []);
const skip = () => {
setOut(true);
setTimeout(() => setShow(false), 500);
};
if (!show) return null;
const tasks = liveStatus?.tasks || [];
const jjc = tasks.filter(isEdict);
const pending = jjc.filter((t) => !['Done', 'Cancelled'].includes(t.state)).length;
const done = jjc.filter((t) => t.state === 'Done').length;
const overdue = jjc.filter(
(t) => t.state !== 'Done' && t.state !== 'Cancelled' && t.eta && new Date(t.eta.replace(' ', 'T')) < new Date()
).length;
const d = new Date();
const days = ['日', '一', '二', '三', '四', '五', '六'];
const dateStr = `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}日 · ${days[d.getDay()]}曜日`;
return (
<div className={`ceremony-bg${out ? ' out' : ''}`} onClick={skip}>
<div className="crm-glow" />
<div className="crm-line1 in">🏛 </div>
<div className="crm-line2 in"></div>
<div className="crm-line3 in">
{pending} · {done} {overdue > 0 && ` · ⚠ 超期 ${overdue}`}
</div>
<div className="crm-date in">{dateStr}</div>
<div className="crm-skip" style={{ fontSize: '16px', opacity: 0.6, marginTop: '40px' }}> 3s</div>
</div>
);
}
@@ -0,0 +1,774 @@
/**
* 军议大厅 — 多将军实时讨论可视化组件
*
* 灵感来自 nvwa 项目的故事剧场 + 协作工坊 + 虚拟生活
* 功能:
* - 可视化朝堂布局,将军站位
* - 实时群聊讨论,将军各抒己见
* - 皇帝(用户)随时发言参与
* - 天命降临(上帝视角)改变讨论走向
* - 命运骰子:随机事件增加趣味性
* - 自动推进 / 手动推进
*/
import { useState, useRef, useEffect, useCallback } from 'react';
import { useStore, DEPTS } from '../store';
import { api } from '../api';
// ── 常量 ──
const OFFICIAL_COLORS: Record<string, string> = {
taizi: '#e8a040', zhongshu: '#a07aff', menxia: '#6a9eff', shangshu: '#2ecc8a',
libu: '#f5c842', hubu: '#ff9a6a', bingbu: '#ff5270', xingbu: '#cc4444',
gongbu: '#44aaff', libu_hr: '#9b59b6',
};
const EMOTION_EMOJI: Record<string, string> = {
neutral: '', confident: '😏', worried: '😟', angry: '😤',
thinking: '🤔', amused: '😄', happy: '😊',
};
const COURT_POSITIONS: Record<string, { x: number; y: number }> = {
// 左列
zhongshu: { x: 15, y: 25 }, menxia: { x: 15, y: 45 }, shangshu: { x: 15, y: 65 },
// 右列
libu: { x: 85, y: 20 }, hubu: { x: 85, y: 35 }, bingbu: { x: 85, y: 50 },
xingbu: { x: 85, y: 65 }, gongbu: { x: 85, y: 80 },
// 中间
taizi: { x: 50, y: 20 }, libu_hr: { x: 50, y: 80 },
};
interface CourtMessage {
type: string;
content: string;
official_id?: string;
official_name?: string;
emotion?: string;
action?: string;
timestamp?: number;
}
interface CourtSession {
session_id: string;
topic: string;
officials: Array<{
id: string;
name: string;
emoji: string;
role: string;
personality: string;
speaking_style: string;
}>;
messages: CourtMessage[];
round: number;
phase: string;
}
export default function CourtDiscussion() {
// Phase: setup | session
const [phase, setPhase] = useState<'setup' | 'session'>('setup');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [topic, setTopic] = useState('');
const [session, setSession] = useState<CourtSession | null>(null);
const [loading, setLoading] = useState(false);
const [autoPlay, setAutoPlay] = useState(false);
const autoPlayRef = useRef(false);
// 皇帝发言
const [userInput, setUserInput] = useState('');
// 天命降临
const [showDecree, setShowDecree] = useState(false);
const [decreeInput, setDecreeInput] = useState('');
const [decreeFlash, setDecreeFlash] = useState(false);
// 命运骰子
const [diceRolling, setDiceRolling] = useState(false);
const [diceResult, setDiceResult] = useState<string | null>(null);
// 活跃说话将军
const [speakingId, setSpeakingId] = useState<string | null>(null);
// 将军情绪
const [emotions, setEmotions] = useState<Record<string, string>>({});
const messagesEndRef = useRef<HTMLDivElement>(null);
const toast = useStore((s) => s.toast);
const liveStatus = useStore((s) => s.liveStatus);
// 自动滚到底部
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [session?.messages?.length]);
// 自动推进
useEffect(() => {
autoPlayRef.current = autoPlay;
}, [autoPlay]);
useEffect(() => {
if (!autoPlay || !session || loading) return;
const timer = setInterval(() => {
if (autoPlayRef.current && !loading) {
handleAdvance();
}
}, 5000);
return () => clearInterval(timer);
}, [autoPlay, session, loading]);
// ── 切换将军选中 ──
const toggleOfficial = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else if (next.size < 8) next.add(id);
return next;
});
};
// ── 开始议政 ──
const handleStart = async () => {
if (!topic.trim() || selectedIds.size < 2 || loading) return;
setLoading(true);
try {
const res = await api.courtDiscussStart(topic, Array.from(selectedIds));
if (!res.ok) throw new Error(res.error || '启动失败');
setSession(res as unknown as CourtSession);
setPhase('session');
} catch (e: unknown) {
toast((e as Error).message || '启动失败', 'err');
} finally {
setLoading(false);
}
};
// ── 推进讨论 ──
const handleAdvance = useCallback(async (userMsg?: string, decree?: string) => {
if (!session || loading) return;
setLoading(true);
try {
const res = await api.courtDiscussAdvance(session.session_id, userMsg, decree);
if (!res.ok) throw new Error(res.error || '推进失败');
// 更新 session messages(追加新消息)
setSession((prev) => {
if (!prev) return prev;
const newMsgs: CourtMessage[] = [];
if (userMsg) {
newMsgs.push({ type: 'emperor', content: userMsg, timestamp: Date.now() / 1000 });
}
if (decree) {
newMsgs.push({ type: 'decree', content: decree, timestamp: Date.now() / 1000 });
}
const aiMsgs = (res.new_messages || []).map((m: Record<string, string>) => ({
type: 'official',
official_id: m.official_id,
official_name: m.name,
content: m.content,
emotion: m.emotion,
action: m.action,
timestamp: Date.now() / 1000,
}));
if (res.scene_note) {
newMsgs.push({ type: 'scene_note', content: res.scene_note, timestamp: Date.now() / 1000 });
}
return {
...prev,
round: res.round ?? prev.round + 1,
messages: [...prev.messages, ...newMsgs, ...aiMsgs],
};
});
// 动画:依次高亮说话的将军
const aiMsgs = res.new_messages || [];
if (aiMsgs.length > 0) {
const emotionMap: Record<string, string> = {};
let idx = 0;
const cycle = () => {
if (idx < aiMsgs.length) {
setSpeakingId(aiMsgs[idx].official_id);
emotionMap[aiMsgs[idx].official_id] = aiMsgs[idx].emotion || 'neutral';
idx++;
setTimeout(cycle, 1200);
} else {
setSpeakingId(null);
}
};
cycle();
setEmotions((prev) => ({ ...prev, ...emotionMap }));
}
} catch {
// silently
} finally {
setLoading(false);
}
}, [session, loading]);
// ── 皇帝发言 ──
const handleEmperor = () => {
const msg = userInput.trim();
if (!msg) return;
setUserInput('');
handleAdvance(msg);
};
// ── 天命降临 ──
const handleDecree = () => {
const msg = decreeInput.trim();
if (!msg) return;
setDecreeInput('');
setShowDecree(false);
setDecreeFlash(true);
setTimeout(() => setDecreeFlash(false), 800);
handleAdvance(undefined, msg);
};
// ── 命运骰子 ──
const handleDice = async () => {
if (loading || diceRolling) return;
setDiceRolling(true);
setDiceResult(null);
// 滚动动画
let count = 0;
const timer = setInterval(async () => {
count++;
setDiceResult('🎲 命运轮转中...');
if (count >= 6) {
clearInterval(timer);
try {
const res = await api.courtDiscussFate();
const event = res.event || '边疆急报传来';
setDiceResult(event);
setDiceRolling(false);
// 自动作为天命降临注入
handleAdvance(undefined, `【命运骰子】${event}`);
} catch {
setDiceResult('命运之力暂时无法触及');
setDiceRolling(false);
}
}
}, 200);
};
// ── 结束议政 ──
const handleConclude = async () => {
if (!session) return;
setLoading(true);
try {
const res = await api.courtDiscussConclude(session.session_id);
if (res.summary) {
setSession((prev) =>
prev
? {
...prev,
phase: 'concluded',
messages: [
...prev.messages,
{ type: 'system', content: `📋 军议大厅结束 — ${res.summary}`, timestamp: Date.now() / 1000 },
],
}
: prev,
);
}
setAutoPlay(false);
} catch {
toast('结束失败', 'err');
} finally {
setLoading(false);
}
};
// ── 重置 ──
const handleReset = () => {
if (session) {
api.courtDiscussDestroy(session.session_id).catch(() => {});
}
setPhase('setup');
setSession(null);
setAutoPlay(false);
setEmotions({});
setSpeakingId(null);
setDiceResult(null);
};
// ── 预设议题(从当前旨意中提取)──
const activeEdicts = (liveStatus?.tasks || []).filter(
(t) => /^JJC-/i.test(t.id) && !['Done', 'Cancelled'].includes(t.state),
);
const presetTopics = [
...activeEdicts.slice(0, 3).map((t) => ({
text: `讨论旨意 ${t.id}${t.title}`,
taskId: t.id,
icon: '📜',
})),
{ text: '讨论系统架构优化方案', taskId: '', icon: '🏗️' },
{ text: '评估当前项目进展和风险', taskId: '', icon: '📊' },
{ text: '制定下周工作计划', taskId: '', icon: '📋' },
{ text: '紧急问题:线上Bug排查方案', taskId: '', icon: '🚨' },
];
// ═══════════════════
// 渲染:设置页
// ═══════════════════
if (phase === 'setup') {
return (
<div className="space-y-6">
{/* Header */}
<div className="text-center py-4">
<h2 className="text-xl font-bold bg-gradient-to-r from-amber-400 to-purple-400 bg-clip-text text-transparent">
🏛
</h2>
<p className="text-xs text-[var(--muted)] mt-1">
殿 ·
</p>
</div>
{/* 选择将军 */}
<div className="bg-[var(--panel)] rounded-xl p-4 border border-[var(--line)]">
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-semibold">👔 </span>
<span className="text-xs text-[var(--muted)]">{selectedIds.size}/82</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
{DEPTS.map((d) => {
const active = selectedIds.has(d.id);
const color = OFFICIAL_COLORS[d.id] || '#6a9eff';
return (
<button
key={d.id}
onClick={() => toggleOfficial(d.id)}
className="p-2.5 rounded-lg border transition-all text-left"
style={{
borderColor: active ? color + '80' : 'var(--line)',
background: active ? color + '15' : 'var(--panel2)',
boxShadow: active ? `0 0 12px ${color}20` : 'none',
}}
>
<div className="flex items-center gap-1.5">
<span className="text-lg">{d.emoji}</span>
<div>
<div className="text-xs font-semibold" style={{ color: active ? color : 'var(--text)' }}>
{d.label}
</div>
<div className="text-[10px] text-[var(--muted)]">{d.role}</div>
</div>
{active && (
<span
className="ml-auto w-4 h-4 rounded-full flex items-center justify-center text-[10px] text-white"
style={{ background: color }}
>
</span>
)}
</div>
</button>
);
})}
</div>
</div>
{/* 议题 */}
<div className="bg-[var(--panel)] rounded-xl p-4 border border-[var(--line)]">
<div className="text-sm font-semibold mb-2">📜 </div>
{presetTopics.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-3">
{presetTopics.map((p, i) => (
<button
key={i}
onClick={() => setTopic(p.text)}
className="text-xs px-2.5 py-1.5 rounded-lg border border-[var(--line)] hover:border-[var(--acc)] hover:text-[var(--acc)] transition-colors"
style={{
background: topic === p.text ? 'var(--acc)' + '18' : 'transparent',
borderColor: topic === p.text ? 'var(--acc)' : undefined,
color: topic === p.text ? 'var(--acc)' : undefined,
}}
>
{p.icon} {p.text}
</button>
))}
</div>
)}
<textarea
className="w-full bg-[var(--panel2)] rounded-lg p-3 text-sm border border-[var(--line)] focus:border-[var(--acc)] outline-none resize-none"
rows={2}
placeholder="或自定义议题..."
value={topic}
onChange={(e) => setTopic(e.target.value)}
/>
</div>
{/* 功能特性标签 */}
<div className="flex flex-wrap gap-1.5">
{[
'👑 皇帝发言', '⚡ 天命降临', '🎲 命运骰子',
'🔄 自动推进', '📜 讨论记录',
].map((tag) => (
<span key={tag} className="text-[10px] px-2 py-1 rounded-full border border-[var(--line)] text-[var(--muted)]">
{tag}
</span>
))}
</div>
{/* 开始按钮 */}
<button
onClick={handleStart}
disabled={selectedIds.size < 2 || !topic.trim() || loading}
className="w-full py-3 rounded-xl font-semibold text-sm transition-all border-0"
style={{
background:
selectedIds.size >= 2 && topic.trim()
? 'linear-gradient(135deg, #6a9eff, #a07aff)'
: 'var(--panel2)',
color: selectedIds.size >= 2 && topic.trim() ? '#fff' : 'var(--muted)',
opacity: loading ? 0.6 : 1,
cursor: selectedIds.size >= 2 && topic.trim() && !loading ? 'pointer' : 'not-allowed',
}}
>
{loading ? '召集中...' : `🏛 开始朝议(${selectedIds.size}位上殿)`}
</button>
</div>
);
}
// ═══════════════════
// 渲染:议政进行中
// ═══════════════════
const officials = session?.officials || [];
const messages = session?.messages || [];
return (
<div className="space-y-3">
{/* 顶部控制栏 */}
<div className="flex items-center justify-between flex-wrap gap-2 bg-[var(--panel)] rounded-xl px-4 py-2 border border-[var(--line)]">
<div className="flex items-center gap-2">
<span className="text-sm font-bold">🏛 </span>
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[var(--acc)]20 text-[var(--acc)] border border-[var(--acc)]30">
{session?.round || 0}
</span>
{session?.phase === 'concluded' && (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-green-900/40 text-green-400 border border-green-800">
</span>
)}
</div>
<div className="flex items-center gap-1.5">
<button
onClick={() => setShowDecree(!showDecree)}
className="text-xs px-2.5 py-1 rounded-lg border border-amber-600/40 text-amber-400 hover:bg-amber-900/20 transition"
title="天命降临 — 上帝视角干预"
>
</button>
<button
onClick={handleDice}
disabled={diceRolling || loading}
className="text-xs px-2.5 py-1 rounded-lg border border-purple-600/40 text-purple-400 hover:bg-purple-900/20 transition"
title="命运骰子 — 随机事件"
>
🎲 {diceRolling ? '...' : '骰子'}
</button>
<button
onClick={() => setAutoPlay(!autoPlay)}
className={`text-xs px-2.5 py-1 rounded-lg border transition ${autoPlay
? 'border-green-600/40 text-green-400 bg-green-900/20'
: 'border-[var(--line)] text-[var(--muted)] hover:text-[var(--text)]'
}`}
>
{autoPlay ? '⏸ 暂停' : '▶ 自动'}
</button>
{session?.phase !== 'concluded' && (
<button
onClick={handleConclude}
className="text-xs px-2.5 py-1 rounded-lg border border-[var(--line)] text-[var(--muted)] hover:text-[var(--warn)] hover:border-[var(--warn)]40 transition"
>
📋
</button>
)}
<button
onClick={handleReset}
className="text-xs px-2 py-1 rounded-lg border border-red-900/40 text-red-400/70 hover:text-red-400 transition"
>
</button>
</div>
</div>
{/* 天命降临面板 */}
{showDecree && (
<div
className="bg-gradient-to-br from-amber-950/40 to-purple-950/30 rounded-xl p-4 border border-amber-700/30"
style={{ animation: 'fadeIn .3s' }}
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-amber-400"> </span>
<button onClick={() => setShowDecree(false)} className="text-xs text-[var(--muted)]">
</button>
</div>
<p className="text-[10px] text-amber-300/60 mb-2">
</p>
<div className="flex gap-2">
<input
value={decreeInput}
onChange={(e) => setDecreeInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleDecree()}
placeholder="例如:突然发现预算多出一倍..."
className="flex-1 bg-black/30 rounded-lg px-3 py-1.5 text-sm border border-amber-800/40 outline-none focus:border-amber-600"
/>
<button
onClick={handleDecree}
disabled={!decreeInput.trim()}
className="px-4 py-1.5 rounded-lg bg-gradient-to-r from-amber-600 to-purple-600 text-white text-xs font-semibold disabled:opacity-40"
>
</button>
</div>
</div>
)}
{/* 命运骰子结果 */}
{diceResult && (
<div
className="bg-purple-950/40 rounded-lg px-3 py-2 border border-purple-700/30 text-xs text-purple-300 flex items-center gap-2"
style={{ animation: 'fadeIn .3s' }}
>
<span className="text-lg">🎲</span>
{diceResult}
</div>
)}
{/* 天命降临闪光效果 */}
{decreeFlash && (
<div
className="fixed inset-0 pointer-events-none z-50"
style={{
background: 'radial-gradient(circle, rgba(255,200,50,0.3), transparent 70%)',
animation: 'fadeOut .8s forwards',
}}
/>
)}
{/* 议题 */}
<div className="text-xs text-center text-[var(--muted)] py-1">
📜 {session?.topic || ''}
</div>
{/* 主内容:朝堂布局 + 聊天记录 */}
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-3">
{/* 左侧:朝堂可视化 */}
<div className="bg-[var(--panel)] rounded-xl p-3 border border-[var(--line)] relative overflow-hidden min-h-[320px]">
{/* 龙椅 */}
<div className="text-center mb-2">
<div className="inline-block px-3 py-1 rounded-lg bg-gradient-to-b from-amber-800/40 to-amber-950/40 border border-amber-700/30">
<span className="text-lg">👑</span>
<div className="text-[10px] text-amber-400/80"> </div>
</div>
</div>
{/* 将军站位 */}
<div className="relative" style={{ minHeight: 250 }}>
{/* 左列标签 */}
<div className="absolute left-0 top-0 text-[9px] text-[var(--muted)] opacity-50"></div>
<div className="absolute right-0 top-0 text-[9px] text-[var(--muted)] opacity-50"></div>
{officials.map((o) => {
const pos = COURT_POSITIONS[o.id] || { x: 50, y: 50 };
const color = OFFICIAL_COLORS[o.id] || '#6a9eff';
const isSpeaking = speakingId === o.id;
const emotion = emotions[o.id] || 'neutral';
return (
<div
key={o.id}
className="absolute transition-all duration-500"
style={{
left: `${pos.x}%`,
top: `${pos.y}%`,
transform: 'translate(-50%, -50%)',
}}
>
{/* 说话光圈 */}
{isSpeaking && (
<div
className="absolute -inset-2 rounded-full"
style={{
background: `radial-gradient(circle, ${color}40, transparent)`,
animation: 'pulse 1s infinite',
}}
/>
)}
{/* 头像 */}
<div
className="relative w-10 h-10 rounded-full flex items-center justify-center text-lg border-2 transition-all"
style={{
borderColor: isSpeaking ? color : color + '40',
background: isSpeaking ? color + '30' : color + '10',
transform: isSpeaking ? 'scale(1.2)' : 'scale(1)',
boxShadow: isSpeaking ? `0 0 16px ${color}50` : 'none',
}}
>
{o.emoji}
{/* 情绪气泡 */}
{EMOTION_EMOJI[emotion] && (
<span
className="absolute -top-1 -right-1 text-xs"
style={{ animation: 'bounceIn .3s' }}
>
{EMOTION_EMOJI[emotion]}
</span>
)}
</div>
{/* 名字 */}
<div
className="text-[9px] text-center mt-0.5 whitespace-nowrap"
style={{ color: isSpeaking ? color : 'var(--muted)' }}
>
{o.name}
</div>
</div>
);
})}
</div>
</div>
{/* 右侧:聊天记录 */}
<div className="bg-[var(--panel)] rounded-xl border border-[var(--line)] flex flex-col" style={{ maxHeight: 500 }}>
{/* 消息列表 */}
<div className="flex-1 overflow-y-auto p-3 space-y-2" style={{ minHeight: 200 }}>
{messages.map((msg, i) => (
<MessageBubble key={i} msg={msg} officials={officials} />
))}
{loading && (
<div className="text-xs text-[var(--muted)] text-center py-2" style={{ animation: 'pulse 1.5s infinite' }}>
🏛 ...
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* 皇帝输入栏 */}
{session?.phase !== 'concluded' && (
<div className="border-t border-[var(--line)] p-2 flex gap-2">
<input
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleEmperor()}
placeholder="朕有话说..."
className="flex-1 bg-[var(--panel2)] rounded-lg px-3 py-1.5 text-sm border border-[var(--line)] outline-none focus:border-amber-600"
/>
<button
onClick={handleEmperor}
disabled={!userInput.trim() || loading}
className="px-4 py-1.5 rounded-lg text-xs font-semibold border-0 disabled:opacity-40"
style={{
background: userInput.trim() ? 'linear-gradient(135deg, #e8a040, #f5c842)' : 'var(--panel2)',
color: userInput.trim() ? '#000' : 'var(--muted)',
}}
>
👑
</button>
<button
onClick={() => handleAdvance()}
disabled={loading}
className="px-3 py-1.5 rounded-lg text-xs border border-[var(--acc)]40 text-[var(--acc)] hover:bg-[var(--acc)]10 disabled:opacity-40 transition"
>
</button>
</div>
)}
</div>
</div>
</div>
);
}
// ── 消息气泡 ──
function MessageBubble({
msg,
officials,
}: {
msg: CourtMessage;
officials: Array<{ id: string; name: string; emoji: string }>;
}) {
const color = OFFICIAL_COLORS[msg.official_id || ''] || '#6a9eff';
const official = officials.find((o) => o.id === msg.official_id);
if (msg.type === 'system') {
return (
<div className="text-center text-[10px] text-[var(--muted)] py-1 border-b border-[var(--line)] border-dashed">
{msg.content}
</div>
);
}
if (msg.type === 'scene_note') {
return (
<div className="text-center text-[10px] text-purple-400/80 py-1 italic">
{msg.content}
</div>
);
}
if (msg.type === 'emperor') {
return (
<div className="flex justify-end">
<div className="max-w-[80%] bg-gradient-to-br from-amber-900/40 to-amber-800/20 rounded-xl px-3 py-2 border border-amber-700/30">
<div className="text-[10px] text-amber-400 mb-0.5">👑 </div>
<div className="text-sm">{msg.content}</div>
</div>
</div>
);
}
if (msg.type === 'decree') {
return (
<div className="text-center py-2">
<div className="inline-block bg-gradient-to-r from-amber-900/30 via-purple-900/30 to-amber-900/30 rounded-lg px-4 py-2 border border-amber-600/30">
<div className="text-xs text-amber-400 font-bold"> </div>
<div className="text-sm mt-0.5">{msg.content}</div>
</div>
</div>
);
}
// 将军消息
return (
<div className="flex gap-2 items-start" style={{ animation: 'fadeIn .4s' }}>
<div
className="w-7 h-7 rounded-full flex items-center justify-center text-sm flex-shrink-0 border"
style={{ borderColor: color + '60', background: color + '15' }}
>
{official?.emoji || '💬'}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<span className="text-[11px] font-semibold" style={{ color }}>
{msg.official_name || '将军'}
</span>
{msg.emotion && EMOTION_EMOJI[msg.emotion] && (
<span className="text-xs">{EMOTION_EMOJI[msg.emotion]}</span>
)}
</div>
<div className="text-sm leading-relaxed">
{msg.content?.split(/(\*[^*]+\*)/).map((part, i) => {
if (part.startsWith('*') && part.endsWith('*')) {
return (
<span key={i} className="text-[var(--muted)] italic text-xs">
{part.slice(1, -1)}
</span>
);
}
return <span key={i}>{part}</span>;
})}
</div>
</div>
</div>
);
}
+524
View File
@@ -0,0 +1,524 @@
import { useState } from 'react';
import { useStore, isEdict, isArchived, getPipeStatus, stateLabel, deptColor, PIPE, STATE_LABEL } from '../store';
import ArtifactList from './ArtifactList';
/** Format ISO timestamp to relative duration string */
function formatDuration(iso: string): string {
try {
const then = new Date(iso).getTime();
const now = Date.now();
const diffMs = now - then;
if (diffMs < 0) return '刚刚';
const mins = Math.floor(diffMs / 60000);
if (mins < 1) return '刚刚';
if (mins < 60) return `${mins}分钟`;
const hrs = Math.floor(mins / 60);
const remainMins = mins % 60;
if (hrs < 24) return `${hrs}小时${remainMins ? remainMins + '分' : ''}`;
const days = Math.floor(hrs / 24);
return `${days}${hrs % 24 ? (hrs % 24) + '小时' : ''}`;
} catch { return ''; }
}
import { api, type Task } from '../api';
// 排序权重
const STATE_ORDER: Record<string, number> = {
created: 0, planning: 1, challenging: 2, assigned: 3,
executing: 4, paused: 4.5, reviewing: 5,
completed: 8, failed: 7, cancelled: 9, escalated: 6,
// legacy
Doing: 4, Review: 5, Assigned: 3, Menxia: 1, Zhongshu: 2,
Taizi: 0, Inbox: 0, Blocked: 6, Next: 0, Done: 8, Cancelled: 9,
};
// 状态筛选定义
const STATUS_FILTERS = [
{ key: 'all', label: '全部', icon: '📋' },
{ key: 'planning', label: '待拆解', icon: '🐦' },
{ key: 'challenging', label: '审核中', icon: '🦅' },
{ key: 'executing', label: '执行中', icon: '⚔️' },
{ key: 'completed', label: '已完成', icon: '🏆' },
{ key: 'failed', label: '失败', icon: '❌' },
{ key: 'paused', label: '暂停', icon: '⏸️' },
{ key: 'cancelled', label: '已取消', icon: '🚫' },
];
// 状态到筛选key的映射
function stateToFilterKey(state: string, task: Task): string {
const meta = task.sourceMeta || {};
// moziplus 用 status + plan_statusedict 用 state + sourceMeta
const s = task.status || state; // 优先用 moziplus status
const planStatus = task.plan_status || (meta.plan_status as string) || '';
if (s === 'planning' && planStatus === 'challenging') return 'challenging';
if (s === 'planning') return 'planning';
if (s === 'executing') return 'executing';
if (s === 'reviewing') return 'executing'; // reviewing is a sub-state of executing
if (s === 'completed' || s === 'Done') return 'completed';
if (s === 'failed') return 'failed';
if (s === 'paused') return 'paused';
if (s === 'cancelled') return 'cancelled';
return s;
}
function MiniPipe({ task }: { task: Task }) {
const stages = getPipeStatus(task);
return (
<div className="ec-pipe">
{stages.map((s, i) => (
<span key={s.key} style={{ display: 'contents' }}>
<div className={`ep-node ${s.status}`}>
<div className="ep-icon">{s.icon}</div>
<div className="ep-name">{s.dept}</div>
</div>
{i < stages.length - 1 && <div className="ep-arrow"></div>}
</span>
))}
</div>
);
}
/** 获取任务最近事件的描述 */
function getLatestEvent(task: Task): string | null {
// 优先取 flow_log 最后一条的 remark
if (task.flow_log && task.flow_log.length > 0) {
const last = task.flow_log[task.flow_log.length - 1];
if (last.remark) return last.remark;
}
// 次选取 activity 最后一条的 text
if (task.activity && task.activity.length > 0) {
const last = task.activity[task.activity.length - 1];
if (last.text) return last.text;
}
return null;
}
function EdictCard({ task }: { task: Task }) {
const setModalTaskId = useStore((s) => s.setModalTaskId);
const toast = useStore((s) => s.toast);
const loadAll = useStore((s) => s.loadAll);
const hb = task.heartbeat || { status: 'unknown', label: '⚪' };
const stCls = 'st-' + (task.state || '');
const deptCls = 'dt-' + (task.org || '').replace(/\s/g, '');
const curStage = PIPE.find((_, i) => getPipeStatus(task)[i].status === 'active');
const todos = task.todos || [];
const todoDone = todos.filter((x) => x.status === 'completed').length;
const todoTotal = todos.length;
const taskState = task.status || task.state || '';
// 前端本地跟 ACTION_GUARDS 同样的映射表(不调 API,卡片列表性能要求)
const ACTION_GUARDS: Record<string, string[]> = {
pause: ['planning', 'executing'],
resume: ['paused', 'cancelled'],
cancel: ['created', 'planning', 'executing', 'paused', 'escalated', 'failed'],
retry: ['failed'],
escalate: ['executing', 'failed'],
rollback: ['escalated'],
steer: ['executing'],
};
const isAllowed = (action: string) => {
if (taskState === 'cancelling' || taskState === 'pausing') return false; // BUG-6: 中间态禁止操作
const allowed = ACTION_GUARDS[action] || [];
return allowed.includes(taskState);
};
const canStop = isAllowed('pause');
const canResume = isAllowed('resume');
const canCancel = isAllowed('cancel');
const archived = isArchived(task);
const isBlocked = task.block && task.block !== '无' && task.block !== '-';
const isCompleted = ['completed', 'Done'].includes(taskState);
const latestEvent = getLatestEvent(task);
const handleAction = async (action: string, e: React.MouseEvent) => {
e.stopPropagation();
if (action === 'stop' || action === 'cancel') {
const reason = prompt(action === 'stop' ? '请输入暂停原因:' : '请输入取消原因:');
if (reason === null) return;
try {
const r = await api.taskAction(task.id, action, reason);
if (r.ok) { toast(r.message || '操作成功'); loadAll(); }
else toast(r.error || '操作失败', 'err');
} catch { toast('服务器连接失败', 'err'); }
} else if (action === 'resume') {
try {
const r = await api.taskAction(task.id, 'resume', '恢复执行');
if (r.ok) { toast(r.message || '已恢复'); loadAll(); }
else toast(r.error || '操作失败', 'err');
} catch { toast('服务器连接失败', 'err'); }
}
};
const handleArchive = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
const r = await api.archiveTask(task.id, !task.archived);
if (r.ok) { toast(r.message || '操作成功'); loadAll(); }
else toast(r.error || '操作失败', 'err');
} catch { toast('服务器连接失败', 'err'); }
};
// M3: 检测 waiting_human 节点
const waitingNodes = (task.nodes || []).filter((n: any) => n.status === 'waiting_human');
const hasWaitingHuman = waitingNodes.length > 0;
return (
<div
className={`edict-card${archived ? ' archived' : ''}${hasWaitingHuman ? ' checkpoint-card' : ''}`}
onClick={() => setModalTaskId(task.id)}
style={hasWaitingHuman ? { border: '2px solid #f59e0b', animation: 'pulse-border 2s ease-in-out infinite' } : undefined}
>
<MiniPipe task={task} />
<div className="ec-id">{task.id}</div>
<div className="ec-title">{task.title || '(无标题)'}</div>
<div className="ec-meta">
<span className={`tag ${stCls}`}>{stateLabel(task)}</span>
{task.org && <span className={`tag ${deptCls}`}>{task.org}</span>}
{task.updated_at && (
<span style={{ fontSize: 11, color: 'var(--muted)' }}>
{formatDuration(task.updated_at)}
</span>
)}
{curStage && (
<span style={{ fontSize: 11, color: 'var(--muted)' }}>
: <b style={{ color: deptColor(curStage.dept) }}>{curStage.dept} · {curStage.action}</b>
</span>
)}
</div>
{task.now && task.now !== '-' && (
<div style={{ fontSize: 11, color: 'var(--muted)', lineHeight: 1.5, marginBottom: 6 }}>
{task.now.substring(0, 80)}
</div>
)}
{(task.review_round || 0) > 0 && (
<div style={{ fontSize: 11, marginBottom: 6 }}>
{Array.from({ length: task.review_round || 0 }, (_, i) => (
<span
key={i}
style={{
display: 'inline-block', width: 14, height: 14, borderRadius: '50%',
background: i < (task.review_round || 0) - 1 ? '#1a3a6a22' : 'var(--acc)22',
border: `1px solid ${i < (task.review_round || 0) - 1 ? '#2a4a8a' : 'var(--acc)'}`,
fontSize: 9, textAlign: 'center', lineHeight: '13px', marginRight: 2,
color: i < (task.review_round || 0) - 1 ? '#4a6aaa' : 'var(--acc)',
}}
>
{i + 1}
</span>
))}
<span style={{ color: 'var(--muted)', fontSize: 10 }}> {task.review_round} </span>
</div>
)}
{todoTotal > 0 && (
<div className="ec-todo-bar">
<span>📋 {todoDone}/{todoTotal}</span>
<div className="ec-todo-track">
<div className="ec-todo-fill" style={{ width: `${Math.round((todoDone / todoTotal) * 100)}%` }} />
</div>
<span>{todoDone === todoTotal ? '✅ 全部完成' : '🔄 进行中'}</span>
</div>
)}
<div className="ec-footer">
<span className={`hb ${hb.status}`}>{hb.label}</span>
{isBlocked && (
<span className="tag" style={{ borderColor: '#ff527044', color: 'var(--danger)', background: '#200a10' }}>
🚫 {task.block}
</span>
)}
{taskState === 'cancelling' && (
<div className="tag" style={{ background: '#ff527022', color: '#ff5270', borderColor: '#ff527044' }}>
...
</div>
)}
{taskState === 'pausing' && (
<div className="tag" style={{ background: '#ffd70022', color: '#ffd700', borderColor: '#ffd70044' }}>
...
</div>
)}
{task.eta && task.eta !== '-' && (
<span style={{ fontSize: 11, color: 'var(--muted)' }}>📅 {task.eta}</span>
)}
</div>
{/* 最近事件 */}
{latestEvent && (
<div style={{
fontSize: 10, color: '#8899aa', lineHeight: 1.4, marginTop: 4,
padding: '3px 6px', background: '#1a1f2e', borderRadius: 4,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
💬 {latestEvent.substring(0, 100)}
</div>
)}
{/* M3: waiting_human 通知 */}
{hasWaitingHuman && (
<div style={{ marginTop: 6, background: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.2)', borderRadius: 8, padding: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<span>🛐</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#f59e0b' }}>({waitingNodes.length})</span>
</div>
{waitingNodes.slice(0, 2).map((n: any) => (
<div key={n.node_id} style={{ fontSize: 11, color: 'var(--fg)' }}>
🎯 {n.name || n.node_id}{n.agent_id}
</div>
))}
<button className="btn-small" style={{ marginTop: 4, borderColor: '#f59e0b', color: '#f59e0b' }} onClick={(e) => { e.stopPropagation(); setModalTaskId(task.id); }}> </button>
</div>
)}
<div className="ec-actions" onClick={(e) => e.stopPropagation()}>
{canStop && (
<button className="mini-act" onClick={(e) => handleAction('stop', e)}> </button>
)}
{canCancel && (
<button className="mini-act danger" onClick={(e) => handleAction('cancel', e)}>🚫 </button>
)}
{canResume && (
<button className="mini-act" onClick={(e) => handleAction('resume', e)}> </button>
)}
{isCompleted && !task.archived && (
<button className="mini-act" onClick={handleArchive}>📦 </button>
)}
{task.archived && (
<button className="mini-act" onClick={handleArchive}>📤 </button>
)}
</div>
{/* M3: 成果物摘要(已完成 + 执行中任务) */}
{(isCompleted || taskState === 'executing') && (
<ArtifactList taskId={task.id} isCompleted={isCompleted} />
)}
</div>
);
}
export default function EdictBoard() {
const liveStatus = useStore((s) => s.liveStatus);
const edictFilter = useStore((s) => s.edictFilter);
const setEdictFilter = useStore((s) => s.setEdictFilter);
const statusFilter = useStore((s) => s.statusFilter);
const setStatusFilter = useStore((s) => s.setStatusFilter);
const searchQuery = useStore((s) => s.searchQuery);
const setSearchQuery = useStore((s) => s.setSearchQuery);
const toast = useStore((s) => s.toast);
const loadAll = useStore((s) => s.loadAll);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [batchLoading, setBatchLoading] = useState(false);
const tasks = liveStatus?.tasks || [];
const allEdicts = tasks.filter(isEdict);
const activeEdicts = allEdicts.filter((t) => !isArchived(t));
const archivedEdicts = allEdicts.filter((t) => isArchived(t));
// Step 1: archive filter
let edicts: Task[];
if (edictFilter === 'active') edicts = activeEdicts;
else if (edictFilter === 'archived') edicts = archivedEdicts;
else edicts = allEdicts;
// Step 2: status filter
if (statusFilter !== 'all') {
edicts = edicts.filter((t) => stateToFilterKey(t.state, t) === statusFilter);
}
// Step 3: search filter
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase();
edicts = edicts.filter((t) =>
(t.title || '').toLowerCase().includes(q) ||
(t.id || '').toLowerCase().includes(q)
);
}
// Sort
edicts.sort((a, b) => (STATE_ORDER[a.state] ?? 9) - (STATE_ORDER[b.state] ?? 9));
const unArchivedDone = allEdicts.filter((t) => !t.archived && ['completed', 'Done', 'Cancelled', 'cancelled'].includes(t.status || t.state));
const handleArchiveAll = async () => {
if (!confirm('将所有已完成/已取消的任务移入归档?')) return;
try {
const r = await api.archiveAllDone();
if (r.ok) { toast(`📦 ${r.count || 0} 道任务已归档`); loadAll(); }
else toast(r.error || '批量归档失败', 'err');
} catch { toast('服务器连接失败', 'err'); }
};
const handleScan = async () => {
try {
const r = await api.schedulerScan();
if (r.ok) toast(`🧭 引擎巡检完成:${r.count || 0} 个动作`);
else toast(r.error || '巡检失败', 'err');
loadAll();
} catch { toast('服务器连接失败', 'err'); }
};
// Count per status for badges
const statusCounts: Record<string, number> = { all: 0 };
for (const t of (edictFilter === 'active' ? activeEdicts : edictFilter === 'archived' ? archivedEdicts : allEdicts)) {
statusCounts.all++;
const key = stateToFilterKey(t.state, t);
statusCounts[key] = (statusCounts[key] || 0) + 1;
}
return (
<div>
{/* Archive Bar */}
<div className="archive-bar">
<span className="ab-label">:</span>
{(['active', 'archived', 'all'] as const).map((f) => (
<button
key={f}
className={`ab-btn ${edictFilter === f ? 'active' : ''}`}
onClick={() => setEdictFilter(f)}
>
{f === 'active' ? '活跃' : f === 'archived' ? '归档' : '全部'}
</button>
))}
{unArchivedDone.length > 0 && (
<button className="ab-btn" onClick={handleArchiveAll}>📦 </button>
)}
<span className="ab-count">
{activeEdicts.length} · {archivedEdicts.length} · {allEdicts.length}
</span>
<button className="ab-scan" onClick={handleScan}>🧭 </button>
</div>
{/* Status Filter + Search Bar */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 0', flexWrap: 'wrap',
}}>
{STATUS_FILTERS.map((f) => (
<button
key={f.key}
onClick={() => setStatusFilter(f.key)}
style={{
padding: '3px 10px', borderRadius: 6, fontSize: 11,
border: `1px solid ${statusFilter === f.key ? 'var(--acc)' : '#2a3550'}`,
background: statusFilter === f.key ? 'var(--acc)22' : '#161b2e',
color: statusFilter === f.key ? 'var(--acc)' : '#8899aa',
cursor: 'pointer', transition: 'all 0.15s',
display: 'flex', alignItems: 'center', gap: 3,
}}
>
<span>{f.icon}</span>
<span>{f.label}</span>
{(statusCounts[f.key] || 0) > 0 && (
<span style={{
fontSize: 10, background: statusFilter === f.key ? 'var(--acc)' : '#2a3550',
color: statusFilter === f.key ? '#000' : '#8899aa',
borderRadius: 8, padding: '0 4px', minWidth: 16, textAlign: 'center',
}}>
{statusCounts[f.key]}
</span>
)}
</button>
))}
<div style={{ marginLeft: 'auto', position: 'relative' }}>
<input
type="text"
placeholder="搜索任务标题..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
padding: '4px 10px 4px 28px', borderRadius: 6, fontSize: 12,
border: '1px solid #2a3550', background: '#161b2e', color: '#c0ccdd',
width: 200, outline: 'none',
}}
/>
<span style={{
position: 'absolute', left: 8, top: '50%', transform: 'translateY(-50%)',
fontSize: 12, color: '#556', pointerEvents: 'none',
}}>🔍</span>
</div>
</div>
{/* Batch Actions Bar */}
{selectedIds.size > 0 && (
<div style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px',
background: '#1a2540', border: '1px solid var(--acc)', borderRadius: 8,
marginBottom: 10,
}}>
<span style={{ fontSize: 12, color: 'var(--acc)', fontWeight: 600 }}>
{selectedIds.size}
</span>
<button
className="btn btn-g"
style={{ fontSize: 11, padding: '4px 12px' }}
disabled={batchLoading}
onClick={async () => {
setBatchLoading(true);
let count = 0;
for (const id of selectedIds) {
try { await api.taskAction(id, 'archive', ''); count++; } catch { /* skip */ }
}
setSelectedIds(new Set());
setBatchLoading(false);
toast(`📦 已归档 ${count} 道军令`, 'ok');
loadAll();
}}
>
{batchLoading ? '⟳' : '📦 批量归档'}
</button>
<button
className="btn"
style={{ fontSize: 11, padding: '4px 12px', background: '#ff527022', color: '#ff5270', border: '1px solid #ff527044' }}
disabled={batchLoading}
onClick={async () => {
setBatchLoading(true);
let count = 0;
for (const id of selectedIds) {
try { await api.taskAction(id, 'cancel', '批量取消'); count++; } catch { /* skip */ }
}
setSelectedIds(new Set());
setBatchLoading(false);
toast(`🚫 已取消 ${count} 道军令`, 'ok');
loadAll();
}}
>
{batchLoading ? '⟳' : '🚫 批量取消'}
</button>
<button
className="btn btn-g"
style={{ fontSize: 11, padding: '4px 8px', marginLeft: 'auto' }}
onClick={() => setSelectedIds(new Set())}
>
</button>
</div>
)}
{/* Grid */}
<div className="edict-grid">
{edicts.length === 0 ? (
<div className="empty" style={{ gridColumn: '1/-1' }}>
<br />
<small style={{ fontSize: 11, marginTop: 6, display: 'block', color: 'var(--muted)' }}>
</small>
</div>
) : (
edicts.map((t) => (
<div key={t.id} style={{ position: 'relative' }}>
<input
type="checkbox"
checked={selectedIds.has(t.id)}
onChange={() => {
const next = new Set(selectedIds);
if (next.has(t.id)) next.delete(t.id); else next.add(t.id);
setSelectedIds(next);
}}
style={{
position: 'absolute', top: 8, left: 8, zIndex: 2,
width: 16, height: 16, cursor: 'pointer',
accentColor: 'var(--acc)',
}}
/>
<EdictCard task={t} />
</div>
))
)}
</div>
</div>
);
}
@@ -0,0 +1,104 @@
/**
* 全局搜索 — Ctrl+K 快捷键触发
* 搜索范围:军令、将军、战法
*/
import { useState, useEffect } from 'react';
import { useStore } from '../store';
export default function GlobalSearch({ onClose }: { onClose: () => void }) {
const [query, setQuery] = useState('');
const liveStatus = useStore((s) => s.liveStatus);
const officialsData = useStore((s) => s.officialsData);
const setModalTaskId = useStore((s) => s.setModalTaskId);
const tasks = liveStatus?.tasks || [];
const officials = officialsData?.officials || [];
const q = query.trim().toLowerCase();
const matchedTasks = q ? tasks.filter((t) =>
t.id.toLowerCase().includes(q) ||
t.title.toLowerCase().includes(q)
).slice(0, 8) : [];
const matchedAgents = q ? officials.filter((o) =>
o.role.toLowerCase().includes(q) ||
o.id.toLowerCase().includes(q)
).slice(0, 5) : [];
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onClose]);
return (
<div className="confirm-bg open" onClick={onClose}>
<div className="confirm-box" style={{ maxWidth: 560 }} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12 }}>
<span style={{ fontSize: 14 }}>🔍</span>
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索军令、将军、战法..."
style={{
flex: 1, background: 'var(--panel2)', border: '1px solid var(--line)',
borderRadius: 8, padding: '8px 12px', fontSize: 14, color: 'var(--text)', outline: 'none',
}}
/>
<span style={{ fontSize: 10, color: 'var(--muted)' }}>ESC </span>
</div>
{matchedTasks.length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--muted)', marginBottom: 6 }}>📜 </div>
{matchedTasks.map((t) => (
<div
key={t.id}
style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '6px 10px', borderRadius: 6, cursor: 'pointer', border: '1px solid transparent' }}
onClick={() => { setModalTaskId(t.id); onClose(); }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--panel2)'; e.currentTarget.style.borderColor = 'var(--line)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.borderColor = 'transparent'; }}
>
<span style={{ fontSize: 10, color: 'var(--acc)', fontWeight: 600 }}>{t.id}</span>
<span style={{ flex: 1, fontSize: 12 }}>{t.title.substring(0, 40)}</span>
<span style={{ fontSize: 10, color: 'var(--muted)' }}>{t.state}</span>
</div>
))}
</div>
)}
{matchedAgents.length > 0 && (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--muted)', marginBottom: 6 }}> </div>
{matchedAgents.map((o) => (
<div
key={o.id}
style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '6px 10px', borderRadius: 6 }}
>
<span>{o.emoji}</span>
<span style={{ fontSize: 12, fontWeight: 600 }}>{o.role}</span>
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{o.model_short}</span>
</div>
))}
</div>
)}
{q && matchedTasks.length === 0 && matchedAgents.length === 0 && (
<div style={{ fontSize: 12, color: 'var(--muted)', padding: '8px 0', textAlign: 'center' }}>
{query}
</div>
)}
{!q && (
<div style={{ fontSize: 11, color: 'var(--muted)', textAlign: 'center', padding: '8px 0' }}>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,284 @@
import { useState } from 'react';
import { useStore, isEdict, STATE_LABEL } from '../store';
import type { Task, FlowEntry, TaskNode } from '../api';
export default function MemorialPanel() {
const liveStatus = useStore((s) => s.liveStatus);
const [filter, setFilter] = useState('all');
const [detailTask, setDetailTask] = useState<Task | null>(null);
const toast = useStore((s) => s.toast);
const tasks = liveStatus?.tasks || [];
const isTerminal = (t: Task) => {
const st = t.status || t.state || '';
return ['Done', 'Cancelled', 'completed', 'cancelled', 'failed'].includes(st);
};
const matchFilter = (t: Task, f: string) => {
if (f === 'all') return true;
const st = t.status || t.state || '';
if (f === 'Done') return ['Done', 'completed'].includes(st);
if (f === 'Cancelled') return ['Cancelled', 'cancelled'].includes(st);
if (f === 'failed') return st === 'failed';
return st === f;
};
let mems = tasks.filter((t) => isEdict(t) && isTerminal(t));
if (filter !== 'all') mems = mems.filter((t) => matchFilter(t, filter));
const exportMemorial = (t: Task) => {
const fl = t.flow_log || [];
let md = `# 📜 奏折 · ${t.title}\n\n`;
md += `- **任务编号**: ${t.id}\n`;
md += `- **状态**: ${t.status || t.state}\n`;
md += `- **负责部门**: ${t.org}\n`;
if (fl.length) {
const startAt = fl[0].at ? fl[0].at.substring(0, 19).replace('T', ' ') : '未知';
const endAt = fl[fl.length - 1].at ? fl[fl.length - 1].at.substring(0, 19).replace('T', ' ') : '未知';
md += `- **开始时间**: ${startAt}\n`;
md += `- **完成时间**: ${endAt}\n`;
}
md += `\n## 流转记录\n\n`;
for (const f of fl) {
md += `- **${f.from}** → **${f.to}** \n ${f.remark} \n _${(f.at || '').substring(0, 19)}_\n\n`;
}
if (t.output && t.output !== '-') md += `## 产出物\n\n\`${t.output}\`\n`;
navigator.clipboard.writeText(md).then(
() => toast('✅ 奏折已复制为 Markdown', 'ok'),
() => toast('复制失败', 'err')
);
};
return (
<div>
{/* Filter */}
<div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}>
<span style={{ fontSize: 12, color: 'var(--muted)' }}></span>
{[
{ key: 'all', label: '全部' },
{ key: 'Done', label: '✅ 已完成' },
{ key: 'Cancelled', label: '🚫 已取消' },
{ key: 'failed', label: '❌ 已失败' },
].map((f) => (
<span
key={f.key}
className={`sess-filter${filter === f.key ? ' active' : ''}`}
onClick={() => setFilter(f.key)}
>
{f.label}
</span>
))}
</div>
{/* List */}
<div className="mem-list">
{!mems.length ? (
<div className="mem-empty"> </div>
) : (
mems.map((t) => {
const fl = t.flow_log || [];
const depts = [...new Set(fl.map((f) => f.from).concat(fl.map((f) => f.to)).filter((x) => x && x !== '皇上' && x !== '主公'))];
const firstAt = fl.length ? (fl[0].at || '').substring(0, 16).replace('T', ' ') : '';
const lastAt = fl.length ? (fl[fl.length - 1].at || '').substring(0, 16).replace('T', ' ') : '';
const stVal = t.status || t.state || '';
const stIcon = ['Done', 'completed'].includes(stVal) ? '✅' : ['Cancelled', 'cancelled'].includes(stVal) ? '🚫' : '❌';
return (
<div className="mem-card" key={t.id} onClick={() => setDetailTask(t)}>
<div className="mem-icon">📜</div>
<div className="mem-info">
<div className="mem-title">
{stIcon} {t.title || t.id}
</div>
<div className="mem-sub">
{t.id} · {t.org || ''} · {fl.length}
</div>
<div className="mem-tags">
{depts.slice(0, 5).map((d) => (
<span className="mem-tag" key={d}>{d}</span>
))}
</div>
</div>
<div className="mem-right">
<span className="mem-date">{firstAt}</span>
{lastAt !== firstAt && <span className="mem-date">{lastAt}</span>}
</div>
</div>
);
})
)}
</div>
{/* Detail Modal */}
{detailTask && (
<MemorialDetailModal task={detailTask} onClose={() => setDetailTask(null)} onExport={exportMemorial} />
)}
</div>
);
}
function MemorialDetailModal({
task: t,
onClose,
onExport,
}: {
task: Task;
onClose: () => void;
onExport: (t: Task) => void;
}) {
const fl = t.flow_log || [];
const st = t.status || t.state || 'Unknown';
const stIcon = ['Done', 'completed'].includes(st) ? '✅' : ['Cancelled', 'cancelled'].includes(st) ? '🚫' : st === 'failed' ? '❌' : '🔄';
const depts = [...new Set(fl.map((f) => f.from).concat(fl.map((f) => f.to)).filter((x) => x && x !== '皇上' && x !== '主公'))];
// Reconstruct phases — support both edict and moziplus terminology
const originLog: FlowEntry[] = [];
const planLog: FlowEntry[] = [];
const reviewLog: FlowEntry[] = [];
const execLog: FlowEntry[] = [];
const resultLog: FlowEntry[] = [];
for (const f of fl) {
if (f.from === '皇上' || f.from === '主公') originLog.push(f);
else if (f.to === '中书省' || f.from === '中书省' || f.to === '庞统' || f.from === '庞统') planLog.push(f);
else if (f.to === '门下省' || f.from === '门下省' || f.to === '司马懿' || f.from === '司马懿') reviewLog.push(f);
else if (f.remark && (f.remark.includes('完成') || f.remark.includes('回奏') || f.remark.includes('交付'))) resultLog.push(f);
else execLog.push(f);
}
const renderPhase = (title: string, icon: string, items: FlowEntry[]) => {
if (!items.length) return null;
return (
<div style={{ marginBottom: 18 }}>
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 10 }}>
{icon} {title}
</div>
<div className="md-timeline">
{items.map((f, i) => {
const dotCls = f.remark?.includes('✅') ? 'green' : f.remark?.includes('驳') ? 'red' : '';
return (
<div className="md-tl-item" key={i}>
<div className={`md-tl-dot ${dotCls}`} />
<div style={{ display: 'flex', gap: 6, alignItems: 'baseline' }}>
<span className="md-tl-from">{f.from}</span>
<span className="md-tl-to"> {f.to}</span>
</div>
<div className="md-tl-remark">{f.remark}</div>
<div className="md-tl-time">{(f.at || '').substring(0, 19).replace('T', ' ')}</div>
</div>
);
})}
</div>
</div>
);
};
// Extract nodes from task.nodes
const nodes = t.nodes || [];
// Extract challenges from sourceMeta.challenges
const challenges = (t.sourceMeta?.challenges || []) as Array<{ verdict?: string; feedback?: string }>;
return (
<div className="modal-bg open" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose}></button>
<div className="modal-body">
<div style={{ fontSize: 11, color: 'var(--acc)', fontWeight: 700, letterSpacing: '.04em', marginBottom: 4 }}>{t.id}</div>
<div style={{ fontSize: 20, fontWeight: 800, marginBottom: 6 }}>{stIcon} {t.title || t.id}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 18, flexWrap: 'wrap' }}>
<span className={`tag st-${st}`}>{STATE_LABEL[st] || st}</span>
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{t.org}</span>
<span style={{ fontSize: 11, color: 'var(--muted)' }}> {fl.length} </span>
{depts.map((d) => (
<span className="mem-tag" key={d}>{d}</span>
))}
</div>
{t.now && (
<div style={{ background: 'var(--panel2)', border: '1px solid var(--line)', borderRadius: 8, padding: '10px 14px', marginBottom: 18, fontSize: 12, color: 'var(--muted)' }}>
{t.now}
</div>
)}
{renderPhase('主公下令', '👑', originLog)}
{renderPhase('庞统拆解', '📋', planLog)}
{renderPhase('司马懿审核', '🔍', reviewLog)}
{renderPhase('将军执行', '⚔️', execLog)}
{renderPhase('汇总交付', '📨', resultLog)}
{/* 各营回报 — 节点产出预览 */}
{(() => {
if (nodes.length === 0) return null;
return (
<div style={{ marginTop: 18, paddingTop: 14, borderTop: '1px solid var(--line)' }}>
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 10 }}>🏕 </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{nodes.map((n: TaskNode, i: number) => {
const isDone = ['completed', 'Done', 'done'].includes(n.status);
const isFailed = ['failed', 'Failed'].includes(n.status);
const icon = isDone ? '✅' : isFailed ? '❌' : '🔄';
return (
<div key={n.node_id || i} style={{ background: 'var(--panel2)', border: '1px solid var(--line)', borderRadius: 8, padding: '10px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span>{icon}</span>
<span style={{ fontSize: 12, fontWeight: 600 }}>{n.name || n.node_id}</span>
{n.agent_id && <span style={{ fontSize: 11, color: 'var(--muted)' }}>· {n.agent_id}</span>}
<span className="mem-tag" style={{ marginLeft: 'auto' }}>{n.status}</span>
</div>
{isDone && n.output_summary && (
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 4, lineHeight: 1.5 }}>
{n.output_summary.length > 200 ? n.output_summary.substring(0, 200) + '…' : n.output_summary}
</div>
)}
</div>
);
})}
</div>
</div>
);
})()}
{/* 磋商实录 — 挑战记录 */}
{(() => {
if (challenges.length === 0) return null;
return (
<div style={{ marginTop: 18, paddingTop: 14, borderTop: '1px solid var(--line)' }}>
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 10 }}> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{challenges.map((c: { verdict?: string; feedback?: string }, i: number) => {
const verdictIcon = c.verdict === 'pass' ? '✅' : c.verdict === 'fail' ? '❌' : '🔄';
return (
<div key={i} style={{ background: 'var(--panel2)', border: '1px solid var(--line)', borderRadius: 8, padding: '10px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span>{verdictIcon}</span>
<span style={{ fontSize: 12, fontWeight: 600 }}> {i + 1} </span>
<span className="mem-tag">{c.verdict}</span>
</div>
{c.feedback && (
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 4, lineHeight: 1.5 }}>
{c.feedback.length > 200 ? c.feedback.substring(0, 200) + '…' : c.feedback}
</div>
)}
</div>
);
})}
</div>
</div>
);
})()}
{t.output && t.output !== '-' && (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--line)' }}>
<div style={{ fontSize: 11, fontWeight: 600, marginBottom: 4 }}>📦 </div>
<code style={{ fontSize: 11, wordBreak: 'break-all' }}>{t.output}</code>
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
<button className="btn btn-g" onClick={() => onExport(t)} style={{ fontSize: 12, padding: '6px 16px' }}>
📋
</button>
</div>
</div>
</div>
</div>
);
}
+196
View File
@@ -0,0 +1,196 @@
import { useEffect, useState } from 'react';
import { useStore } from '../store';
import { api } from '../api';
const FALLBACK_MODELS = [
{ id: 'anthropic/claude-sonnet-4-6', l: 'Claude Sonnet 4.6', p: 'Anthropic' },
{ id: 'anthropic/claude-opus-4-5', l: 'Claude Opus 4.5', p: 'Anthropic' },
{ id: 'anthropic/claude-haiku-3-5', l: 'Claude Haiku 3.5', p: 'Anthropic' },
{ id: 'openai/gpt-4o', l: 'GPT-4o', p: 'OpenAI' },
{ id: 'openai/gpt-4o-mini', l: 'GPT-4o Mini', p: 'OpenAI' },
{ id: 'google/gemini-2.5-pro', l: 'Gemini 2.5 Pro', p: 'Google' },
{ id: 'copilot/claude-sonnet-4', l: 'Claude Sonnet 4', p: 'Copilot' },
{ id: 'copilot/claude-opus-4.5', l: 'Claude Opus 4.5', p: 'Copilot' },
{ id: 'copilot/gpt-4o', l: 'GPT-4o', p: 'Copilot' },
{ id: 'copilot/gemini-2.5-pro', l: 'Gemini 2.5 Pro', p: 'Copilot' },
];
const CHANNELS = [
{ id: 'feishu', label: '飞书 Feishu' },
{ id: 'telegram', label: 'Telegram' },
{ id: 'wecom', label: '企业微信 WeCom' },
{ id: 'discord', label: 'Discord' },
{ id: 'slack', label: 'Slack' },
{ id: 'signal', label: 'Signal' },
{ id: 'tui', label: 'TUI (终端)' },
];
export default function ModelConfig() {
const agentConfig = useStore((s) => s.agentConfig);
const changeLog = useStore((s) => s.changeLog);
const loadAgentConfig = useStore((s) => s.loadAgentConfig);
const toast = useStore((s) => s.toast);
const [selMap, setSelMap] = useState<Record<string, string>>({});
const [statusMap, setStatusMap] = useState<Record<string, { cls: string; text: string }>>({});
const [channelSel, setChannelSel] = useState('feishu');
const [channelStatus, setChannelStatus] = useState('');
useEffect(() => {
loadAgentConfig();
}, [loadAgentConfig]);
useEffect(() => {
if (agentConfig?.agents) {
const m: Record<string, string> = {};
agentConfig.agents.forEach((ag) => {
m[ag.id] = ag.model;
});
setSelMap(m);
}
if (agentConfig?.dispatchChannel) {
setChannelSel(agentConfig.dispatchChannel);
}
}, [agentConfig]);
if (!agentConfig?.agents) {
return <div className="empty" style={{ gridColumn: '1/-1' }}> </div>;
}
const models = agentConfig.knownModels?.length
? agentConfig.knownModels.map((m) => ({ id: m.id, l: m.label, p: m.provider }))
: FALLBACK_MODELS;
const handleSelect = (agentId: string, val: string) => {
setSelMap((p) => ({ ...p, [agentId]: val }));
};
const resetMC = (agentId: string) => {
const ag = agentConfig.agents.find((a) => a.id === agentId);
if (ag) setSelMap((p) => ({ ...p, [agentId]: ag.model }));
};
const applyModel = async (agentId: string) => {
const model = selMap[agentId];
if (!model) return;
setStatusMap((p) => ({ ...p, [agentId]: { cls: 'pending', text: '⟳ 提交中…' } }));
try {
const r = await api.setModel(agentId, model);
if (r.ok) {
setStatusMap((p) => ({ ...p, [agentId]: { cls: 'ok', text: '✅ 已提交,Gateway 重启中(约5秒)' } }));
toast(agentId + ' 模型已更改', 'ok');
setTimeout(() => loadAgentConfig(), 5500);
} else {
setStatusMap((p) => ({ ...p, [agentId]: { cls: 'err', text: '❌ ' + (r.error || '错误') } }));
}
} catch {
setStatusMap((p) => ({ ...p, [agentId]: { cls: 'err', text: '❌ 无法连接服务器' } }));
}
};
return (
<div>
<div className="model-grid">
{agentConfig.agents.map((ag) => {
const sel = selMap[ag.id] || ag.model;
const changed = sel !== ag.model;
const st = statusMap[ag.id];
return (
<div className="mc-card" key={ag.id}>
<div className="mc-top">
<span className="mc-emoji">{ag.emoji || '🏛️'}</span>
<div>
<div className="mc-name">
{ag.label}{' '}
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{ag.id}</span>
</div>
<div className="mc-role">{ag.role}</div>
</div>
</div>
<div className="mc-cur">
: <b>{ag.model}</b>
</div>
<select className="msel" value={sel} onChange={(e) => handleSelect(ag.id, e.target.value)}>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.l} ({m.p})
</option>
))}
</select>
<div className="mc-btns">
<button className="btn btn-p" disabled={!changed} onClick={() => applyModel(ag.id)}>
</button>
<button className="btn btn-g" onClick={() => resetMC(ag.id)}>
</button>
</div>
{st && <div className={`mc-st ${st.cls}`}>{st.text}</div>}
</div>
);
})}
</div>
{/* Dispatch Channel 配置 */}
<div style={{ marginTop: 24, marginBottom: 8 }}>
<div className="sec-title"></div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 0' }}>
<select className="msel" value={channelSel} onChange={(e) => setChannelSel(e.target.value)}
style={{ maxWidth: 220 }}>
{CHANNELS.map((ch) => (
<option key={ch.id} value={ch.id}>{ch.label}</option>
))}
</select>
<button className="btn btn-p" disabled={channelSel === (agentConfig?.dispatchChannel || 'feishu')}
onClick={async () => {
try {
const r = await api.setDispatchChannel(channelSel);
if (r.ok) { setChannelStatus('✅ 已保存'); toast('派发渠道已切换', 'ok'); loadAgentConfig(); }
else setChannelStatus('❌ ' + (r.error || '失败'));
} catch { setChannelStatus('❌ 无法连接'); }
setTimeout(() => setChannelStatus(''), 3000);
}}></button>
{channelStatus && <span style={{ fontSize: 12, color: channelStatus.startsWith('✅') ? 'var(--success)' : 'var(--danger)' }}>{channelStatus}</span>}
</div>
<div style={{ fontSize: 11, color: 'var(--muted)' }}>使 OpenClaw openclaw.json channel</div>
</div>
{/* Change Log */}
<div style={{ marginTop: 24 }}>
<div className="sec-title"></div>
<div className="cl-list">
{!changeLog?.length ? (
<div style={{ fontSize: 12, color: 'var(--muted)', padding: '8px 0' }}></div>
) : (
[...changeLog]
.reverse()
.slice(0, 15)
.map((e, i) => (
<div className="cl-row" key={i}>
<span className="cl-t">{(e.at || '').substring(0, 16).replace('T', ' ')}</span>
<span className="cl-a">{e.agentId}</span>
<span className="cl-c">
<b>{e.oldModel}</b> <b>{e.newModel}</b>
{e.rolledBack && (
<span
style={{
color: 'var(--danger)',
fontSize: 10,
border: '1px solid #ff527044',
padding: '1px 5px',
borderRadius: 3,
marginLeft: 4,
}}
>
</span>
)}
</span>
</div>
))
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,503 @@
import { useEffect, useState } from 'react';
import { useStore, DEPTS, isEdict, stateLabel } from '../store';
import { api, type OfficialInfo } from '../api';
// ── Agent name mapping (P1-08) ──
const AGENT_NAMES: Record<string, string> = {
'zhangfei-dev': '张飞 翼德',
'guanyu-dev': '关羽 云长',
'zhaoyun-data': '赵云 子龙',
'jiangwei-infra': '姜维 伯约',
'simayi-challenger': '司马懿 仲达',
'pangtong-fujunshi': '庞统 士元',
};
const AGENT_EMOJI: Record<string, string> = {
'zhangfei-dev': '⚔️',
'guanyu-dev': '🗡️',
'zhaoyun-data': '🏹',
'jiangwei-infra': '🛡️',
'simayi-challenger': '🦅',
'pangtong-fujunshi': '🐦',
};
const AGENT_DEPT: Record<string, string> = {
'zhangfei-dev': '张飞',
'guanyu-dev': '关羽',
'zhaoyun-data': '赵云',
'jiangwei-infra': '姜维',
'simayi-challenger': '司马懿',
'pangtong-fujunshi': '庞统',
};
// ── Stats card definitions (P1-09) ──
interface StatCardDef {
icon: string;
label: string;
color: string;
bg: string;
border: string;
states: string[];
}
const STAT_CARDS: StatCardDef[] = [
{ icon: '📋', label: '总任务', color: '#dde4f8', bg: '#0f1219', border: '#1c223688', states: ['__all__'] },
{ icon: '⚔️', label: '执行中', color: '#6a9eff', bg: '#0a1428', border: '#6a9eff44', states: ['executing', 'Doing'] },
{ icon: '✅', label: '已完成', color: '#2ecc8a', bg: '#0a2018', border: '#2ecc8a44', states: ['completed', 'Done'] },
{ icon: '⏳', label: '待审批', color: '#f5c842', bg: '#201a08', border: '#f5c84244', states: ['challenging', 'Review'] },
{ icon: '❌', label: '失败', color: '#ff5270', bg: '#200a10', border: '#ff527044', states: ['failed'] },
{ icon: '⏸️', label: '暂停', color: '#5a6b92', bg: '#141824', border: '#5a6b9244', states: ['paused'] },
];
export default function MonitorPanel() {
const liveStatus = useStore((s) => s.liveStatus);
const agentsStatusData = useStore((s) => s.agentsStatusData);
const officialsData = useStore((s) => s.officialsData);
const loadAgentsStatus = useStore((s) => s.loadAgentsStatus);
const setModalTaskId = useStore((s) => s.setModalTaskId);
const setActiveTab = useStore((s) => s.setActiveTab);
const toast = useStore((s) => s.toast);
// P3-11: engine scan state
const [scanLoading, setScanLoading] = useState(false);
const [scanResult, setScanResult] = useState<{ zombies: number; actions: { taskId: string; action: string; stalledSec?: number }[] } | null>(null);
useEffect(() => {
loadAgentsStatus();
}, [loadAgentsStatus]);
const tasks = liveStatus?.tasks || [];
const allEdicts = tasks.filter((t) => isEdict(t));
const activeTasks = allEdicts.filter((t) => t.state !== 'completed' && t.state !== 'Done' && t.state !== 'cancelled');
// ── Compute stats ──
function countStates(states: string[]): number {
if (states.includes('__all__')) return allEdicts.length;
return allEdicts.filter((t) => states.includes(t.state)).length;
}
// ── Build task lookup per dept ──
const deptActiveTasks: Record<string, typeof activeTasks> = {};
for (const t of activeTasks) {
const dept = t.org || '';
if (!deptActiveTasks[dept]) deptActiveTasks[dept] = [];
deptActiveTasks[dept].push(t);
}
// Build official map
const offMap: Record<string, OfficialInfo> = {};
if (officialsData?.officials) {
officialsData.officials.forEach((o) => { offMap[o.id] = o; });
}
// Agent wake
const handleWake = async (agentId: string) => {
try {
const r = await api.agentWake(agentId);
toast(r.message || '唤醒指令已发出');
setTimeout(() => loadAgentsStatus(), 30000);
} catch { toast('唤醒失败', 'err'); }
};
const handleWakeAll = async () => {
if (!agentsStatusData) return;
const toWake = agentsStatusData.agents.filter(
(a) => a.id !== 'main' && a.status !== 'running' && a.status !== 'unconfigured'
);
if (!toWake.length) { toast('所有 Agent 均已在线'); return; }
toast(`正在唤醒 ${toWake.length} 个 Agent...`);
for (const a of toWake) {
try { await api.agentWake(a.id); } catch { /* ignore */ }
}
toast(`${toWake.length} 个唤醒指令已发出,30秒后刷新状态`);
setTimeout(() => loadAgentsStatus(), 30000);
};
// Click stat card → jump to tasks tab
const handleStatClick = (card: StatCardDef) => {
setActiveTab('tasks');
};
// P3-11: engine scan handler
const handleSchedulerScan = async () => {
setScanLoading(true);
setScanResult(null);
try {
const r = await api.schedulerScan();
const zombies = r.actions?.length || 0;
setScanResult({ zombies, actions: (r.actions || []).map((a) => ({ taskId: a.taskId, action: a.action, stalledSec: a.stalledSec })) });
toast(r.message || (zombies > 0 ? `发现 ${zombies} 个僵尸任务` : '一切正常'));
} catch {
toast('巡营扫描失败', 'err');
} finally {
setScanLoading(false);
}
};
// P3-12: compute context pressure per agent from officialsData
const contextPressureMap: Record<string, number> = {};
if (officialsData?.officials) {
for (const o of officialsData.officials) {
const used = o.tokens_in + o.tokens_out;
const pct = Math.min(100, Math.round((used / 200000) * 100));
contextPressureMap[o.id] = pct;
}
}
const avgPressure = Object.keys(contextPressureMap).length > 0
? Math.round(Object.values(contextPressureMap).reduce((a, b) => a + b, 0) / Object.keys(contextPressureMap).length)
: 0;
// Agent Status Panel data
const asData = agentsStatusData;
const filtered = asData?.agents?.filter((a) => a.id !== 'main') || [];
const running = filtered.filter((a) => a.status === 'running').length;
const idle = filtered.filter((a) => a.status === 'idle').length;
const offline = filtered.filter((a) => a.status === 'offline').length;
const unconf = filtered.filter((a) => a.status === 'unconfigured').length;
const gw = asData?.gateway;
const gwCls = gw?.probe ? 'ok' : gw?.alive ? 'warn' : 'err';
return (
<div>
{/* ══ P1-09: 今日军报统计卡片 ══ */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
gap: 10,
marginBottom: 16,
}}>
{STAT_CARDS.map((card) => {
const value = countStates(card.states);
return (
<div
key={card.label}
onClick={() => handleStatClick(card)}
style={{
background: card.bg,
border: `1px solid ${card.border}`,
borderRadius: 12,
padding: '14px 16px',
cursor: 'pointer',
transition: 'transform .12s, border-color .15s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLDivElement).style.transform = 'translateY(-2px)';
(e.currentTarget as HTMLDivElement).style.borderColor = card.color;
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLDivElement).style.transform = 'translateY(0)';
(e.currentTarget as HTMLDivElement).style.borderColor = card.border;
}}
>
<div style={{ fontSize: 11, color: '#5a6b92', marginBottom: 6 }}>{card.icon} {card.label}</div>
<div style={{ fontSize: 28, fontWeight: 800, color: card.color, lineHeight: 1 }}>{value}</div>
</div>
);
})}
{/* P3-12: 粮草告急 context pressure card */}
{(() => {
const pColor = avgPressure > 80 ? '#ff5270' : avgPressure > 50 ? '#f5c842' : '#2ecc8a';
const pBg = avgPressure > 80 ? '#200a10' : avgPressure > 50 ? '#201a08' : '#0a2018';
const pBorder = avgPressure > 80 ? '#ff527044' : avgPressure > 50 ? '#f5c84244' : '#2ecc8a44';
return (
<div
style={{
background: pBg,
border: `1px solid ${pBorder}`,
borderRadius: 12,
padding: '14px 16px',
transition: 'transform .12s, border-color .15s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLDivElement).style.transform = 'translateY(-2px)';
(e.currentTarget as HTMLDivElement).style.borderColor = pColor;
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLDivElement).style.transform = 'translateY(0)';
(e.currentTarget as HTMLDivElement).style.borderColor = pBorder;
}}
>
<div style={{ fontSize: 11, color: '#5a6b92', marginBottom: 6 }}>🌾 </div>
<div style={{ fontSize: 28, fontWeight: 800, color: pColor, lineHeight: 1 }}>{avgPressure}%</div>
<div style={{ marginTop: 8, height: 6, background: '#0e1320', borderRadius: 3, overflow: 'hidden' }}>
<div style={{
height: '100%',
width: `${avgPressure}%`,
background: pColor,
borderRadius: 3,
transition: 'width .3s, background .3s',
...(avgPressure > 80 ? { animation: 'pulse 1s infinite' } : {}),
}} />
</div>
</div>
);
})()}
</div>
{/* ══ P1-08: Agent 在线状态面板(增强) ══ */}
{asData && asData.ok && (
<div className="as-panel">
<div className="as-header">
<span className="as-title">🔌 Agent 线</span>
<span className={`as-gw ${gwCls}`}>Gateway: {gw?.status || '未知'}</span>
<button className="btn-refresh" onClick={() => loadAgentsStatus()} style={{ marginLeft: 8 }}>
🔄
</button>
{(offline + unconf > 0) && (
<button className="btn-refresh" onClick={handleWakeAll} style={{ marginLeft: 4, borderColor: 'var(--warn)', color: 'var(--warn)' }}>
</button>
)}
</div>
{/* Agent cards grid */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
gap: 10,
}}>
{filtered.map((a) => {
const isRunning = a.status === 'running';
const deptLabel = AGENT_DEPT[a.id] || '';
const myTasks = deptActiveTasks[deptLabel] || [];
const currentTask = myTasks[0]; // first active task
const emoji = AGENT_EMOJI[a.id] || '🤖';
const name = AGENT_NAMES[a.id] || a.label;
const canWake = a.status !== 'running' && a.status !== 'unconfigured' && gw?.alive;
return (
<div
key={a.id}
style={{
background: 'var(--panel2)',
border: `1px solid ${isRunning ? '#6a9eff66' : '#1c2236'}`,
borderRadius: 12,
padding: 14,
position: 'relative',
transition: 'border-color .15s, background .15s',
...(isRunning ? { animation: 'pulse 2s ease-in-out infinite' } : {}),
}}
>
{/* Status dot */}
<div style={{
position: 'absolute',
top: 8,
right: 8,
width: 8,
height: 8,
borderRadius: '50%',
background: isRunning ? '#2ecc8a' : a.status === 'idle' ? '#4a5568' : '#ff5270',
boxShadow: isRunning ? '0 0 6px #2ecc8a88' : undefined,
animation: isRunning || a.status === 'offline' ? 'pulse 1.5s infinite' : undefined,
}} />
<div style={{ textAlign: 'center', marginBottom: 6 }}>
<div style={{ fontSize: 26 }}>{emoji}</div>
<div style={{ fontSize: 13, fontWeight: 800, marginTop: 4 }}>{name}</div>
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>
{isRunning ? '🔵 忙碌' : a.status === 'idle' ? '🟢 空闲' : a.status === 'offline' ? '🔴 离线' : '⚪ 未配置'}
</div>
</div>
{/* Current task info */}
{isRunning && currentTask ? (
<div style={{
marginTop: 8,
padding: '6px 8px',
background: '#0a1228',
borderRadius: 6,
borderLeft: '2px solid var(--acc)',
cursor: 'pointer',
}} onClick={() => setModalTaskId(currentTask.id)}>
<div style={{ fontSize: 10, color: 'var(--acc)', fontWeight: 700, marginBottom: 2 }}>
{currentTask.id}
</div>
<div style={{
fontSize: 11,
color: 'var(--text)',
fontWeight: 600,
lineHeight: 1.3,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}>
{currentTask.title || '(无标题)'}
</div>
{currentTask.now && currentTask.now !== '-' && (
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>
📍 {currentTask.now.substring(0, 30)}
</div>
)}
</div>
) : isRunning ? (
<div style={{ fontSize: 10, color: 'var(--muted)', textAlign: 'center', marginTop: 6 }}></div>
) : (
<div style={{ fontSize: 10, color: 'var(--muted)', textAlign: 'center', marginTop: 6 }}></div>
)}
{/* Wake button */}
{canWake && (
<div style={{ textAlign: 'center', marginTop: 6 }}>
<button className="as-wake-btn" onClick={(e) => { e.stopPropagation(); handleWake(a.id); }}>
</button>
</div>
)}
{/* P3-12: per-agent context pressure bar */}
{(() => {
const pressure = contextPressureMap[a.id];
if (pressure === undefined) return null;
const barColor = pressure > 80 ? '#ff5270' : pressure > 50 ? '#f5c842' : '#2ecc8a';
return (
<div style={{ marginTop: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 9, color: 'var(--muted)', marginBottom: 2 }}>
<span>🌾 </span>
<span style={{ color: barColor }}>{pressure}%</span>
</div>
<div style={{ height: 4, background: '#0e1320', borderRadius: 2, overflow: 'hidden' }}>
<div style={{
height: '100%',
width: `${pressure}%`,
background: barColor,
borderRadius: 2,
transition: 'width .3s',
...(pressure > 80 ? { animation: 'pulse 1s infinite' } : {}),
}} />
</div>
</div>
);
})()}
</div>
);
})}
</div>
<div className="as-summary">
<span><span className="as-dot running" style={{ position: 'static', width: 8, height: 8 }} /> {running} </span>
<span><span className="as-dot idle" style={{ position: 'static', width: 8, height: 8 }} /> {idle} </span>
{offline > 0 && <span><span className="as-dot offline" style={{ position: 'static', width: 8, height: 8 }} /> {offline} 线</span>}
{unconf > 0 && <span><span className="as-dot unconfigured" style={{ position: 'static', width: 8, height: 8 }} /> {unconf} </span>}
<span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--muted)' }}>
{(asData.checkedAt || '').substring(11, 19)}
</span>
</div>
</div>
)}
{/* ══ Duty Grid (existing) ══ */}
<div className="duty-grid">
{DEPTS.map((d) => {
const myTasks = activeTasks.filter((t) => t.org === d.label);
const isActive = myTasks.some((t) => t.state === 'Doing' || t.state === 'executing');
const isBlocked = myTasks.some((t) => t.state === 'Blocked');
const off = offMap[d.id];
const hb = off?.heartbeat || { status: 'idle', label: '⚪' };
const dotCls = isBlocked ? 'blocked' : isActive ? 'busy' : hb.status === 'active' ? 'active' : 'idle';
const statusText = isBlocked ? '⚠️ 阻塞' : isActive ? '⚙️ 执行中' : hb.status === 'active' ? '🟢 活跃' : '⚪ 候命';
const cardCls = isBlocked ? 'blocked-card' : isActive ? 'active-card' : '';
return (
<div key={d.id} className={`duty-card ${cardCls}`}>
<div className="dc-hdr">
<span className="dc-emoji">{d.emoji}</span>
<div className="dc-info">
<div className="dc-name">{d.label}</div>
<div className="dc-role">{d.role} · {d.rank}</div>
</div>
<div className="dc-status">
<span className={`dc-dot ${dotCls}`} />
<span>{statusText}</span>
</div>
</div>
<div className="dc-body">
{myTasks.length > 0 ? (
myTasks.map((t) => (
<div key={t.id} className="dc-task" onClick={() => setModalTaskId(t.id)}>
<div className="dc-task-id">{t.id}</div>
<div className="dc-task-title">{t.title || '(无标题)'}</div>
{t.now && t.now !== '-' && (
<div className="dc-task-now">{t.now.substring(0, 70)}</div>
)}
<div className="dc-task-meta">
<span className={`tag st-${t.state}`}>{stateLabel(t)}</span>
{t.block && t.block !== '无' && (
<span className="tag" style={{ borderColor: '#ff527044', color: 'var(--danger)' }}>🚫{t.block}</span>
)}
</div>
</div>
))
) : (
<div className="dc-idle">
<span style={{ fontSize: 20 }}>🪭</span>
<span></span>
</div>
)}
</div>
<div className="dc-footer">
<span className="dc-model">🤖 {off?.model_short || '待配置'}</span>
{off?.last_active && <span className="dc-la"> {off.last_active}</span>}
</div>
</div>
);
})}
</div>
{/* ══ P3-11: 引擎巡营 / 操作区 ══ */}
<div style={{
marginTop: 16,
padding: 16,
background: 'var(--panel2)',
border: '1px solid var(--line)',
borderRadius: 12,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: scanResult ? 12 : 0 }}>
<button
className="btn btn-g"
onClick={handleSchedulerScan}
disabled={scanLoading}
style={{ minWidth: 120 }}
>
{scanLoading ? '⏳ 巡营中...' : '🔍 引擎巡营'}
</button>
{scanResult && (
<span style={{ fontSize: 12, color: scanResult.zombies > 0 ? '#ff5270' : '#2ecc8a' }}>
{scanResult.zombies > 0
? `⚠️ 发现 ${scanResult.zombies} 个僵尸任务`
: '✅ 一切正常'}
</span>
)}
</div>
{scanResult && scanResult.zombies > 0 && (
<div style={{
display: 'flex',
flexDirection: 'column',
gap: 6,
}}>
{scanResult.actions.map((act, i) => (
<div key={i} style={{
fontSize: 11,
color: 'var(--text)',
padding: '4px 8px',
background: '#0e1320',
borderRadius: 6,
borderLeft: '2px solid #ff5270',
}}>
<span style={{ color: '#ff5270', fontWeight: 700 }}>{act.taskId}</span>
<span style={{ color: 'var(--muted)', marginLeft: 8 }}>{act.action}</span>
{act.stalledSec != null && (
<span style={{ color: '#f5c842', marginLeft: 8 }}> {Math.round(act.stalledSec / 60)} </span>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,418 @@
import { useEffect, useState, useRef } from 'react';
import { useStore } from '../store';
import { api } from '../api';
import type { SubConfig, MorningNewsItem } from '../api';
const CAT_META: Record<string, { icon: string; color: string; desc: string }> = {
'政治': { icon: '🏛️', color: '#6a9eff', desc: '全球政治动态' },
'军事': { icon: '⚔️', color: '#ff5270', desc: '军事与冲突' },
'经济': { icon: '💹', color: '#2ecc8a', desc: '经济与市场' },
'AI大模型': { icon: '🤖', color: '#a07aff', desc: 'AI与大模型进展' },
};
const DEFAULT_CATS = ['政治', '军事', '经济', 'AI大模型'];
export default function MorningPanel() {
const morningBrief = useStore((s) => s.morningBrief);
const subConfig = useStore((s) => s.subConfig);
const loadMorning = useStore((s) => s.loadMorning);
const loadSubConfig = useStore((s) => s.loadSubConfig);
const toast = useStore((s) => s.toast);
const [showConfig, setShowConfig] = useState(false);
const [localConfig, setLocalConfig] = useState<SubConfig | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [refreshLabel, setRefreshLabel] = useState('⟳ 立即采集');
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
loadMorning();
}, [loadMorning]);
useEffect(() => {
if (subConfig) setLocalConfig(JSON.parse(JSON.stringify(subConfig)));
}, [subConfig]);
useEffect(() => {
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, []);
const refreshNews = async () => {
setRefreshing(true);
setRefreshLabel('⟳ 采集中…');
let lastDate: string | null = null;
try {
lastDate = morningBrief?.generated_at || null;
} catch { /* */ }
try {
await api.refreshMorning();
toast('采集已触发,自动检测更新中…', 'ok');
let count = 0;
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = setInterval(async () => {
count++;
if (count > 24) {
clearInterval(pollRef.current!);
pollRef.current = null;
setRefreshing(false);
setRefreshLabel('⟳ 立即采集');
toast('采集超时,请重试', 'err');
return;
}
try {
const fresh = await api.morningBrief();
if (fresh.generated_at && fresh.generated_at !== lastDate) {
clearInterval(pollRef.current!);
pollRef.current = null;
setRefreshing(false);
setRefreshLabel('⟳ 立即采集');
loadMorning();
toast('✅ 天下要闻已更新', 'ok');
} else {
setRefreshLabel(`⟳ 采集中… (${count * 5}s)`);
}
} catch { /* */ }
}, 5000);
} catch {
toast('触发失败', 'err');
setRefreshing(false);
setRefreshLabel('⟳ 立即采集');
}
};
// Config helpers
const toggleCat = (name: string) => {
if (!localConfig) return;
const cats = [...(localConfig.categories || [])];
const existing = cats.find((c) => c.name === name);
if (existing) existing.enabled = !existing.enabled;
else cats.push({ name, enabled: true });
setLocalConfig({ ...localConfig, categories: cats });
};
const addKeyword = (kw: string) => {
if (!localConfig || !kw) return;
const kws = [...(localConfig.keywords || [])];
if (!kws.includes(kw)) kws.push(kw);
setLocalConfig({ ...localConfig, keywords: kws });
};
const removeKeyword = (i: number) => {
if (!localConfig) return;
const kws = [...(localConfig.keywords || [])];
kws.splice(i, 1);
setLocalConfig({ ...localConfig, keywords: kws });
};
const addFeed = (name: string, url: string, category: string) => {
if (!localConfig || !name || !url) {
toast('请填写源名称和URL', 'err');
return;
}
const feeds = [...(localConfig.custom_feeds || [])];
feeds.push({ name, url, category });
setLocalConfig({ ...localConfig, custom_feeds: feeds });
};
const removeFeed = (i: number) => {
if (!localConfig) return;
const feeds = [...(localConfig.custom_feeds || [])];
feeds.splice(i, 1);
setLocalConfig({ ...localConfig, custom_feeds: feeds });
};
const saveConfig = async () => {
if (!localConfig) return;
try {
const r = await api.saveMorningConfig(localConfig);
if (r.ok) {
toast('订阅配置已保存', 'ok');
loadSubConfig();
} else {
toast(r.error || '保存失败', 'err');
}
} catch {
toast('服务器连接失败', 'err');
}
};
const enabledSet = localConfig
? new Set((localConfig.categories || []).filter((c) => c.enabled).map((c) => c.name))
: new Set(DEFAULT_CATS);
const userKws = (localConfig?.keywords || []).map((k) => k.toLowerCase());
const cats = morningBrief?.categories || {};
const dateStr = morningBrief?.date
? morningBrief.date.replace(/(\d{4})(\d{2})(\d{2})/, '$1年$2月$3日')
: '';
const totalNews = Object.values(cats).flat().length;
return (
<div>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div>
<div style={{ fontSize: 20, fontWeight: 800, marginBottom: 4 }}>🌅 </div>
<div style={{ fontSize: 12, color: 'var(--muted)' }}>
{dateStr && `${dateStr} | `}
{morningBrief?.generated_at && `采集于 ${morningBrief.generated_at} | `}
{totalNews}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn btn-g"
onClick={() => setShowConfig(!showConfig)}
style={{ fontSize: 12, padding: '6px 14px' }}
>
</button>
<button
className="tpl-go"
disabled={refreshing}
onClick={refreshNews}
style={{ fontSize: 12, padding: '6px 14px' }}
>
{refreshLabel}
</button>
</div>
</div>
{/* Subscription Config */}
{showConfig && localConfig && (
<SubConfigPanel
config={localConfig}
enabledSet={enabledSet}
onToggleCat={toggleCat}
onAddKeyword={addKeyword}
onRemoveKeyword={removeKeyword}
onAddFeed={addFeed}
onRemoveFeed={removeFeed}
onSave={saveConfig}
onSetWebhook={(v) => setLocalConfig({ ...localConfig, feishu_webhook: v })}
/>
)}
{/* News */}
{!Object.keys(cats).length ? (
<div className="mb-empty"></div>
) : (
<div className="mb-cats">
{Object.entries(cats).map(([cat, items]) => {
if (!enabledSet.has(cat)) return null;
const meta = CAT_META[cat] || { icon: '📰', color: 'var(--acc)', desc: cat };
const scored = (items as MorningNewsItem[])
.map((item) => {
const text = ((item.title || '') + (item.summary || '')).toLowerCase();
const kwHits = userKws.filter((k) => text.includes(k)).length;
return { ...item, _kwHits: kwHits };
})
.sort((a, b) => b._kwHits - a._kwHits);
return (
<div className="mb-cat" key={cat}>
<div className="mb-cat-hdr">
<span className="mb-cat-icon">{meta.icon}</span>
<span className="mb-cat-name" style={{ color: meta.color }}>{cat}</span>
<span className="mb-cat-cnt">{scored.length} </span>
</div>
<div className="mb-news-list">
{!scored.length ? (
<div className="mb-empty" style={{ padding: 16 }}></div>
) : (
scored.map((item, i) => {
const hasImg = !!(item.image && item.image.startsWith('http'));
return (
<div
className="mb-card"
key={i}
onClick={() => window.open(item.link, '_blank')}
>
<div className="mb-img">
{hasImg ? (
<img
src={item.image}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
loading="lazy"
alt=""
/>
) : (
<span>{meta.icon}</span>
)}
</div>
<div className="mb-info">
<div className="mb-headline">
{item.title}
{item._kwHits > 0 && (
<span
style={{
fontSize: 9,
padding: '1px 5px',
borderRadius: 999,
background: '#a07aff22',
color: '#a07aff',
border: '1px solid #a07aff44',
marginLeft: 4,
}}
>
</span>
)}
</div>
<div className="mb-summary">{item.summary || item.desc || ''}</div>
<div className="mb-meta">
<span className="mb-source">📡 {item.source || ''}</span>
{item.pub_date && (
<span className="mb-time">{item.pub_date.substring(0, 16)}</span>
)}
</div>
</div>
</div>
);
})
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
function SubConfigPanel({
config,
enabledSet,
onToggleCat,
onAddKeyword,
onRemoveKeyword,
onAddFeed,
onRemoveFeed,
onSave,
onSetWebhook,
}: {
config: SubConfig;
enabledSet: Set<string>;
onToggleCat: (name: string) => void;
onAddKeyword: (kw: string) => void;
onRemoveKeyword: (i: number) => void;
onAddFeed: (name: string, url: string, cat: string) => void;
onRemoveFeed: (i: number) => void;
onSave: () => void;
onSetWebhook: (v: string) => void;
}) {
const [newKw, setNewKw] = useState('');
const [feedName, setFeedName] = useState('');
const [feedUrl, setFeedUrl] = useState('');
const [feedCat, setFeedCat] = useState(DEFAULT_CATS[0]);
const allCats = [...DEFAULT_CATS];
(config.categories || []).forEach((c) => {
if (!allCats.includes(c.name)) allCats.push(c.name);
});
return (
<div className="sub-config" style={{ marginBottom: 20, padding: 16, background: 'var(--panel2)', borderRadius: 12, border: '1px solid var(--line)' }}>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}> </div>
{/* Categories */}
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}></div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{allCats.map((cat) => {
const meta = CAT_META[cat] || { icon: '📰', color: 'var(--acc)', desc: cat };
const on = enabledSet.has(cat);
return (
<div
key={cat}
className={`sub-cat ${on ? 'active' : ''}`}
onClick={() => onToggleCat(cat)}
style={{ cursor: 'pointer', padding: '6px 12px', borderRadius: 8, border: `1px solid ${on ? 'var(--acc)' : 'var(--line)'}`, display: 'flex', alignItems: 'center', gap: 6 }}
>
<span>{meta.icon}</span>
<span style={{ fontSize: 12 }}>{cat}</span>
{on && <span style={{ fontSize: 10, color: 'var(--ok)' }}></span>}
</div>
);
})}
</div>
</div>
{/* Keywords */}
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}></div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 6 }}>
{(config.keywords || []).map((kw, i) => (
<span key={i} className="sub-kw" style={{ fontSize: 11, padding: '2px 8px', borderRadius: 4, background: 'var(--bg)', border: '1px solid var(--line)' }}>
{kw}
<span style={{ cursor: 'pointer', marginLeft: 4, color: 'var(--danger)' }} onClick={() => onRemoveKeyword(i)}></span>
</span>
))}
</div>
<div style={{ display: 'flex', gap: 6 }}>
<input
type="text"
value={newKw}
onChange={(e) => setNewKw(e.target.value)}
placeholder="输入关键词"
onKeyDown={(e) => { if (e.key === 'Enter') { onAddKeyword(newKw.trim()); setNewKw(''); } }}
style={{ flex: 1, padding: '6px 10px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 6, color: 'var(--text)', fontSize: 12, outline: 'none' }}
/>
<button className="btn btn-g" onClick={() => { onAddKeyword(newKw.trim()); setNewKw(''); }} style={{ fontSize: 11, padding: '4px 12px' }}>
</button>
</div>
</div>
{/* Custom Feeds */}
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}></div>
{(config.custom_feeds || []).map((f, i) => (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4, fontSize: 11 }}>
<span style={{ fontWeight: 600 }}>{f.name}</span>
<span style={{ color: 'var(--muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.url}</span>
<span style={{ color: 'var(--acc)' }}>{f.category}</span>
<span style={{ cursor: 'pointer', color: 'var(--danger)' }} onClick={() => onRemoveFeed(i)}></span>
</div>
))}
<div style={{ display: 'flex', gap: 6, marginTop: 6 }}>
<input placeholder="源名称" value={feedName} onChange={(e) => setFeedName(e.target.value)}
style={{ width: 100, padding: '6px 8px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 6, color: 'var(--text)', fontSize: 11, outline: 'none' }} />
<input placeholder="RSS / URL" value={feedUrl} onChange={(e) => setFeedUrl(e.target.value)}
style={{ flex: 1, padding: '6px 8px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 6, color: 'var(--text)', fontSize: 11, outline: 'none' }} />
<select value={feedCat} onChange={(e) => setFeedCat(e.target.value)}
style={{ padding: '6px 8px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 6, color: 'var(--text)', fontSize: 11, outline: 'none' }}>
{allCats.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<button className="btn btn-g" onClick={() => { onAddFeed(feedName, feedUrl, feedCat); setFeedName(''); setFeedUrl(''); }} style={{ fontSize: 11, padding: '4px 12px' }}>
</button>
</div>
</div>
{/* Feishu Webhook */}
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}> Webhook</div>
<input
type="text"
value={config.feishu_webhook || ''}
onChange={(e) => onSetWebhook(e.target.value)}
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
style={{ width: '100%', padding: '8px 10px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 6, color: 'var(--text)', fontSize: 12, outline: 'none' }}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button className="tpl-go" onClick={onSave} style={{ fontSize: 12, padding: '6px 16px' }}>
💾
</button>
</div>
</div>
);
}
@@ -0,0 +1,73 @@
/**
* 通知中心 — 烽火台
* 显示系统通知、审批请求、告警信息
*/
import { useState } from 'react';
import { useStore } from '../store';
export interface Notification {
id: string;
type: 'info' | 'warning' | 'success' | 'error';
title: string;
message: string;
time: string;
read: boolean;
}
const TYPE_STYLES: Record<string, { icon: string; color: string }> = {
info: { icon: '️', color: '#6a9eff' },
warning: { icon: '⚠️', color: '#f5c842' },
success: { icon: '✅', color: '#2ecc8a' },
error: { icon: '🚨', color: '#ff5270' },
};
export default function NotificationCenter({ onClose }: { onClose: () => void }) {
const [notifications] = useState<Notification[]>([]);
const unread = notifications.filter((n) => !n.read).length;
return (
<div className="confirm-bg open" onClick={onClose}>
<div className="confirm-box" style={{ maxWidth: 400, maxHeight: 500 }} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<span style={{ fontSize: 14, fontWeight: 700 }}>🔔 </span>
{unread > 0 && (
<span style={{ fontSize: 10, padding: '2px 8px', borderRadius: 10, background: '#ff5270', color: '#fff', fontWeight: 600 }}>
{unread}
</span>
)}
</div>
{notifications.length === 0 ? (
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<div style={{ fontSize: 28, marginBottom: 8 }}>🔕</div>
<div style={{ fontSize: 12, color: 'var(--muted)' }}></div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 380, overflow: 'auto' }}>
{notifications.map((n) => {
const style = TYPE_STYLES[n.type] || TYPE_STYLES.info;
return (
<div
key={n.id}
style={{
padding: '10px 12px', borderRadius: 8,
background: 'var(--panel2)', border: `1px solid ${n.read ? 'var(--line)' : style.color + '44'}`,
opacity: n.read ? 0.6 : 1,
}}
>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
<span>{style.icon}</span>
<span style={{ fontSize: 12, fontWeight: 600 }}>{n.title}</span>
<span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--muted)' }}>{n.time}</span>
</div>
<div style={{ fontSize: 11, color: 'var(--muted)' }}>{n.message}</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,258 @@
import { useEffect } from 'react';
import { useStore, STATE_LABEL } from '../store';
const MEDALS = ['🥇', '🥈', '🥉'];
export default function OfficialPanel() {
const officialsData = useStore((s) => s.officialsData);
const selectedOfficial = useStore((s) => s.selectedOfficial);
const setSelectedOfficial = useStore((s) => s.setSelectedOfficial);
const loadOfficials = useStore((s) => s.loadOfficials);
const setModalTaskId = useStore((s) => s.setModalTaskId);
useEffect(() => {
loadOfficials();
}, [loadOfficials]);
if (!officialsData?.officials) {
return <div className="empty"> </div>;
}
const offs = officialsData.officials;
const totals = officialsData.totals || { tasks_done: 0, cost_cny: 0 };
const maxTk = Math.max(...offs.map((o) => o.tokens_in + o.tokens_out + o.cache_read + o.cache_write), 1);
// Active officials
const alive = offs.filter((o) => o.heartbeat?.status === 'active');
// Selected official detail
const sel = offs.find((o) => o.id === (selectedOfficial || offs[0]?.id));
const selId = sel?.id || offs[0]?.id;
return (
<div>
{/* Activity banner */}
{alive.length > 0 && (
<div className="off-activity">
<span>🟢 </span>
{alive.map((o) => (
<span key={o.id} style={{ fontSize: 12 }}>{o.emoji} {o.role}</span>
))}
<span style={{ color: 'var(--muted)', fontSize: 11, marginLeft: 'auto' }}></span>
</div>
)}
{/* KPI Row */}
<div className="off-kpi">
<div className="kpi">
<div className="kpi-v" style={{ color: 'var(--acc)' }}>{offs.length}</div>
<div className="kpi-l"></div>
</div>
<div className="kpi">
<div className="kpi-v" style={{ color: '#f5c842' }}>{totals.tasks_done || 0}</div>
<div className="kpi-l"></div>
</div>
<div className="kpi">
<div className="kpi-v" style={{ color: (totals.cost_cny || 0) > 20 ? 'var(--warn)' : 'var(--ok)' }}>
¥{totals.cost_cny || 0}
</div>
<div className="kpi-l"></div>
</div>
<div className="kpi">
<div className="kpi-v" style={{ fontSize: 16, paddingTop: 4 }}>{officialsData.top_official || '—'}</div>
<div className="kpi-l"></div>
</div>
</div>
{/* Layout: Ranklist + Detail */}
<div className="off-layout">
{/* Left: Ranklist */}
<div className="off-ranklist">
<div className="orl-hdr"></div>
{offs.map((o) => {
const hb = o.heartbeat || { status: 'idle' };
return (
<div
key={o.id}
className={`orl-item${selId === o.id ? ' selected' : ''}`}
onClick={() => setSelectedOfficial(o.id)}
>
<span style={{ minWidth: 24, textAlign: 'center' }}>
{o.merit_rank <= 3 ? MEDALS[o.merit_rank - 1] : '#' + o.merit_rank}
</span>
<span>{o.emoji}</span>
<span style={{ flex: 1 }}>
<div style={{ fontSize: 12, fontWeight: 700 }}>{o.role}</div>
<div style={{ fontSize: 10, color: 'var(--muted)' }}>{o.label} · {o.model_short || o.model}</div>
</span>
<span style={{ fontSize: 11, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2 }}>
<span>{o.merit_score}</span>
{(() => {
const failed = o.tasks_failed ?? 0;
const total = o.tasks_done + o.tasks_active + failed;
const rate = total > 0 ? Math.round((o.tasks_done / total) * 100) : -1;
if (rate < 0) return null;
const color = rate > 80 ? '#2ecc8a' : rate >= 50 ? '#f5c842' : '#ff5270';
return <span style={{ fontSize: 9, color }}> {rate}%</span>;
})()}
{o.cost_cny > 0 && <span style={{ fontSize: 9, color: 'var(--muted)' }}>¥{o.cost_cny}</span>}
</span>
<span className={`dc-dot ${hb.status}`} style={{ width: 8, height: 8 }} />
</div>
);
})}
</div>
{/* Right: Detail */}
<div className="off-detail">
{sel ? (
<OfficialDetail official={sel} maxTk={maxTk} onOpenTask={setModalTaskId} />
) : (
<div className="empty"></div>
)}
</div>
{/* 锦囊在 OfficialDetail 内部渲染 */}
</div>
</div>
);
}
function OfficialDetail({
official: o,
maxTk,
onOpenTask,
}: {
official: NonNullable<ReturnType<typeof useStore.getState>['officialsData']>['officials'][0];
maxTk: number;
onOpenTask: (id: string) => void;
}) {
const hb = o.heartbeat || { status: 'idle', label: '⚪ 待命' };
const totTk = o.tokens_in + o.tokens_out + o.cache_read + o.cache_write;
const edicts = o.participated_edicts || [];
const tkBars = [
{ l: '输入', v: o.tokens_in, color: '#6a9eff' },
{ l: '输出', v: o.tokens_out, color: '#a07aff' },
{ l: '缓存读', v: o.cache_read, color: '#2ecc8a' },
{ l: '缓存写', v: o.cache_write, color: '#f5c842' },
];
return (
<div>
{/* Hero */}
<div style={{ display: 'flex', gap: 16, alignItems: 'center', marginBottom: 20 }}>
<div style={{ fontSize: 40 }}>{o.emoji}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 18, fontWeight: 800 }}>{o.role}</div>
<div style={{ fontSize: 12, color: 'var(--muted)' }}>
{o.label} · <span style={{ color: 'var(--acc)' }}>{o.model_short || o.model}</span>
</div>
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2 }}>
🏅 {o.rank} · {o.merit_score}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div className={`hb ${hb.status}`} style={{ marginBottom: 4 }}>{hb.label}</div>
{o.last_active && <div style={{ fontSize: 10, color: 'var(--muted)' }}> {o.last_active}</div>}
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>
{o.sessions} · {o.messages}
</div>
</div>
</div>
{/* Merit Stats */}
<div style={{ marginBottom: 18 }}>
<div className="sec-title"></div>
<div style={{ display: 'flex', gap: 16 }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 20, fontWeight: 800, color: 'var(--ok)' }}>{o.tasks_done}</div>
<div style={{ fontSize: 10, color: 'var(--muted)' }}></div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 20, fontWeight: 800, color: 'var(--warn)' }}>{o.tasks_active}</div>
<div style={{ fontSize: 10, color: 'var(--muted)' }}></div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 20, fontWeight: 800, color: 'var(--acc)' }}>{o.flow_participations}</div>
<div style={{ fontSize: 10, color: 'var(--muted)' }}></div>
</div>
</div>
</div>
{/* Token Bars */}
<div style={{ marginBottom: 18 }}>
<div className="sec-title">Token </div>
{tkBars.map((b) => (
<div key={b.l} style={{ marginBottom: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, marginBottom: 2 }}>
<span style={{ color: 'var(--muted)' }}>{b.l}</span>
<span>{b.v.toLocaleString()}</span>
</div>
<div style={{ height: 6, background: '#0e1320', borderRadius: 3 }}>
<div style={{ height: '100%', width: `${maxTk > 0 ? Math.round((b.v / maxTk) * 100) : 0}%`, background: b.color, borderRadius: 3 }} />
</div>
</div>
))}
</div>
{/* Cost */}
<div style={{ marginBottom: 18 }}>
<div className="sec-title"></div>
<div style={{ display: 'flex', gap: 10 }}>
<span style={{ fontSize: 12, color: o.cost_cny > 10 ? 'var(--danger)' : o.cost_cny > 3 ? 'var(--warn)' : 'var(--ok)' }}>
<b>¥{o.cost_cny}</b>
</span>
<span style={{ fontSize: 12 }}><b>${o.cost_usd}</b> </span>
<span style={{ fontSize: 11, color: 'var(--muted)' }}> {totTk.toLocaleString()} tokens</span>
</div>
</div>
{/* Participated Edicts */}
<div>
<div className="sec-title">{edicts.length} </div>
{edicts.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--muted)', padding: '8px 0' }}></div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{edicts.map((e) => {
const stRaw = e.state || e.status || '';
const stLabel = STATE_LABEL[stRaw] || stRaw || '未知';
const stKey = stRaw;
return (
<div
key={e.id}
style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '6px 8px', borderRadius: 6, cursor: 'pointer', border: '1px solid var(--line)' }}
onClick={() => onOpenTask(e.id)}
>
<span style={{ fontSize: 10, color: 'var(--acc)', fontWeight: 700 }}>{e.id}</span>
<span style={{ flex: 1, fontSize: 12 }}>{e.title.substring(0, 35)}</span>
<span className={`tag st-${stKey}`} style={{ fontSize: 10 }}>{stLabel}</span>
</div>
);
})}
</div>
)}
</div>
{/* 锦囊(MEMORY.md*/}
<div style={{ marginTop: 18 }}>
<div className="sec-title">🎒 </div>
<div style={{ background: 'var(--panel2)', border: '1px solid var(--line)', borderRadius: 8, padding: '10px 14px', display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 12, color: 'var(--muted)' }}>
{o.memorySize ? `📜 锦囊已备 (${o.memorySize} 字)` : '📜 锦囊状态未知'}
{o.memoryUpdatedAt && <span style={{ marginLeft: 8 }}>· {o.memoryUpdatedAt}</span>}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn btn-g"
style={{ fontSize: 11, padding: '4px 12px' }}
onClick={() => { /* TODO: 锦囊翻阅待后端支持 */ }}
>
📖
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,358 @@
import { useStore, isEdict, STATE_LABEL, timeAgo } from '../store';
import type { Task } from '../api';
import { useState } from 'react';
import { formatDashboardTime } from '../time';
// Agent maps built from agentConfig
function useAgentMaps() {
const cfg = useStore((s) => s.agentConfig);
const emojiMap: Record<string, string> = {};
const labelMap: Record<string, string> = {};
if (cfg?.agents) {
cfg.agents.forEach((a) => {
emojiMap[a.id] = a.emoji || '🏛️';
labelMap[a.id] = a.label || a.id;
});
}
return { emojiMap, labelMap };
}
function extractAgent(t: Task): string {
const m = (t.id || '').match(/^OC-(\w+)-/);
if (m) return m[1];
return (t.org || '').replace(/省|部/g, '').toLowerCase();
}
function humanTitle(t: Task, labelMap: Record<string, string>): string {
let title = t.title || '';
if (title === 'heartbeat 会话' || title === 'heartbeat 传令') return '💓 心跳检测';
const m = title.match(/^agent:(\w+):(\w+)/);
if (m) {
const agLabel = labelMap[m[1]] || m[1];
if (m[2] === 'main') return agLabel + ' · 主传令';
if (m[2] === 'subagent') return agLabel + ' · 子任务执行';
if (m[2] === 'cron') return agLabel + ' · 定时任务';
return agLabel + ' · ' + m[2];
}
return title.replace(/ 会话$/, '').replace(/ 传令$/, '') || t.id;
}
function channelLabel(t: Task): { icon: string; text: string } {
const now = t.now || '';
if (now.includes('feishu/direct')) return { icon: '💬', text: '飞书对话' };
if (now.includes('feishu')) return { icon: '💬', text: '飞书' };
if (now.includes('webchat')) return { icon: '🌐', text: 'WebChat' };
if (now.includes('cron')) return { icon: '⏰', text: '定时' };
if (now.includes('direct')) return { icon: '📨', text: '直连' };
return { icon: '📡', text: '传令' };
}
function lastMessage(t: Task): string {
const acts = t.activity || [];
for (let i = acts.length - 1; i >= 0; i--) {
const a = acts[i];
if (a.kind === 'assistant') {
let txt = a.text || '';
if (txt.startsWith('NO_REPLY') || txt.startsWith('Reasoning:')) continue;
txt = txt.replace(/\[\[.*?\]\]/g, '').replace(/\*\*/g, '').replace(/^#+\s/gm, '').trim();
return txt.substring(0, 50) + (txt.length > 50 ? '…' : '');
}
}
return '';
}
/** Compute active dot color from heartbeat + state */
function activeDot(t: Task): { color: string; title: string } {
const hb = t.heartbeat;
const st = t.status || t.state;
if (hb?.status === 'active') return { color: '#2ecc8a', title: '执行中' };
if (hb?.status === 'warn') return { color: '#f5c842', title: '警告' };
if (hb?.status === 'stalled') return { color: '#ff5270', title: '卡顿' };
if (st && !['Done', 'Cancelled', 'completed', 'cancelled'].includes(st))
return { color: '#2ecc8a', title: '进行中' };
return { color: '#6b7280', title: '空闲' };
}
/** Compute duration from activity timestamps */
function computeDuration(t: Task): string | null {
const acts = t.activity || [];
if (acts.length < 2) return null;
const first = acts[0].at;
const last = acts[acts.length - 1].at;
if (!first || !last) return null;
const ms = new Date(typeof last === 'number' ? last : last).getTime()
- new Date(typeof first === 'number' ? first : first).getTime();
if (ms < 0) return null;
const sec = Math.round(ms / 1000);
if (sec < 60) return `${sec}`;
const min = Math.floor(sec / 60);
const remSec = sec % 60;
if (min < 60) return `${min}${remSec}`;
const hr = Math.floor(min / 60);
const remMin = min % 60;
return `${hr}${remMin}`;
}
export default function SessionsPanel() {
const liveStatus = useStore((s) => s.liveStatus);
const sessFilter = useStore((s) => s.sessFilter);
const setSessFilter = useStore((s) => s.setSessFilter);
const { emojiMap, labelMap } = useAgentMaps();
const [detailTask, setDetailTask] = useState<Task | null>(null);
const tasks = liveStatus?.tasks || [];
const sessions = tasks.filter((t) => !isEdict(t));
let filtered = sessions;
if (sessFilter === 'active') filtered = sessions.filter((t) => {
const st = t.status || t.state;
return st !== 'Done' && st !== 'Cancelled' && st !== 'completed' && st !== 'cancelled';
});
else if (sessFilter !== 'all') filtered = sessions.filter((t) => extractAgent(t) === sessFilter);
// Unique agents for filter tabs
const agentIds = [...new Set(sessions.map(extractAgent))];
// Count active sessions
const activeCount = sessions.filter((t) => {
const st = t.status || t.state;
return st !== 'Done' && st !== 'Cancelled' && st !== 'completed' && st !== 'cancelled';
}).length;
return (
<div>
{/* Filters */}
<div style={{ display: 'flex', gap: 6, marginBottom: 16, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: 12, color: 'var(--muted)', marginRight: 4 }}>📡 </span>
{[
{ key: 'all', label: `全部 (${sessions.length})` },
{ key: 'active', label: `活跃 (${activeCount})` },
...agentIds.slice(0, 8).map((id) => ({
key: id,
label: `${emojiMap[id] || ''} ${labelMap[id] || id}`,
})),
].map((f) => (
<span
key={f.key}
className={`sess-filter${sessFilter === f.key ? ' active' : ''}`}
onClick={() => setSessFilter(f.key)}
>
{f.label}
</span>
))}
</div>
{/* Grid */}
<div className="sess-grid">
{!filtered.length ? (
<div style={{ fontSize: 13, color: 'var(--muted)', padding: 24, textAlign: 'center', gridColumn: '1/-1' }}>
</div>
) : (
filtered.map((t) => {
const agent = extractAgent(t);
const emoji = emojiMap[agent] || '🏛️';
const agLabel = labelMap[agent] || t.org || agent;
const ch = channelLabel(t);
const title = humanTitle(t, labelMap);
const msg = lastMessage(t);
const sm = t.sourceMeta || {};
const totalTk = (sm as Record<string, unknown>).totalTokens as number | undefined;
const updatedAt = t.eta || t.updatedAt || '';
const dot = activeDot(t);
const st = t.status || t.state || 'Unknown';
return (
<div className="sess-card" key={t.id} onClick={() => setDetailTask(t)}>
<div className="sc-top">
<span className="sc-emoji">{emoji}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="sc-agent">{agLabel}</span>
<span style={{ fontSize: 10, color: 'var(--muted)', background: 'var(--panel2)', padding: '2px 6px', borderRadius: 4 }}>
{ch.icon} {ch.text}
</span>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span
style={{
width: 8, height: 8, borderRadius: '50%', display: 'inline-block',
background: dot.color,
boxShadow: dot.color === '#2ecc8a' ? '0 0 6px #2ecc8a88' : 'none',
}}
title={dot.title}
/>
<span className={`tag st-${st}`} style={{ fontSize: 10 }}>{STATE_LABEL[st] || st}</span>
</div>
</div>
<div className="sc-title">{title}</div>
{msg && (
<div style={{ fontSize: 11, color: 'var(--muted)', lineHeight: 1.5, marginBottom: 8, borderLeft: '2px solid var(--line)', paddingLeft: 8, maxHeight: 40, overflow: 'hidden' }}>
{msg}
</div>
)}
<div className="sc-meta">
{totalTk ? <span style={{ fontSize: 10, color: 'var(--muted)' }}>🪙 {totalTk.toLocaleString()} tokens</span> : null}
{updatedAt ? <span className="sc-time">{timeAgo(updatedAt)}</span> : null}
</div>
</div>
);
})
)}
</div>
{/* Session Detail Modal */}
{detailTask && (
<SessionDetailModal task={detailTask} labelMap={labelMap} emojiMap={emojiMap} onClose={() => setDetailTask(null)} />
)}
</div>
);
}
function SessionDetailModal({
task: t,
labelMap,
emojiMap,
onClose,
}: {
task: Task;
labelMap: Record<string, string>;
emojiMap: Record<string, string>;
onClose: () => void;
}) {
const agent = extractAgent(t);
const emoji = emojiMap[agent] || '🏛️';
const agLabel = labelMap[agent] || t.org || agent;
const title = humanTitle(t, labelMap);
const ch = channelLabel(t);
const dot = activeDot(t);
const sm = t.sourceMeta || {};
const acts = t.activity || [];
const st = t.status || t.state || 'Unknown';
const duration = computeDuration(t);
const totalTokens = (sm as Record<string, unknown>).totalTokens as number | undefined;
const inputTokens = (sm as Record<string, unknown>).inputTokens as number | undefined;
const outputTokens = (sm as Record<string, unknown>).outputTokens as number | undefined;
const cachedTokens = (sm as Record<string, unknown>).cacheTokens as number | undefined
?? (sm as Record<string, unknown>).cache_read as number | undefined;
return (
<div className="modal-bg open" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose}></button>
<div className="modal-body">
<div style={{ fontSize: 11, color: 'var(--acc)', fontWeight: 700, letterSpacing: '.04em', marginBottom: 4 }}>{t.id}</div>
<div style={{ fontSize: 20, fontWeight: 800, marginBottom: 6 }}>
{emoji} {title}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 18, flexWrap: 'wrap' }}>
<span
style={{
width: 10, height: 10, borderRadius: '50%', display: 'inline-block',
background: dot.color,
boxShadow: dot.color === '#2ecc8a' ? '0 0 6px #2ecc8a88' : 'none',
}}
title={dot.title}
/>
<span className={`tag st-${st}`}>{STATE_LABEL[st] || st}</span>
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{ch.icon} {ch.text}</span>
{duration && (
<span style={{ fontSize: 11, color: 'var(--muted)', background: 'var(--panel2)', padding: '2px 8px', borderRadius: 4 }}>
{duration}
</span>
)}
</div>
{/* Agent Info */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18, padding: '10px 14px', background: 'var(--panel2)', borderRadius: 10, border: '1px solid var(--line)' }}>
<span style={{ fontSize: 24 }}>{emoji}</span>
<div>
<div style={{ fontSize: 14, fontWeight: 700 }}>{agLabel}</div>
<div style={{ fontSize: 11, color: 'var(--muted)' }}> · {agent}</div>
</div>
</div>
{/* Stats */}
<div style={{ display: 'flex', gap: 10, marginBottom: 18, flexWrap: 'wrap' }}>
{totalTokens != null && (
<div style={{ background: 'var(--panel2)', padding: '10px 16px', borderRadius: 8, fontSize: 12, border: '1px solid var(--line)' }}>
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--acc)' }}>{totalTokens.toLocaleString()}</div>
<div style={{ color: 'var(--muted)', fontSize: 10 }}> Tokens</div>
</div>
)}
{inputTokens != null && (
<div style={{ background: 'var(--panel2)', padding: '10px 16px', borderRadius: 8, fontSize: 12, border: '1px solid var(--line)' }}>
<div style={{ fontSize: 16, fontWeight: 700 }}>{inputTokens.toLocaleString()}</div>
<div style={{ color: 'var(--muted)', fontSize: 10 }}></div>
</div>
)}
{outputTokens != null && (
<div style={{ background: 'var(--panel2)', padding: '10px 16px', borderRadius: 8, fontSize: 12, border: '1px solid var(--line)' }}>
<div style={{ fontSize: 16, fontWeight: 700 }}>{outputTokens.toLocaleString()}</div>
<div style={{ color: 'var(--muted)', fontSize: 10 }}></div>
</div>
)}
{cachedTokens != null && (
<div style={{ background: 'var(--panel2)', padding: '10px 16px', borderRadius: 8, fontSize: 12, border: '1px solid var(--line)' }}>
<div style={{ fontSize: 16, fontWeight: 700, color: '#2ecc8a' }}>{cachedTokens.toLocaleString()}</div>
<div style={{ color: 'var(--muted)', fontSize: 10 }}></div>
</div>
)}
{acts.length > 0 && (
<div style={{ background: 'var(--panel2)', padding: '10px 16px', borderRadius: 8, fontSize: 12, border: '1px solid var(--line)' }}>
<div style={{ fontSize: 16, fontWeight: 700 }}>{acts.length}</div>
<div style={{ color: 'var(--muted)', fontSize: 10 }}></div>
</div>
)}
</div>
{/* Activity Timeline */}
<div style={{ fontSize: 12, fontWeight: 700, marginBottom: 8 }}>
📋 线 <span style={{ fontWeight: 400, color: 'var(--muted)' }}>({acts.length} )</span>
</div>
<div style={{ maxHeight: 400, overflowY: 'auto', border: '1px solid var(--line)', borderRadius: 10, background: 'var(--panel2)' }}>
{!acts.length ? (
<div style={{ padding: 16, color: 'var(--muted)', fontSize: 12, textAlign: 'center' }}></div>
) : (
acts.slice().reverse().map((a, i) => {
const kind = a.kind || '';
const kIcon = kind === 'assistant' ? '🤖' : kind === 'tool' ? '🔧' : kind === 'user' ? '👤' : '📝';
const kLabel = kind === 'assistant' ? '回复' : kind === 'tool' ? '工具' : kind === 'user' ? '用户' : '事件';
let txt = (a.text || '').replace(/\[\[.*?\]\]/g, '').replace(/\*\*/g, '').trim();
if (txt.length > 200) txt = txt.substring(0, 200) + '…';
const time = formatDashboardTime(a.at as string | number | undefined, { showSeconds: true });
return (
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--line)', fontSize: 12, lineHeight: 1.5 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3 }}>
<span>{kIcon}</span>
<span style={{ fontWeight: 600, fontSize: 11 }}>{kLabel}</span>
<span style={{ color: 'var(--muted)', fontSize: 10, marginLeft: 'auto' }}>{time}</span>
</div>
{txt && <div style={{ color: 'var(--muted)' }}>{txt}</div>}
{a.tools && a.tools.length > 0 && (
<div style={{ marginTop: 4 }}>
{a.tools.map((tool, ti) => (
<span key={ti} style={{ fontSize: 10, background: 'var(--panel)', border: '1px solid var(--line)', padding: '2px 6px', borderRadius: 4, marginRight: 4, color: 'var(--acc)' }}>
{tool.name}
</span>
))}
</div>
)}
</div>
);
})
)}
</div>
{t.output && t.output !== '-' && (
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 12, wordBreak: 'break-all', borderTop: '1px solid var(--line)', paddingTop: 8 }}>
📂 {t.output}
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,293 @@
/**
* 城防设置 — 接线状态、安全防务、版本更新、数据源配置
* 三国术语:设置→城防、连接→接线、配置→防务
*/
import { useState, useCallback } from 'react';
import { api, AgentsStatusData } from '../api';
interface ServiceCheckResult {
name: string;
url: string;
online: boolean;
detail: string;
checkedAt: string;
}
export default function SettingsPanel() {
const [tab, setTab] = useState<'connections' | 'security' | 'version' | 'logs'>('connections');
// 接线状态巡检
const [checking, setChecking] = useState(false);
const [checkResults, setCheckResults] = useState<ServiceCheckResult[]>([]);
const [lastInspection, setLastInspection] = useState<string>('');
const runInspection = useCallback(async () => {
setChecking(true);
const results: ServiceCheckResult[] = [];
const now = new Date().toLocaleString('zh-CN', { hour12: false });
// 1. Gateway 状态
let gatewayOnline = false;
let gatewayUrl = 'http://192.168.2.153:18789';
let gatewayVersion = '-';
try {
const status: AgentsStatusData = await api.agentsStatus();
gatewayOnline = status.gateway.alive;
gatewayVersion = status.gateway.status || '-';
gatewayUrl = status.ok ? gatewayUrl : gatewayUrl;
} catch {
gatewayOnline = false;
}
results.push({
name: 'OpenClaw Gateway',
url: gatewayUrl,
online: gatewayOnline,
detail: gatewayOnline ? `在线 · ${gatewayVersion}` : '离线',
checkedAt: now,
});
// 2. moziplus 后端
let backendOnline = false;
try {
await api.agentsStatus();
backendOnline = true;
} catch {
backendOnline = false;
}
results.push({
name: 'moziplus 后端',
url: 'http://192.168.2.154:8088',
online: backendOnline,
detail: backendOnline ? '在线' : '离线',
checkedAt: now,
});
setCheckResults(results);
setLastInspection(now);
setChecking(false);
}, []);
// 静态接线列表
const connections = [
{ name: 'moziplus 后端', url: 'http://localhost:8088', status: 'connected', note: 'moziplus daemon' },
{ name: 'OpenClaw Gateway', url: 'http://192.168.2.153:18789', status: 'connected', note: '主 Gateway' },
{ name: 'NAS 回测服务', url: 'http://192.168.2.154:8088', status: 'connected', note: 'Docker 回测' },
{ name: 'Gitee 远程仓库', url: 'git@gitee.com:cfdaily/sanguo_moziplus.git', status: 'connected', note: 'Git 同步' },
{ name: 'RedisM3', url: '-', status: 'pending', note: 'M3 阶段接入实时推送' },
{ name: '飞书推送(M3', url: '-', status: 'pending', note: 'M3 阶段接入通知推送' },
];
// 安全防务
const risks = [
{ level: 'info' as const, title: 'API 无鉴权', desc: 'Dashboard API 默认无 token 鉴权,本地运行无风险', action: 'M3 考虑加入 token 验证' },
{ level: 'ok' as const, title: 'SSH 密钥正常', desc: 'Gitee SSH 密钥配置正确,Git 同步正常', action: '' },
{ level: 'ok' as const, title: '数据库备份', desc: 'SQLite 数据库自动备份已启用', action: '' },
{ level: 'warn' as const, title: 'Python 3.9 版本较旧', desc: 'Python 3.9.6 不支持 X | None 语法,需 from __future__ import annotations', action: '考虑升级到 Python 3.10+' },
];
return (
<div>
{/* 城防子页签 */}
<div style={{ display: 'flex', gap: 6, marginBottom: 20 }}>
{[
{ key: 'connections' as const, label: '🔌 接线状态' },
{ key: 'security' as const, label: '🛡️ 安全防务' },
{ key: 'version' as const, label: '📦 版本更新' },
{ key: 'logs' as const, label: '📋 城防日志' },
].map((t) => (
<button key={t.key} className={`btn ${tab === t.key ? 'btn-primary' : ''}`} onClick={() => setTab(t.key)}>
{t.label}
</button>
))}
</div>
{/* ========== 接线状态 ========== */}
{tab === 'connections' && (
<div>
{/* 巡视城防 */}
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 14, padding: 18, marginBottom: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<span style={{ fontWeight: 700, fontSize: 14 }}>🏰 线</span>
<button
className="btn btn-action"
onClick={runInspection}
disabled={checking}
style={{ opacity: checking ? 0.6 : 1, cursor: checking ? 'not-allowed' : 'pointer' }}
>
{checking ? '⏳ 巡视中…' : '🔍 巡视城防'}
</button>
</div>
{lastInspection && (
<div style={{ fontSize: 10, color: 'var(--muted)', marginBottom: 10 }}>
{lastInspection}
</div>
)}
{checkResults.length > 0 && (
<div>
{checkResults.map((r, i) => (
<div
key={i}
style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '12px 0',
borderBottom: i < checkResults.length - 1 ? '1px solid var(--line)' : 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{
width: 10, height: 10, borderRadius: '50%',
background: r.online ? '#2ecc8a' : '#ff5270',
boxShadow: `0 0 6px ${r.online ? '#2ecc8a66' : '#ff527066'}`,
display: 'inline-block', flexShrink: 0,
}} />
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{r.name}</div>
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace' }}>{r.url}</div>
</div>
</div>
<div style={{ textAlign: 'right' }}>
<span style={{
fontSize: 10, padding: '2px 8px', borderRadius: 4,
background: r.online ? '#0a2018' : '#200a0a',
color: r.online ? '#2ecc8a' : '#ff5270',
border: `1px solid ${r.online ? '#2ecc8a44' : '#ff527044'}`,
}}>
{r.detail}
</span>
</div>
</div>
))}
{/* 巡视摘要 */}
<div style={{
marginTop: 12, padding: '10px 14px', borderRadius: 8,
background: checkResults.every(r => r.online) ? '#0a2018' : '#200a0a',
border: `1px solid ${checkResults.every(r => r.online) ? '#2ecc8a44' : '#ff527044'}`,
fontSize: 12,
color: checkResults.every(r => r.online) ? '#2ecc8a' : '#ff5270',
}}>
{checkResults.every(r => r.online)
? `✅ 全部城门正常 (${checkResults.length}/${checkResults.length})`
: `⚠️ ${checkResults.filter(r => !r.online).length} 处城门告急 (${checkResults.filter(r => r.online).length}/${checkResults.length} 正常)`
}
</div>
</div>
)}
{checkResults.length === 0 && !checking && (
<div style={{ fontSize: 12, color: 'var(--muted)', textAlign: 'center', padding: '16px 0' }}>
🔍 线
</div>
)}
</div>
{/* 接线列表 */}
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 14 }}>
<div style={{ padding: '12px 18px 0', fontSize: 12, fontWeight: 700, color: 'var(--muted)' }}>线</div>
{connections.map((c, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 18px', borderBottom: i < connections.length - 1 ? '1px solid #0e1320' : 'none' }}>
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{c.name}</div>
<div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'monospace' }}>{c.url}</div>
</div>
<div style={{ textAlign: 'right' }}>
<span style={{ fontSize: 10, padding: '2px 8px', borderRadius: 4, background: c.status === 'connected' ? '#0a2018' : '#201a08', color: c.status === 'connected' ? 'var(--ok)' : 'var(--warn)', border: `1px solid ${c.status === 'connected' ? '#2ecc8a44' : '#f5c84244'}` }}>
{c.status === 'connected' ? '✅ 已接线' : '⏳ 待接入'}
</span>
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>{c.note}</div>
</div>
</div>
))}
</div>
</div>
)}
{/* ========== 安全防务 ========== */}
{tab === 'security' && (
<div>
{risks.map((r, i) => (
<div key={i} style={{ background: 'var(--panel)', border: `1px solid ${r.level === 'warn' ? '#f5c84244' : r.level === 'ok' ? '#2ecc8a44' : '#6a9eff44'}`, borderRadius: 12, padding: 14, marginBottom: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>
{r.level === 'ok' && '✅ '}{r.level === 'warn' && '⚠️ '}{r.level === 'info' && '️ '}
{r.title}
</span>
<span style={{ fontSize: 9, padding: '1px 6px', borderRadius: 3, background: r.level === 'ok' ? '#0a2018' : r.level === 'warn' ? '#201a08' : '#0a1428', color: r.level === 'ok' ? 'var(--ok)' : r.level === 'warn' ? 'var(--warn)' : 'var(--acc)' }}>
{r.level === 'ok' ? '正常' : r.level === 'warn' ? '警告' : '提示'}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: r.action ? 6 : 0 }}>{r.desc}</div>
{r.action && <div style={{ fontSize: 11, color: 'var(--acc)' }}> {r.action}</div>}
</div>
))}
</div>
)}
{/* ========== 版本更新 ========== */}
{tab === 'version' && (
<div>
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 14, padding: 18 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div>
<div style={{ fontSize: 10, color: 'var(--muted)', marginBottom: 4 }}>moziplus</div>
<div style={{ fontSize: 18, fontWeight: 800 }}>v0.4b</div>
<div style={{ fontSize: 10, color: 'var(--muted)' }}>M2 </div>
</div>
<div>
<div style={{ fontSize: 10, color: 'var(--muted)', marginBottom: 4 }}>Dashboard</div>
<div style={{ fontSize: 18, fontWeight: 800 }}>v1.0</div>
<div style={{ fontSize: 10, color: 'var(--muted)' }}> Edict </div>
</div>
<div>
<div style={{ fontSize: 10, color: 'var(--muted)', marginBottom: 4 }}>OpenClaw</div>
<div style={{ fontSize: 18, fontWeight: 800 }}>latest</div>
<div style={{ fontSize: 10, color: 'var(--ok)' }}> </div>
</div>
<div>
<div style={{ fontSize: 10, color: 'var(--muted)', marginBottom: 4 }}>Node.js</div>
<div style={{ fontSize: 18, fontWeight: 800 }}>v22</div>
<div style={{ fontSize: 10, color: 'var(--ok)' }}> </div>
</div>
</div>
</div>
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 14, padding: 18, marginTop: 12 }}>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 10 }}>📋 </div>
<div style={{ fontSize: 12, color: 'var(--muted)', lineHeight: 1.8 }}>
<div>📦 git clone + PM2</div>
<div>🔄 git pull + npm install + pm2 restart</div>
<div>📝 git@gitee.com:cfdaily/sanguo_moziplus.git</div>
<div>🔀 main develop</div>
</div>
</div>
</div>
)}
{/* ========== 城防日志 ========== */}
{tab === 'logs' && (
<div>
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 14, padding: 18 }}>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>📋 </div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 12 }}>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{JSON.parse(localStorage.getItem('opLogs') || '[]').slice(-20).reverse().map((log: { time: string; action: string; detail: string }, i: number) => (
<div key={i} style={{ display: 'flex', gap: 10, alignItems: 'baseline', padding: '6px 0', borderBottom: '1px solid var(--line)' }}>
<span style={{ fontSize: 10, color: 'var(--muted)', width: 140, flexShrink: 0 }}>{log.time}</span>
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--acc)', width: 80 }}>{log.action}</span>
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{log.detail}</span>
</div>
))}
{JSON.parse(localStorage.getItem('opLogs') || '[]').length === 0 && (
<div style={{ fontSize: 12, color: 'var(--muted)', padding: 8 }}></div>
)}
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,649 @@
import { useEffect, useState } from 'react';
import { useStore } from '../store';
import { api, RemoteSkillItem } from '../api';
// 社区知名 Skills 源快选列表
const COMMUNITY_SOURCES = [
{
label: 'obra/superpowers',
emoji: '⚡',
stars: '66.9k',
desc: '完整开发工作流战法集',
skills: [
{ name: 'brainstorming', url: 'https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/skills/brainstorming/SKILL.md' },
{ name: 'test-driven-development', url: 'https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/skills/test-driven-development/SKILL.md' },
{ name: 'systematic-debugging', url: 'https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/skills/systematic-debugging/SKILL.md' },
{ name: 'subagent-driven-development', url: 'https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/skills/subagent-driven-development/SKILL.md' },
{ name: 'writing-plans', url: 'https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/skills/writing-plans/SKILL.md' },
{ name: 'executing-plans', url: 'https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/skills/executing-plans/SKILL.md' },
{ name: 'requesting-code-review', url: 'https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/skills/requesting-code-review/SKILL.md' },
{ name: 'root-cause-tracing', url: 'https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/skills/root-cause-tracing/SKILL.md' },
{ name: 'verification-before-completion', url: 'https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/skills/verification-before-completion/SKILL.md' },
{ name: 'dispatching-parallel-agents', url: 'https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/skills/dispatching-parallel-agents/SKILL.md' },
],
},
{
label: 'anthropics/skills',
emoji: '🏛️',
stars: '官方',
desc: 'Anthropic 官方战法库',
skills: [
{ name: 'docx', url: 'https://raw.githubusercontent.com/anthropics/skills/main/skills/docx/SKILL.md' },
{ name: 'pdf', url: 'https://raw.githubusercontent.com/anthropics/skills/main/skills/pdf/SKILL.md' },
{ name: 'xlsx', url: 'https://raw.githubusercontent.com/anthropics/skills/main/skills/xlsx/SKILL.md' },
{ name: 'pptx', url: 'https://raw.githubusercontent.com/anthropics/skills/main/skills/pptx/SKILL.md' },
{ name: 'mcp-builder', url: 'https://raw.githubusercontent.com/anthropics/skills/main/skills/mcp-builder/SKILL.md' },
{ name: 'frontend-design', url: 'https://raw.githubusercontent.com/anthropics/skills/main/skills/frontend-design/SKILL.md' },
{ name: 'web-artifacts-builder', url: 'https://raw.githubusercontent.com/anthropics/skills/main/skills/web-artifacts-builder/SKILL.md' },
{ name: 'webapp-testing', url: 'https://raw.githubusercontent.com/anthropics/skills/main/skills/webapp-testing/SKILL.md' },
{ name: 'algorithmic-art', url: 'https://raw.githubusercontent.com/anthropics/skills/main/skills/algorithmic-art/SKILL.md' },
{ name: 'canvas-design', url: 'https://raw.githubusercontent.com/anthropics/skills/main/skills/canvas-design/SKILL.md' },
],
},
{
label: 'ComposioHQ/awesome-claude-skills',
emoji: '🌐',
stars: '39.2k',
desc: '100+ 武林秘籍',
skills: [
{ name: 'github-integration', url: 'https://raw.githubusercontent.com/ComposioHQ/awesome-claude-skills/master/github-integration/SKILL.md' },
{ name: 'data-analysis', url: 'https://raw.githubusercontent.com/ComposioHQ/awesome-claude-skills/master/data-analysis/SKILL.md' },
{ name: 'code-review', url: 'https://raw.githubusercontent.com/ComposioHQ/awesome-claude-skills/master/code-review/SKILL.md' },
],
},
];
export default function SkillsConfig() {
const agentConfig = useStore((s) => s.agentConfig);
const loadAgentConfig = useStore((s) => s.loadAgentConfig);
const toast = useStore((s) => s.toast);
// 自家战法状态
const [skillModal, setSkillModal] = useState<{ agentId: string; name: string; content: string; path: string } | null>(null);
const [addForm, setAddForm] = useState<{ agentId: string; agentLabel: string } | null>(null);
const [formData, setFormData] = useState({ name: '', desc: '', trigger: '' });
const [submitting, setSubmitting] = useState(false);
// 主 Tab 切换
const [activeTab, setActiveTab] = useState<'local' | 'remote'>('local');
// 江湖战法状态
const [remoteSkills, setRemoteSkills] = useState<RemoteSkillItem[]>([]);
const [remoteLoading, setRemoteLoading] = useState(false);
const [addRemoteForm, setAddRemoteForm] = useState(false);
const [remoteFormData, setRemoteFormData] = useState({ agentId: '', skillName: '', sourceUrl: '', description: '' });
const [remoteSubmitting, setRemoteSubmitting] = useState(false);
const [updatingSkill, setUpdatingSkill] = useState<string | null>(null);
const [removingSkill, setRemovingSkill] = useState<string | null>(null);
const [quickPickSource, setQuickPickSource] = useState<(typeof COMMUNITY_SOURCES)[0] | null>(null);
const [quickPickAgent, setQuickPickAgent] = useState('');
useEffect(() => {
loadAgentConfig();
}, [loadAgentConfig]);
useEffect(() => {
if (activeTab === 'remote') loadRemoteSkills();
}, [activeTab]);
const loadRemoteSkills = async () => {
setRemoteLoading(true);
try {
const r = await api.remoteSkillsList();
if (r.ok) setRemoteSkills(r.remoteSkills || []);
} catch {
toast('江湖战法列表加载失败', 'err');
}
setRemoteLoading(false);
};
const openSkill = async (agentId: string, skillName: string) => {
setSkillModal({ agentId, name: skillName, content: '⟳ 加载中…', path: '' });
try {
const r = await api.skillContent(agentId, skillName);
if (r.ok) {
setSkillModal({ agentId, name: skillName, content: r.content || '', path: r.path || '' });
} else {
setSkillModal({ agentId, name: skillName, content: '❌ ' + (r.error || '无法读取'), path: '' });
}
} catch {
setSkillModal({ agentId, name: skillName, content: '❌ 服务器连接失败', path: '' });
}
};
const openAddForm = (agentId: string, agentLabel: string) => {
setAddForm({ agentId, agentLabel });
setFormData({ name: '', desc: '', trigger: '' });
};
const submitAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!addForm || !formData.name) return;
setSubmitting(true);
try {
const r = await api.addSkill(addForm.agentId, formData.name, formData.desc, formData.trigger);
if (r.ok) {
toast(`✅ 战法 ${formData.name} 已修炼到 ${addForm.agentLabel}`, 'ok');
setAddForm(null);
loadAgentConfig();
} else {
toast(r.error || '添加失败', 'err');
}
} catch {
toast('服务器连接失败', 'err');
}
setSubmitting(false);
};
const submitAddRemote = async (e: React.FormEvent) => {
e.preventDefault();
const { agentId, skillName, sourceUrl, description } = remoteFormData;
if (!agentId || !skillName || !sourceUrl) return;
setRemoteSubmitting(true);
try {
const r = await api.addRemoteSkill(agentId, skillName, sourceUrl, description);
if (r.ok) {
toast(`✅ 江湖战法 ${skillName} 已添加到 ${agentId}`, 'ok');
setAddRemoteForm(false);
setRemoteFormData({ agentId: '', skillName: '', sourceUrl: '', description: '' });
loadRemoteSkills();
loadAgentConfig();
} else {
toast(r.error || '添加失败', 'err');
}
} catch {
toast('服务器连接失败', 'err');
}
setRemoteSubmitting(false);
};
const handleUpdate = async (skill: RemoteSkillItem) => {
const key = `${skill.agentId}/${skill.skillName}`;
setUpdatingSkill(key);
try {
const r = await api.updateRemoteSkill(skill.agentId, skill.skillName);
if (r.ok) {
toast(`✅ 战法 ${skill.skillName} 已精进`, 'ok');
loadRemoteSkills();
} else {
toast(r.error || '更新失败', 'err');
}
} catch {
toast('服务器连接失败', 'err');
}
setUpdatingSkill(null);
};
const handleRemove = async (skill: RemoteSkillItem) => {
const key = `${skill.agentId}/${skill.skillName}`;
setRemovingSkill(key);
try {
const r = await api.removeRemoteSkill(skill.agentId, skill.skillName);
if (r.ok) {
toast(`🗑️ 战法 ${skill.skillName} 已废弃`, 'ok');
loadRemoteSkills();
loadAgentConfig();
} else {
toast(r.error || '移除失败', 'err');
}
} catch {
toast('服务器连接失败', 'err');
}
setRemovingSkill(null);
};
const handleQuickImport = async (skillUrl: string, skillName: string) => {
if (!quickPickAgent) { toast('请先选择目标 Agent', 'err'); return; }
try {
const r = await api.addRemoteSkill(quickPickAgent, skillName, skillUrl, '');
if (r.ok) {
toast(`${skillName}${quickPickAgent}`, 'ok');
loadRemoteSkills();
loadAgentConfig();
} else {
toast(r.error || '导入失败', 'err');
}
} catch {
toast('服务器连接失败', 'err');
}
};
if (!agentConfig?.agents) {
return <div className="empty"></div>;
}
// ── 自家战法面板 ──
const localPanel = (
<div>
<div className="skills-grid">
{agentConfig.agents.map((ag) => (
<div className="sk-card" key={ag.id}>
<div className="sk-hdr">
<span className="sk-emoji">{ag.emoji || '🏛️'}</span>
<span className="sk-name">{ag.label}</span>
<span className="sk-cnt">{(ag.skills || []).length} </span>
</div>
<div className="sk-list">
{!(ag.skills || []).length ? (
<div className="sk-empty"> Skills</div>
) : (
(ag.skills || []).map((sk) => (
<div className="sk-item" key={sk.name} onClick={() => openSkill(ag.id, sk.name)}>
<span className="si-name">📦 {sk.name}</span>
<span className="si-desc">{sk.description || '无描述'}</span>
<span className="si-arrow"></span>
</div>
))
)}
</div>
<div className="sk-add" onClick={() => openAddForm(ag.id, ag.label)}>
</div>
</div>
))}
</div>
</div>
);
// ── 江湖战法面板 ──
const remotePanel = (
<div>
{/* 操作栏 */}
<div style={{ display: 'flex', gap: 10, marginBottom: 20, flexWrap: 'wrap', alignItems: 'center' }}>
<button
style={{ padding: '8px 18px', background: 'var(--acc)', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 600, fontSize: 13 }}
onClick={() => { setAddRemoteForm(true); setQuickPickSource(null); }}
>
</button>
<button
style={{ padding: '8px 14px', background: 'transparent', color: 'var(--acc)', border: '1px solid var(--acc)', borderRadius: 8, cursor: 'pointer', fontSize: 12 }}
onClick={loadRemoteSkills}
>
</button>
<span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 4 }}>
{remoteSkills.length}
</span>
</div>
{/* 武林秘籍区 */}
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--muted)', letterSpacing: '.06em', marginBottom: 10 }}>
🌐
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{COMMUNITY_SOURCES.map((src) => (
<div
key={src.label}
onClick={() => setQuickPickSource(quickPickSource?.label === src.label ? null : src)}
style={{
padding: '8px 14px',
background: quickPickSource?.label === src.label ? '#0d1f45' : 'var(--panel)',
border: `1px solid ${quickPickSource?.label === src.label ? 'var(--acc)' : 'var(--line)'}`,
borderRadius: 10,
cursor: 'pointer',
fontSize: 12,
transition: 'all .15s',
}}
>
<span style={{ marginRight: 6 }}>{src.emoji}</span>
<b style={{ color: 'var(--text)' }}>{src.label}</b>
<span style={{ marginLeft: 6, color: '#f0b429', fontSize: 11 }}> {src.stars}</span>
<span style={{ marginLeft: 8, color: 'var(--muted)' }}>{src.desc}</span>
</div>
))}
</div>
{quickPickSource && (
<div style={{ marginTop: 14, background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 12, padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
<span style={{ fontSize: 12, fontWeight: 600 }}> Agent</span>
<select
value={quickPickAgent}
onChange={(e) => setQuickPickAgent(e.target.value)}
style={{ padding: '6px 10px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 6, color: 'var(--text)', fontSize: 12 }}
>
<option value=""> Agent </option>
{agentConfig.agents.map((ag) => (
<option key={ag.id} value={ag.id}>{ag.emoji} {ag.label} ({ag.id})</option>
))}
</select>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 8 }}>
{quickPickSource.skills.map((sk) => {
const alreadyAdded = remoteSkills.some((r) => r.skillName === sk.name && r.agentId === quickPickAgent);
return (
<div
key={sk.name}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '8px 12px', background: 'var(--panel2)', borderRadius: 8,
border: '1px solid var(--line)',
}}
>
<div>
<div style={{ fontSize: 12, fontWeight: 600 }}>📦 {sk.name}</div>
<div style={{ fontSize: 10, color: 'var(--muted)', wordBreak: 'break-all', maxWidth: 180 }}>{sk.url.split('/').slice(-2).join('/')}</div>
</div>
{alreadyAdded ? (
<span style={{ fontSize: 10, color: '#4caf88', fontWeight: 600 }}> </span>
) : (
<button
onClick={() => handleQuickImport(sk.url, sk.name)}
style={{ padding: '4px 10px', background: 'var(--acc)', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: 11, whiteSpace: 'nowrap' }}
>
</button>
)}
</div>
);
})}
</div>
</div>
)}
</div>
{/* 已添加的江湖战法列表 */}
{remoteLoading ? (
<div style={{ textAlign: 'center', padding: '40px 0', color: 'var(--muted)', fontSize: 13 }}> </div>
) : remoteSkills.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', background: 'var(--panel)', borderRadius: 12, border: '1px dashed var(--line)' }}>
<div style={{ fontSize: 32, marginBottom: 10 }}>🌐</div>
<div style={{ fontSize: 14, color: 'var(--muted)' }}></div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 6 }}> URL</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{remoteSkills.map((sk) => {
const key = `${sk.agentId}/${sk.skillName}`;
const isUpdating = updatingSkill === key;
const isRemoving = removingSkill === key;
const agInfo = agentConfig.agents.find((a) => a.id === sk.agentId);
return (
<div
key={key}
style={{
background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 12, padding: '14px 18px',
display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'center',
}}
>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 6 }}>
<span style={{ fontSize: 14, fontWeight: 700 }}>📦 {sk.skillName}</span>
<span style={{
fontSize: 10, padding: '2px 8px', borderRadius: 999,
background: sk.status === 'valid' ? '#0d3322' : '#3d1111',
color: sk.status === 'valid' ? '#4caf88' : '#ff5270',
fontWeight: 600,
}}>
{sk.status === 'valid' ? '✓ 有效' : '✗ 文件丢失'}
</span>
<span style={{ fontSize: 11, color: 'var(--muted)', background: 'var(--panel2)', padding: '2px 8px', borderRadius: 6 }}>
{agInfo?.emoji} {agInfo?.label || sk.agentId}
</span>
</div>
{sk.description && (
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 4 }}>{sk.description}</div>
)}
<div style={{ fontSize: 10, color: 'var(--muted)', display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<span>🔗 <a href={sk.sourceUrl} target="_blank" rel="noreferrer" style={{ color: 'var(--acc)', textDecoration: 'none' }}>{sk.sourceUrl.length > 60 ? sk.sourceUrl.slice(0, 60) + '…' : sk.sourceUrl}</a></span>
<span>📅 {sk.lastUpdated ? sk.lastUpdated.slice(0, 10) : sk.addedAt?.slice(0, 10)}</span>
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => openSkill(sk.agentId, sk.skillName)}
style={{ padding: '6px 12px', background: 'transparent', color: 'var(--muted)', border: '1px solid var(--line)', borderRadius: 6, cursor: 'pointer', fontSize: 11 }}
>
</button>
<button
onClick={() => handleUpdate(sk)}
disabled={isUpdating}
style={{ padding: '6px 12px', background: 'transparent', color: 'var(--acc)', border: '1px solid var(--acc)', borderRadius: 6, cursor: 'pointer', fontSize: 11 }}
>
{isUpdating ? '⟳' : '更新'}
</button>
<button
onClick={() => handleRemove(sk)}
disabled={isRemoving}
style={{ padding: '6px 12px', background: 'transparent', color: '#ff5270', border: '1px solid #ff5270', borderRadius: 6, cursor: 'pointer', fontSize: 11 }}
>
{isRemoving ? '⟳' : '删除'}
</button>
</div>
</div>
);
})}
</div>
)}
</div>
);
return (
<div>
{/* 主 Tab 切换 */}
<div style={{ display: 'flex', gap: 4, marginBottom: 20, borderBottom: '1px solid var(--line)', paddingBottom: 0 }}>
{[
{ key: 'local', label: '🏛️ 自家战法', count: agentConfig.agents.reduce((n, a) => n + (a.skills?.length || 0), 0) },
{ key: 'remote', label: '🌐 江湖战法', count: remoteSkills.length },
].map((t) => (
<div
key={t.key}
onClick={() => setActiveTab(t.key as 'local' | 'remote')}
style={{
padding: '8px 18px', cursor: 'pointer', fontSize: 13, borderRadius: '8px 8px 0 0',
fontWeight: activeTab === t.key ? 700 : 400,
background: activeTab === t.key ? 'var(--panel)' : 'transparent',
color: activeTab === t.key ? 'var(--text)' : 'var(--muted)',
border: activeTab === t.key ? '1px solid var(--line)' : '1px solid transparent',
borderBottom: activeTab === t.key ? '1px solid var(--panel)' : '1px solid transparent',
position: 'relative', bottom: -1,
transition: 'all .15s',
}}
>
{t.label}
{t.count > 0 && (
<span style={{ marginLeft: 6, fontSize: 10, padding: '1px 6px', borderRadius: 999, background: '#1a2040', color: 'var(--acc)' }}>
{t.count}
</span>
)}
</div>
))}
</div>
{activeTab === 'local' ? localPanel : remotePanel}
{/* Skill Content Modal */}
{skillModal && (
<div className="modal-bg open" onClick={() => setSkillModal(null)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={() => setSkillModal(null)}></button>
<div className="modal-body">
<div style={{ fontSize: 11, color: 'var(--acc)', fontWeight: 700, letterSpacing: '.04em', marginBottom: 4 }}>
{skillModal.agentId.toUpperCase()}
</div>
<div style={{ fontSize: 20, fontWeight: 800, marginBottom: 16 }}>📦 {skillModal.name}</div>
<div className="sk-modal-body">
<div className="sk-md" style={{ whiteSpace: 'pre-wrap', fontSize: 12, lineHeight: 1.7 }}>
{skillModal.content}
</div>
{skillModal.path && (
<div className="sk-path" style={{ fontSize: 10, color: 'var(--muted)', marginTop: 12 }}>
📂 {skillModal.path}
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* 本地 Add Skill Form Modal */}
{addForm && (
<div className="modal-bg open" onClick={() => setAddForm(null)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={() => setAddForm(null)}></button>
<div className="modal-body">
<div style={{ fontSize: 11, color: 'var(--acc)', fontWeight: 700, letterSpacing: '.04em', marginBottom: 4 }}>
{addForm.agentLabel}
</div>
<div style={{ fontSize: 20, fontWeight: 800, marginBottom: 18 }}> Skill</div>
<div
style={{
background: 'var(--panel2)',
border: '1px solid var(--line)',
borderRadius: 10,
padding: 14,
marginBottom: 18,
fontSize: 12,
lineHeight: 1.7,
color: 'var(--muted)',
}}
>
<b style={{ color: 'var(--text)' }}>📋 Skill </b>
<br />
使<b style={{ color: 'var(--text)' }}> + </b>
<br />
SKILL.md
<br />
<b style={{ color: 'var(--text)' }}></b>
</div>
<form onSubmit={submitAdd} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 6 }}>
<span style={{ color: '#ff5270' }}>*</span>
</label>
<input
type="text"
required
placeholder="如 data-analysis, code-review"
value={formData.name}
onChange={(e) =>
setFormData((p) => ({ ...p, name: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '') }))
}
style={{ width: '100%', padding: '10px 12px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 8, color: 'var(--text)', fontSize: 13, outline: 'none' }}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 6 }}></label>
<input
type="text"
placeholder="一句话说明用途"
value={formData.desc}
onChange={(e) => setFormData((p) => ({ ...p, desc: e.target.value }))}
style={{ width: '100%', padding: '10px 12px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 8, color: 'var(--text)', fontSize: 13, outline: 'none' }}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 6 }}></label>
<input
type="text"
placeholder="何时触发此战法"
value={formData.trigger}
onChange={(e) => setFormData((p) => ({ ...p, trigger: e.target.value }))}
style={{ width: '100%', padding: '10px 12px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 8, color: 'var(--text)', fontSize: 13, outline: 'none' }}
/>
</div>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 4 }}>
<button type="button" className="btn btn-g" onClick={() => setAddForm(null)} style={{ padding: '8px 20px' }}>
</button>
<button
type="submit"
disabled={submitting}
style={{ padding: '8px 20px', fontSize: 13, background: 'var(--acc)', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 600 }}
>
{submitting ? '⟳ 修炼中…' : '📦 创建战法'}
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* 远程 Add Remote Skill Modal */}
{addRemoteForm && (
<div className="modal-bg open" onClick={() => setAddRemoteForm(false)}>
<div className="modal" style={{ maxWidth: 520 }} onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={() => setAddRemoteForm(false)}></button>
<div className="modal-body">
<div style={{ fontSize: 11, color: '#a07aff', fontWeight: 700, letterSpacing: '.04em', marginBottom: 4 }}>
</div>
<div style={{ fontSize: 20, fontWeight: 800, marginBottom: 18 }}>🌐 </div>
<div style={{ background: 'var(--panel2)', border: '1px solid var(--line)', borderRadius: 10, padding: 12, marginBottom: 18, fontSize: 11, color: 'var(--muted)', lineHeight: 1.7 }}>
GitHub Raw URL<br />
<code style={{ color: 'var(--acc)', fontSize: 10 }}>https://raw.githubusercontent.com/obra/superpowers/refs/heads/main/skills/brainstorming/SKILL.md</code>
</div>
<form onSubmit={submitAddRemote} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 6 }}> Agent <span style={{ color: '#ff5270' }}>*</span></label>
<select
required
value={remoteFormData.agentId}
onChange={(e) => setRemoteFormData((p) => ({ ...p, agentId: e.target.value }))}
style={{ width: '100%', padding: '10px 12px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 8, color: 'var(--text)', fontSize: 13 }}
>
<option value=""> Agent </option>
{agentConfig.agents.map((ag) => (
<option key={ag.id} value={ag.id}>{ag.emoji} {ag.label} ({ag.id})</option>
))}
</select>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 6 }}> <span style={{ color: '#ff5270' }}>*</span></label>
<input
type="text"
required
placeholder="如 brainstorming, code-review"
value={remoteFormData.skillName}
onChange={(e) => setRemoteFormData((p) => ({ ...p, skillName: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '') }))}
style={{ width: '100%', padding: '10px 12px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 8, color: 'var(--text)', fontSize: 13, outline: 'none' }}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 6 }}> URL <span style={{ color: '#ff5270' }}>*</span></label>
<input
type="url"
required
placeholder="https://raw.githubusercontent.com/..."
value={remoteFormData.sourceUrl}
onChange={(e) => setRemoteFormData((p) => ({ ...p, sourceUrl: e.target.value }))}
style={{ width: '100%', padding: '10px 12px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 8, color: 'var(--text)', fontSize: 12, outline: 'none' }}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 6 }}></label>
<input
type="text"
placeholder="一句话说明用途"
value={remoteFormData.description}
onChange={(e) => setRemoteFormData((p) => ({ ...p, description: e.target.value }))}
style={{ width: '100%', padding: '10px 12px', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 8, color: 'var(--text)', fontSize: 13, outline: 'none' }}
/>
</div>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 4 }}>
<button type="button" className="btn btn-g" onClick={() => setAddRemoteForm(false)} style={{ padding: '8px 20px' }}></button>
<button
type="submit"
disabled={remoteSubmitting}
style={{ padding: '8px 20px', fontSize: 13, background: '#a07aff', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 600 }}
>
{remoteSubmitting ? '⟳ 下载中…' : '🌐 添加江湖战法'}
</button>
</div>
</form>
</div>
</div>
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,232 @@
import { useState } from 'react';
import { useStore, TEMPLATES, TPL_CATS } from '../store';
import type { Template } from '../store';
import { api } from '../api';
export default function TemplatePanel() {
const tplCatFilter = useStore((s) => s.tplCatFilter);
const setTplCatFilter = useStore((s) => s.setTplCatFilter);
const toast = useStore((s) => s.toast);
const loadAll = useStore((s) => s.loadAll);
const [formTpl, setFormTpl] = useState<Template | null>(null);
const [formVals, setFormVals] = useState<Record<string, string>>({});
const [previewCmd, setPreviewCmd] = useState('');
let tpls = TEMPLATES;
if (tplCatFilter !== '全部') tpls = tpls.filter((t) => t.cat === tplCatFilter);
const openForm = (tpl: Template) => {
const vals: Record<string, string> = {};
tpl.params.forEach((p) => {
vals[p.key] = p.default || '';
});
setFormVals(vals);
setFormTpl(tpl);
setPreviewCmd('');
};
const buildCmd = (tpl: Template) => {
let cmd = tpl.command;
for (const p of tpl.params) {
cmd = cmd.replace(new RegExp('\\{' + p.key + '\\}', 'g'), formVals[p.key] || p.default || '');
}
return cmd;
};
const preview = () => {
if (!formTpl) return;
setPreviewCmd(buildCmd(formTpl));
};
const execute = async (e: React.FormEvent) => {
e.preventDefault();
if (!formTpl) return;
const cmd = buildCmd(formTpl);
if (!cmd.trim()) {
toast('请填写必填参数', 'err');
return;
}
// Pre-check (skip in moziplus - Gateway check not applicable)
if (!confirm(`确认创建任务?\n\n${cmd.substring(0, 200)}${cmd.length > 200 ? '…' : ''}`)) return;
try {
const params: Record<string, string> = {};
for (const p of formTpl.params) {
params[p.key] = formVals[p.key] || p.default || '';
}
const r = await api.createTask({
title: `${formTpl.name}: ${Object.values(params).join(' ').substring(0, 80)}`,
requirement: cmd,
project_root: '/tmp',
project_type: 'general',
templateId: formTpl.id,
params,
});
const rAny = r as unknown as Record<string, unknown>;
if (r.ok || r.taskId || rAny.id) {
const tid = r.taskId || String(rAny.id || '');
toast(`📜 ${tid} 任务已创建`, 'ok');
setFormTpl(null);
loadAll();
} else {
toast(r.error || '创建任务失败', 'err');
}
} catch {
toast('⚠️ 服务器连接失败', 'err');
}
};
return (
<div>
{/* Category filter */}
<div style={{ display: 'flex', gap: 6, marginBottom: 16, flexWrap: 'wrap' }}>
{TPL_CATS.map((c) => (
<span
key={c.name}
className={`tpl-cat${tplCatFilter === c.name ? ' active' : ''}`}
onClick={() => setTplCatFilter(c.name)}
>
{c.icon} {c.name}
</span>
))}
</div>
{/* Grid */}
<div className="tpl-grid">
{tpls.map((t) => (
<div className="tpl-card" key={t.id}>
<div className="tpl-top">
<span className="tpl-icon">{t.icon}</span>
<span className="tpl-name">{t.name}</span>
</div>
<div className="tpl-desc">{t.desc}</div>
<div className="tpl-footer">
{t.depts.map((d) => (
<span className="tpl-dept" key={d}>{d}</span>
))}
<span className="tpl-est">
{t.est} · {t.cost}
</span>
<button className="tpl-go" onClick={() => openForm(t)}>
</button>
</div>
</div>
))}
</div>
{/* Template Form Modal */}
{formTpl && (
<div className="modal-bg open" onClick={() => setFormTpl(null)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={() => setFormTpl(null)}></button>
<div className="modal-body">
<div style={{ fontSize: 11, color: 'var(--acc)', fontWeight: 700, letterSpacing: '.04em', marginBottom: 4 }}>
</div>
<div style={{ fontSize: 20, fontWeight: 800, marginBottom: 6 }}>
{formTpl.icon} {formTpl.name}
</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 18 }}>{formTpl.desc}</div>
<div style={{ display: 'flex', gap: 6, marginBottom: 18, flexWrap: 'wrap' }}>
{formTpl.depts.map((d) => (
<span className="tpl-dept" key={d}>{d}</span>
))}
<span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 'auto' }}>
{formTpl.est} · {formTpl.cost}
</span>
</div>
<form className="tpl-form" onSubmit={execute}>
{formTpl.params.map((p) => (
<div className="tpl-field" key={p.key}>
<label className="tpl-label">
{p.label}
{p.required && <span style={{ color: '#ff5270' }}> *</span>}
</label>
{p.type === 'textarea' ? (
<textarea
className="tpl-input"
style={{ minHeight: 80, resize: 'vertical' }}
required={p.required}
value={formVals[p.key] || ''}
onChange={(e) => setFormVals((v) => ({ ...v, [p.key]: e.target.value }))}
/>
) : p.type === 'select' ? (
<select
className="tpl-input"
value={formVals[p.key] || p.default || ''}
onChange={(e) => setFormVals((v) => ({ ...v, [p.key]: e.target.value }))}
>
{(p.options || []).map((o) => (
<option key={o}>{o}</option>
))}
</select>
) : (
<input
className="tpl-input"
type="text"
required={p.required}
value={formVals[p.key] || ''}
onChange={(e) => setFormVals((v) => ({ ...v, [p.key]: e.target.value }))}
/>
)}
</div>
))}
{previewCmd && (
<div
style={{
background: 'var(--panel2)',
border: '1px solid var(--line)',
borderRadius: 8,
padding: 12,
marginBottom: 14,
fontSize: 12,
color: 'var(--muted)',
}}
>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text)', marginBottom: 6 }}>
📜
</div>
<div style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>{previewCmd}</div>
</div>
)}
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button type="button" className="btn btn-g" onClick={preview} style={{ padding: '8px 16px', fontSize: 12 }}>
👁
</button>
<button type="submit" className="tpl-go" style={{ padding: '8px 20px', fontSize: 13 }}>
📜
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* Self-create Template */}
<div style={{ marginTop: 16, textAlign: 'center' }}>
<button
className="btn btn-g"
style={{ fontSize: 12, padding: '6px 16px' }}
onClick={() => {
const name = prompt('战法名称:');
if (!name) return;
const desc = prompt('战法描述:') || '';
const cmd = prompt('命令模板(用 {参数名} 做占位符):') || '';
const customs = JSON.parse(localStorage.getItem('customTemplates') || '[]');
customs.push({ id: `custom-${Date.now()}`, name, desc, command: cmd, icon: '✏️', cat: '自创', est: '自定义', cost: '自定义', depts: ['庞统'], params: [] });
localStorage.setItem('customTemplates', JSON.stringify(customs));
toast(`✏️ 自创战法「${name}」已保存`, 'ok');
}}
>
</button>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { useStore } from '../store';
export default function Toaster() {
const toasts = useStore((s) => s.toasts);
if (!toasts.length) return null;
return (
<div className="toaster">
{toasts.map((t) => (
<div key={t.id} className={`toast ${t.type}`}>
{t.msg}
</div>
))}
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
/**
* 军费总览 — Token 消耗归因、上下文压力、预算状态
* 三国术语:军费/各营消耗/兵器消耗/军令消耗/粮草告急
*/
import { useStore } from '../store';
import type { OfficialInfo } from '../api';
export default function UsagePanel() {
const officialsData = useStore((s) => s.officialsData);
// 从真实数据构建归因,fallback 到空数组
const officials = officialsData?.officials || [];
const totals = officialsData?.totals || { tasks_done: 0, cost_cny: 0 };
// 各营消耗(按 Agent
const byAgent: Array<{ name: string; emoji: string; tokens: number; cost: number; pct: number }> = [];
const totalTokens = officials.reduce((s, o) => s + o.tokens_in + o.tokens_out + o.cache_read + o.cache_write, 0) || 1;
for (const o of officials) {
const tk = o.tokens_in + o.tokens_out + o.cache_read + o.cache_write;
byAgent.push({
name: o.role.split(' ')[0],
emoji: o.emoji,
tokens: tk,
cost: o.cost_cny,
pct: Math.round((tk / totalTokens) * 100),
});
}
byAgent.sort((a, b) => b.tokens - a.tokens);
// 兵器消耗(按模型)
const modelMap = new Map<string, { tokens: number; cost: number }>();
for (const o of officials) {
const key = o.model_short || o.model;
const tk = o.tokens_in + o.tokens_out + o.cache_read + o.cache_write;
const existing = modelMap.get(key) || { tokens: 0, cost: 0 };
existing.tokens += tk;
existing.cost += o.cost_cny;
modelMap.set(key, existing);
}
const byModel = Array.from(modelMap.entries()).map(([name, data]) => ({
name,
...data,
pct: Math.round((data.tokens / totalTokens) * 100),
})).sort((a, b) => b.tokens - a.tokens);
// 军令消耗(参与军令 top 5)
const taskMap = new Map<string, { title: string; tokens: number; cost: number }>();
for (const o of officials) {
for (const e of o.participated_edicts) {
const existing = taskMap.get(e.id) || { title: e.title, tokens: 0, cost: 0 };
existing.tokens += o.tokens_in + o.tokens_out;
existing.cost += o.cost_cny / Math.max(o.participated_edicts.length, 1);
taskMap.set(e.id, existing);
}
}
const byTask = Array.from(taskMap.values())
.sort((a, b) => b.cost - a.cost)
.slice(0, 5);
// 粮草告急(上下文压力)
const contextWindows = officials.map((o: OfficialInfo) => {
const used = (o.tokens_in + o.tokens_out) / 2000; // 粗估 k tokens
const max = 200; // 200k context
const pct = Math.round((used / max) * 100);
const status = pct > 80 ? 'high' : pct > 50 ? 'warn' : 'ok';
return { agent: o.role.split(' ')[0], emoji: o.emoji, used: Math.round(used), max, pct, status };
}).sort((a, b) => b.pct - a.pct);
const totalCost = byAgent.reduce((s, a) => s + a.cost, 0);
const highPressure = contextWindows.filter((cw) => cw.status === 'high').length;
return (
<div>
{/* 军费摘要卡片 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 10, marginBottom: 24 }}>
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 14, padding: 18, textAlign: 'center' }}>
<div style={{ fontSize: 28, fontWeight: 800, color: 'var(--acc)' }}>{(totalTokens / 1000000).toFixed(1)}M</div>
<div style={{ fontSize: 11, color: 'var(--muted)' }}> Token</div>
</div>
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 14, padding: 18, textAlign: 'center' }}>
<div style={{ fontSize: 28, fontWeight: 800, color: '#f5c842' }}>¥{totalCost.toFixed(1)}</div>
<div style={{ fontSize: 11, color: 'var(--muted)' }}></div>
</div>
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 14, padding: 18, textAlign: 'center' }}>
<div style={{ fontSize: 28, fontWeight: 800, color: 'var(--ok)' }}>{totals.tasks_done || 0}</div>
<div style={{ fontSize: 11, color: 'var(--muted)' }}></div>
</div>
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 14, padding: 18, textAlign: 'center' }}>
<div style={{ fontSize: 28, fontWeight: 800, color: highPressure > 0 ? 'var(--danger)' : 'var(--ok)' }}>{highPressure}</div>
<div style={{ fontSize: 11, color: 'var(--muted)' }}></div>
</div>
</div>
{/* 各营消耗 */}
<div style={{ fontWeight: 700, color: 'var(--acc)', marginBottom: 10, fontSize: 14, borderBottom: '1px solid var(--line)', paddingBottom: 6 }}>
</div>
{byAgent.map((a) => (
<div key={a.name} style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<span style={{ width: 24 }}>{a.emoji}</span>
<span style={{ width: 50, fontSize: 12 }}>{a.name}</span>
<div style={{ flex: 1, height: 10, background: '#0e1320', borderRadius: 5, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${a.pct}%`, background: 'linear-gradient(90deg, var(--acc), #a07aff)', borderRadius: 5, transition: 'width .3s' }} />
</div>
<span style={{ fontSize: 11, color: 'var(--muted)', width: 60, textAlign: 'right' }}>{(a.tokens / 1000).toFixed(0)}k</span>
<span style={{ fontSize: 11, color: '#f5c842', width: 50, textAlign: 'right' }}>¥{a.cost.toFixed(1)}</span>
<span style={{ fontSize: 10, color: 'var(--muted)', width: 30, textAlign: 'right' }}>{a.pct}%</span>
</div>
))}
{/* 兵器消耗 */}
<div style={{ fontWeight: 700, color: 'var(--acc)', marginTop: 20, marginBottom: 10, fontSize: 14, borderBottom: '1px solid var(--line)', paddingBottom: 6 }}>
🗡
</div>
{byModel.map((m) => (
<div key={m.name} style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<span style={{ fontSize: 12, width: 160, color: 'var(--text)' }}>{m.name}</span>
<div style={{ flex: 1, height: 10, background: '#0e1320', borderRadius: 5, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${m.pct}%`, background: 'linear-gradient(90deg, #2ecc8a, var(--acc))', borderRadius: 5 }} />
</div>
<span style={{ fontSize: 11, color: 'var(--muted)', width: 60, textAlign: 'right' }}>{(m.tokens / 1000).toFixed(0)}k</span>
<span style={{ fontSize: 11, color: '#f5c842', width: 50, textAlign: 'right' }}>¥{m.cost.toFixed(1)}</span>
</div>
))}
{/* 军令消耗 Top 5 */}
<div style={{ fontWeight: 700, color: 'var(--acc)', marginTop: 20, marginBottom: 10, fontSize: 14, borderBottom: '1px solid var(--line)', paddingBottom: 6 }}>
📜 5
</div>
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 12 }}>
{byTask.length === 0 ? (
<div style={{ padding: 14, fontSize: 12, color: 'var(--muted)' }}></div>
) : (
byTask.map((t, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 14px', borderBottom: i < byTask.length - 1 ? '1px solid #0e1320' : 'none' }}>
<span style={{ fontSize: 12 }}>{t.title}</span>
<div style={{ display: 'flex', gap: 16 }}>
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{(t.tokens / 1000).toFixed(0)}k tokens</span>
<span style={{ fontSize: 11, color: '#f5c842' }}>¥{t.cost.toFixed(1)}</span>
</div>
</div>
))
)}
</div>
{/* 粮草告急 */}
<div style={{ fontWeight: 700, color: 'var(--acc)', marginTop: 20, marginBottom: 10, fontSize: 14, borderBottom: '1px solid var(--line)', paddingBottom: 6 }}>
🌾
</div>
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 12 }}>
{contextWindows.map((cw, i) => (
<div key={cw.agent} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', borderBottom: i < contextWindows.length - 1 ? '1px solid #0e1320' : 'none' }}>
<span style={{ width: 20 }}>{cw.emoji}</span>
<span style={{ width: 50, fontSize: 12 }}>{cw.agent}</span>
<div style={{ flex: 1, height: 6, background: '#0e1320', borderRadius: 3, overflow: 'hidden' }}>
<div style={{
height: '100%',
width: `${cw.pct}%`,
borderRadius: 3,
background: cw.status === 'high' ? '#ff5270' : cw.status === 'warn' ? '#f5c842' : '#2ecc8a',
}} />
</div>
<span style={{ fontSize: 10, color: cw.status === 'high' ? '#ff5270' : cw.status === 'warn' ? '#f5c842' : 'var(--muted)' }}>
{cw.used}k / {cw.max}k {cw.status === 'high' && '⚠️'}
</span>
</div>
))}
</div>
</div>
);
}
+706
View File
@@ -0,0 +1,706 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ══ 三省六部总控台 · CSS 变量 ══ */
:root {
--bg: #07090f;
--panel: #0f1219;
--panel2: #141824;
--line: #1c2236;
--text: #dde4f8;
--muted: #5a6b92;
--ok: #2ecc8a;
--warn: #f5c842;
--danger: #ff5270;
--acc: #6a9eff;
--acc2: #a07aff;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: "PingFang SC", Inter, -apple-system, "Segoe UI", sans-serif;
min-height: 100vh;
}
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #1e2538; border-radius: 4px; }
/* ══ 布局 ══ */
.wrap { max-width: 1400px; margin: 0 auto; padding: 16px; }
/* ══ HEADER ══ */
.hdr { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
.logo { font-size: 20px; font-weight: 800; background: linear-gradient(135deg, #6a9eff, #a07aff); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.sub-text { font-size: 11px; color: var(--muted); }
.hdr-r { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.chip { font-size: 11px; padding: 3px 9px; border: 1px solid var(--line); border-radius: 999px; background: var(--panel); color: var(--muted); }
.chip.ok { border-color: #2ecc8a44; color: var(--ok); }
.chip.warn { border-color: #f5c84244; color: var(--warn); }
.chip.err { border-color: #ff527044; color: var(--danger); }
.btn-refresh { font-size: 11px; padding: 4px 10px; border: 1px solid var(--acc); border-radius: 6px; background: transparent; color: var(--acc); cursor: pointer; }
.btn-refresh:hover { background: #0a1228; }
/* ══ TABS ══ */
.tabs { display: flex; gap: 2px; margin-bottom: 18px; border-bottom: 1px solid var(--line); overflow-x: auto; }
.tab { font-size: 13px; padding: 8px 16px; border-radius: 8px 8px 0 0; cursor: pointer; color: var(--muted); border: 1px solid transparent; border-bottom: none; white-space: nowrap; position: relative; bottom: -1px; transition: all .15s; user-select: none; }
.tab:hover { color: var(--text); background: var(--panel); }
.tab.active { color: var(--text); background: var(--panel); border-color: var(--line); font-weight: 600; }
.tbadge { font-size: 10px; padding: 1px 5px; border-radius: 999px; background: #1a2040; color: var(--acc); margin-left: 4px; }
/* ══ 旨意看板卡片 ══ */
.edict-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 12px; }
.edict-card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 18px; cursor: pointer; transition: border-color .15s, transform .1s, box-shadow .15s; }
.edict-card:hover { border-color: var(--acc); transform: translateY(-2px); box-shadow: 0 4px 20px rgba(106,158,255,.1); }
.edict-card.archived { opacity: .55; border-style: dashed; }
.edict-card.archived:hover { opacity: .85; }
.ec-pipe { display: flex; align-items: center; gap: 0; margin-bottom: 14px; overflow-x: auto; padding-bottom: 2px; }
.ep-node { display: flex; flex-direction: column; align-items: center; gap: 1px; padding: 5px 8px; border-radius: 6px; flex-shrink: 0; min-width: 52px; }
.ep-node.done { background: #0a2018; }
.ep-node.active { background: #0f1a38; border: 1px solid var(--acc); }
.ep-node.pending { opacity: .3; }
.ep-icon { font-size: 14px; }
.ep-name { font-size: 9px; color: var(--muted); white-space: nowrap; }
.ep-node.done .ep-name { color: var(--ok); }
.ep-node.active .ep-name { color: var(--acc); font-weight: 700; }
.ep-arrow { font-size: 10px; color: #1c2236; padding: 0 1px; flex-shrink: 0; }
.ec-id { font-size: 10px; color: var(--acc); font-weight: 700; letter-spacing: .04em; margin-bottom: 5px; }
.ec-title { font-size: 15px; font-weight: 700; line-height: 1.4; margin-bottom: 10px; color: var(--text); }
.ec-meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 8px; }
.tag { font-size: 10px; padding: 2px 7px; border-radius: 4px; border: 1px solid; display: inline-block; white-space: nowrap; }
/* state colors */
.st-Inbox { border-color: #3a4a7a44; color: #7a9aff; background: #0a1028; }
.st-Taizi { border-color: #e8a04044; color: #e8a040; background: #281a08; }
.st-Zhongshu { border-color: #a07aff44; color: #a07aff; background: #110a28; }
.st-Menxia { border-color: #ff9a6a44; color: #ff9a6a; background: #280f0a; }
.st-Assigned, .st-Doing { border-color: #6a9eff44; color: #6a9eff; background: #0a1428; }
.st-Review { border-color: #f5c84244; color: #f5c842; background: #201a08; }
.st-Done { border-color: #2ecc8a44; color: var(--ok); background: #0a2018; }
.st-Blocked { border-color: #ff527044; color: var(--danger); background: #200a10; }
.st-Cancelled { border-color: #88888844; color: #888; background: #1a1a1a; }
.st-Next { border-color: #4a9adf44; color: #4a9adf; background: #0a1424; }
.st-Pending { border-color: #3a4a7a44; color: #7a9aff; background: #0a1028; }
/* dept colors */
.dt-中书省 { border-color: #a07aff44; color: #a07aff; background: #1a0f38; }
.dt-门下省 { border-color: #6a9eff44; color: #6a9eff; background: #0f1a38; }
.dt-尚书省 { border-color: #6aef9a44; color: #6aef9a; background: #0a2018; }
.dt-礼部 { border-color: #f5c84244; color: #f5c842; background: #201a08; }
.dt-户部 { border-color: #ff9a6a44; color: #ff9a6a; background: #28100a; }
.dt-兵部 { border-color: #ff527044; color: #ff5270; background: #280a10; }
.dt-刑部 { border-color: #cc444444; color: #cc4444; background: #280808; }
.dt-工部 { border-color: #44aaff44; color: #44aaff; background: #081828; }
.ec-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; flex-wrap: wrap; gap: 6px; }
.hb { font-size: 10px; padding: 2px 7px; border-radius: 999px; border: 1px solid var(--line); }
.hb.active { border-color: #2ecc8a44; color: var(--ok); }
.hb.warn { border-color: #f5c84244; color: var(--warn); }
.hb.stalled { border-color: #ff527044; color: var(--danger); animation: pulse 1.5s infinite; }
.hb.unknown { color: var(--muted); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .4; } }
/* ══ TASK DETAIL MODAL ══ */
.modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 100; backdrop-filter: blur(3px); overflow-y: auto; display: flex; align-items: flex-start; justify-content: center; padding: 40px 16px; }
.modal { background: var(--panel); border: 1px solid var(--line); border-radius: 18px; width: 100%; max-width: 760px; padding: 28px; position: relative; box-shadow: 0 20px 60px rgba(0,0,0,.6); }
.modal-close { position: absolute; top: 16px; right: 16px; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 8px; cursor: pointer; font-size: 18px; color: var(--muted); background: none; border: none; }
.modal-close:hover { background: var(--panel2); color: var(--text); }
.modal-id { font-size: 11px; color: var(--acc); font-weight: 700; letter-spacing: .04em; margin-bottom: 6px; }
.modal-title { font-size: 22px; font-weight: 800; line-height: 1.3; margin-bottom: 18px; }
.m-pipe { display: flex; align-items: stretch; gap: 0; overflow-x: auto; padding: 16px; background: var(--panel2); border-radius: 12px; margin-bottom: 20px; }
.mp-stage { display: flex; align-items: center; flex-shrink: 0; }
.mp-node { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 10px 14px; border-radius: 10px; min-width: 80px; position: relative; }
.mp-node.done { background: #0a2018; border: 1px solid #2ecc8a44; }
.mp-node.active { background: #0f1838; border: 2px solid var(--acc); box-shadow: 0 0 14px rgba(106,158,255,.2); }
.mp-node.pending { opacity: .25; border: 1px dashed var(--line); }
.mp-icon { font-size: 22px; }
.mp-dept { font-size: 12px; font-weight: 700; margin-top: 2px; }
.mp-node.done .mp-dept { color: var(--ok); }
.mp-node.active .mp-dept { color: var(--acc); }
.mp-node.pending .mp-dept { color: var(--muted); }
.mp-action { font-size: 10px; color: var(--muted); margin-top: 1px; }
.mp-node.active .mp-action { color: #6a9eff88; }
.mp-done-tick { position: absolute; top: -6px; right: -6px; width: 16px; height: 16px; background: var(--ok); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 9px; color: #000; font-weight: 700; }
.mp-arrow { color: #1c2236; font-size: 18px; padding: 0 6px; margin-top: -10px; }
.cur-stage { display: flex; align-items: center; gap: 10px; padding: 12px 16px; background: #0a1228; border: 1px solid var(--acc); border-radius: 10px; margin-bottom: 18px; }
.cs-icon { font-size: 24px; }
.cs-info .cs-dept { font-size: 16px; font-weight: 700; color: var(--acc); }
.cs-info .cs-action { font-size: 12px; color: var(--muted); margin-top: 2px; }
.cs-hb { margin-left: auto; }
.m-section { margin-bottom: 18px; }
.m-sec-label { font-size: 11px; font-weight: 700; color: var(--muted); letter-spacing: .06em; text-transform: uppercase; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid var(--line); }
.fl-timeline { display: flex; flex-direction: column; gap: 0; position: relative; }
.fl-timeline::before { content: ''; position: absolute; left: 60px; top: 0; bottom: 0; width: 1px; background: var(--line); }
.fl-item { display: flex; gap: 0; position: relative; padding: 8px 0; }
.fl-time { min-width: 60px; font-size: 10px; color: var(--muted); text-align: right; padding-right: 14px; flex-shrink: 0; padding-top: 3px; }
.fl-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; margin-top: 3px; position: relative; z-index: 1; }
.fl-content { padding-left: 12px; flex: 1; }
.fl-who { font-size: 12px; margin-bottom: 2px; }
.fl-who .from { font-weight: 700; }
.fl-who .to { font-weight: 700; }
.fl-rem { font-size: 11px; color: var(--muted); line-height: 1.5; }
.m-rows { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.m-row { background: var(--panel2); border-radius: 8px; padding: 10px 12px; }
.mr-label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; margin-bottom: 3px; }
.mr-val { font-size: 13px; font-weight: 600; word-break: break-all; }
/* ══ 省部调度 ══ */
.duty-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); gap: 12px; }
.duty-card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; transition: border-color .15s; }
.duty-card:hover { border-color: #2e3d6a; }
.duty-card.active-card { border-color: var(--acc); }
.duty-card.blocked-card { border-color: #ff527055; }
.dc-hdr { display: flex; align-items: center; gap: 10px; padding: 12px 16px; background: var(--panel2); border-bottom: 1px solid var(--line); }
.dc-emoji { font-size: 22px; }
.dc-info { flex: 1; }
.dc-name { font-size: 14px; font-weight: 800; }
.dc-role { font-size: 10px; color: var(--muted); }
.dc-status { display: flex; align-items: center; gap: 5px; font-size: 11px; }
.dc-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dc-dot.active { background: var(--ok); }
.dc-dot.busy { background: var(--warn); animation: pulse 1.5s infinite; }
.dc-dot.blocked { background: var(--danger); animation: pulse 1s infinite; }
.dc-dot.idle { background: #2a3a5a; }
.dc-body { padding: 14px 16px; }
.dc-idle { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; padding: 6px 0; }
.dc-task { display: flex; flex-direction: column; gap: 6px; cursor: pointer; padding: 6px; border-radius: 8px; border: 1px solid var(--line); margin-bottom: 6px; }
.dc-task:hover { border-color: var(--acc); }
.dc-task-id { font-size: 10px; color: var(--acc); font-weight: 700; letter-spacing: .04em; }
.dc-task-title { font-size: 14px; font-weight: 700; color: var(--text); line-height: 1.3; }
.dc-task-now { font-size: 12px; color: var(--muted); line-height: 1.5; margin-top: 2px; }
.dc-task-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
.dc-footer { padding: 8px 16px; border-top: 1px solid var(--line); display: flex; align-items: center; gap: 8px; background: var(--panel2); }
.dc-model { font-size: 10px; color: var(--muted); }
.dc-la { font-size: 10px; color: var(--muted); margin-left: auto; }
/* ══ Agent Status Panel ══ */
.as-panel { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 14px 18px; margin-bottom: 16px; }
.as-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.as-title { font-size: 13px; font-weight: 700; }
.as-gw { font-size: 11px; padding: 3px 10px; border-radius: 999px; margin-left: auto; }
.as-gw.ok { background: #0a2018; border: 1px solid #2ecc8a44; color: var(--ok); }
.as-gw.err { background: #200a10; border: 1px solid #ff527044; color: var(--danger); }
.as-gw.warn { background: #201a08; border: 1px solid #f5c84244; color: var(--warn); }
.as-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 8px; }
.as-card { background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 10px; text-align: center; cursor: pointer; transition: border-color .15s, background .15s; position: relative; }
.as-card:hover { border-color: var(--acc); background: #0a1228; }
.as-dot { position: absolute; top: 6px; right: 6px; width: 8px; height: 8px; border-radius: 50%; }
.as-dot.running { background: #2ecc8a; box-shadow: 0 0 6px #2ecc8a88; animation: pulse 1.5s infinite; }
.as-dot.idle { background: #4a5568; }
.as-dot.offline { background: #ff5270; animation: pulse 1.2s infinite; }
.as-dot.unconfigured { background: #6b7280; }
.as-wake-btn { font-size: 10px; padding: 2px 8px; border-radius: 6px; border: 1px solid var(--acc); color: var(--acc); background: transparent; cursor: pointer; margin-top: 6px; transition: background .15s; }
.as-wake-btn:hover { background: var(--acc); color: #fff; }
.as-summary { font-size: 11px; color: var(--muted); display: flex; gap: 12px; margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--line); }
/* ══ Task Actions ══ */
.task-actions { display: flex; gap: 8px; margin-bottom: 18px; flex-wrap: wrap; }
.btn-action { font-size: 12px; padding: 7px 16px; border-radius: 8px; border: none; cursor: pointer; font-weight: 700; transition: all .15s; }
.btn-stop { background: #ff527022; color: #ff5270; border: 1px solid #ff527044; }
.btn-stop:hover { background: #ff527044; }
.btn-cancel-action { background: #88888822; color: #888; border: 1px solid #88888844; }
.btn-cancel-action:hover { background: #88888844; }
.btn-resume { background: #2ecc8a22; color: #2ecc8a; border: 1px solid #2ecc8a44; }
.btn-resume:hover { background: #2ecc8a44; }
/* ══ Scheduler Panel ══ */
.sched-section { margin-bottom: 18px; background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 12px; }
.sched-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
.sched-title { font-size: 11px; font-weight: 700; letter-spacing: .06em; color: var(--acc); }
.sched-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 10px; }
.sched-kpi { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; }
.sched-kpi .k { font-size: 10px; color: var(--muted); margin-bottom: 2px; }
.sched-kpi .v { font-size: 13px; font-weight: 700; }
.sched-btn { font-size: 11px; padding: 5px 10px; border-radius: 6px; border: 1px solid var(--line); background: transparent; color: var(--muted); cursor: pointer; transition: all .15s; }
.sched-btn:hover { border-color: var(--acc); color: var(--text); }
/* ══ Todo List ══ */
.todo-section { margin-bottom: 18px; }
.todo-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.todo-progress { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); }
.todo-bar { width: 120px; height: 6px; background: #0e1320; border-radius: 3px; overflow: hidden; }
.todo-bar-fill { height: 100%; border-radius: 3px; background: var(--ok); transition: width .3s; }
.todo-list { display: flex; flex-direction: column; gap: 4px; }
.todo-item { display: flex; flex-direction: column; background: var(--panel2); border-radius: 8px; font-size: 12px; transition: opacity .15s; }
.todo-item.done { opacity: .55; }
.todo-item .t-row { display: flex; align-items: center; gap: 8px; padding: 7px 10px; }
.todo-item .t-icon { font-size: 14px; flex-shrink: 0; }
.todo-item .t-title { flex: 1; color: var(--text); }
.todo-item.done .t-title { text-decoration: line-through; color: var(--muted); }
.todo-item .t-status { font-size: 10px; padding: 2px 6px; border-radius: 4px; }
.todo-item .t-status.s-done { color: var(--ok); background: #0a2018; border: 1px solid #2ecc8a44; }
.todo-item .t-status.s-progress { color: var(--acc); background: #0a1228; border: 1px solid #6a9eff44; }
.todo-item .t-status.s-notstarted { color: var(--muted); background: var(--panel); border: 1px solid var(--line); }
.ec-todo-bar { display: flex; align-items: center; gap: 6px; font-size: 10px; color: var(--muted); margin-top: 6px; }
.ec-todo-track { flex: 1; max-width: 80px; height: 4px; background: #0e1320; border-radius: 2px; overflow: hidden; }
.ec-todo-fill { height: 100%; background: var(--ok); border-radius: 2px; }
.ec-actions { display: flex; gap: 4px; margin-top: 8px; }
.ec-actions .mini-act { font-size: 10px; padding: 3px 8px; border-radius: 5px; border: 1px solid var(--line); background: transparent; cursor: pointer; color: var(--muted); transition: all .12s; }
.ec-actions .mini-act:hover { border-color: var(--acc); color: var(--text); }
.ec-actions .mini-act.danger:hover { border-color: #ff5270; color: #ff5270; }
/* ══ Archive Bar ══ */
.archive-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
.ab-label { font-size: 12px; color: var(--muted); margin-right: 4px; }
.ab-btn { font-size: 11px; padding: 4px 12px; border-radius: 6px; border: 1px solid var(--line); background: transparent; cursor: pointer; color: var(--muted); transition: all .15s; font-weight: 600; }
.ab-btn:hover { border-color: var(--acc); color: var(--text); }
.ab-btn.active { border-color: var(--acc); color: var(--acc); background: #0f1a38; }
.ab-count { font-size: 10px; color: var(--muted); margin-left: auto; }
.ab-scan { font-size: 11px; padding: 4px 12px; border-radius: 6px; border: 1px solid #6a9eff44; background: transparent; cursor: pointer; color: var(--acc); font-weight: 600; transition: all .15s; }
.ab-scan:hover { background: #0a1228; border-color: var(--acc); }
.ab-scan-status { font-size: 10px; color: var(--muted); }
/* ══ Live Activity ══ */
.la-section { margin-bottom: 18px; }
.la-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.la-title { font-size: 11px; font-weight: 700; color: var(--acc); letter-spacing: .06em; }
.la-log { max-height: 320px; overflow-y: auto; background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; font-size: 12px; }
.la-entry { display: flex; gap: 8px; align-items: flex-start; padding: 5px 8px; border-radius: 6px; line-height: 1.5; word-break: break-all; }
.la-entry:hover { background: rgba(106,158,255,.04); }
.la-empty { text-align: center; color: var(--muted); padding: 20px; font-size: 12px; }
/* ══ Model Config ══ */
.model-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; margin-bottom: 18px; }
.mc-card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; }
.mc-top { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.mc-emoji { font-size: 22px; }
.mc-name { font-size: 15px; font-weight: 700; }
.mc-role { font-size: 11px; color: var(--muted); }
.mc-cur { font-size: 11px; color: var(--muted); margin-bottom: 8px; }
.mc-cur b { color: var(--text); }
.msel { width: 100%; background: var(--panel2); border: 1px solid var(--line); border-radius: 7px; color: var(--text); padding: 7px 10px; font-size: 12px; outline: none; cursor: pointer; }
.msel:focus { border-color: var(--acc); }
.mc-btns { display: flex; gap: 6px; margin-top: 8px; }
.btn { font-size: 12px; padding: 6px 14px; border-radius: 7px; border: none; cursor: pointer; font-weight: 600; }
.btn-p { background: var(--acc); color: #000; }
.btn-p:hover { filter: brightness(1.15); }
.btn-p:disabled { background: #2a3a6a; color: var(--muted); cursor: not-allowed; }
.btn-g { background: transparent; border: 1px solid var(--line); color: var(--muted); }
.btn-g:hover { border-color: #2e3d6a; color: var(--text); }
.cl-wrap { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; }
.cl-title { font-size: 12px; font-weight: 700; color: var(--muted); letter-spacing: .05em; text-transform: uppercase; margin-bottom: 10px; }
.cl-row { display: flex; gap: 10px; font-size: 11px; padding: 5px 0; border-bottom: 1px solid var(--line); }
.cl-row:last-child { border-bottom: none; }
/* ══ Skills Config ══ */
.skills-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.sk-card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
.sk-hdr { display: flex; align-items: center; gap: 9px; padding: 11px 14px; background: var(--panel2); border-bottom: 1px solid var(--line); }
.sk-list { padding: 10px; }
.sk-item { display: flex; gap: 8px; padding: 8px 10px; border-radius: 7px; font-size: 12px; margin-bottom: 3px; cursor: pointer; border: 1px solid transparent; transition: all .12s; }
.sk-item:hover { background: var(--panel2); border-color: var(--line); }
/* ══ Sessions ══ */
.sess-filters { display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; align-items: center; }
.sess-filter { font-size: 11px; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--line); background: var(--panel); color: var(--muted); cursor: pointer; transition: all .12s; }
.sess-filter:hover { border-color: var(--acc); color: var(--text); }
.sess-filter.active { border-color: var(--acc); color: var(--acc); background: #0a1228; }
.sess-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 10px; }
.sess-card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px; transition: border-color .12s; cursor: pointer; }
.sess-card:hover { border-color: #2e3d6a; }
/* ══ Officials ══ */
.off-activity { display: flex; align-items: center; gap: 8px; padding: 8px 14px; background: #0a1228; border: 1px solid #1a2a4a; border-radius: 10px; margin-bottom: 14px; font-size: 12px; flex-wrap: wrap; }
.off-kpi { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 16px; }
.kpi { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; }
.kpi-v { font-size: 24px; font-weight: 800; margin-bottom: 3px; }
.kpi-l { font-size: 11px; color: var(--muted); }
.off-layout { display: grid; grid-template-columns: 260px 1fr; gap: 14px; }
@media (max-width: 700px) { .off-layout { grid-template-columns: 1fr; } .off-kpi { grid-template-columns: repeat(2, 1fr); } }
.off-ranklist { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; }
.orl-hdr { padding: 10px 14px; background: var(--panel2); border-bottom: 1px solid var(--line); font-size: 11px; font-weight: 700; color: var(--muted); letter-spacing: .06em; text-transform: uppercase; }
.orl-item { display: flex; align-items: center; gap: 10px; padding: 10px 14px; cursor: pointer; border-bottom: 1px solid var(--line); transition: background .1s; }
.orl-item:last-child { border-bottom: none; }
.orl-item:hover { background: var(--panel2); }
.orl-item.selected { background: #0a1228; border-left: 3px solid var(--acc); }
.off-detail { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 22px; min-height: 400px; }
/* ══ Memorials ══ */
.mem-list { display: flex; flex-direction: column; gap: 8px; }
.mem-card { display: flex; gap: 14px; align-items: flex-start; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; cursor: pointer; transition: border-color .12s; }
.mem-card:hover { border-color: var(--acc); }
/* ══ Templates ══ */
.tpl-cats { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
.tpl-cat { font-size: 12px; padding: 6px 14px; border-radius: 999px; border: 1px solid var(--line); background: var(--panel); color: var(--muted); cursor: pointer; transition: all .12s; }
.tpl-cat:hover { border-color: var(--acc); color: var(--text); }
.tpl-cat.active { border-color: var(--acc); color: var(--acc); background: #0a1228; }
.tpl-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 10px; }
.tpl-card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; transition: border-color .12s; cursor: pointer; display: flex; flex-direction: column; }
.tpl-card:hover { border-color: var(--acc); }
/* ══ Morning Brief ══ */
.mb-hdr { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 18px; flex-wrap: wrap; gap: 10px; }
.mb-title { font-size: 20px; font-weight: 800; background: linear-gradient(135deg, #f5c842, #ff9a4a); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.mb-sub { font-size: 12px; color: var(--muted); margin-top: 3px; }
.mb-cats { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
.mb-cat { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; }
.mb-cat-hdr { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-bottom: 1px solid var(--line); }
.mb-news-list { padding: 10px; }
.mb-card { display: flex; gap: 12px; padding: 10px 8px; border-radius: 10px; margin-bottom: 6px; cursor: pointer; transition: background .12s; border-bottom: 1px solid var(--line); }
.mb-card:last-child { border-bottom: none; }
.mb-card:hover { background: var(--panel2); }
/* ══ Court Ceremony ══ */
.ceremony-bg { position: fixed; inset: 0; z-index: 9999; background: #07090f; display: flex; flex-direction: column; align-items: center; justify-content: center; opacity: 0; animation: crmFadeIn .6s ease forwards; cursor: pointer; }
.ceremony-bg.out { animation: crmFadeOut .5s ease forwards; }
.crm-glow { position: absolute; width: 400px; height: 400px; border-radius: 50%; background: radial-gradient(circle, rgba(106,158,255,.08), transparent 70%); animation: crmPulse 3s ease-in-out infinite; }
.crm-line1 { font-family: 'Noto Serif SC', serif; font-size: 52px; font-weight: 900; color: #dde4f8; letter-spacing: .15em; opacity: 0; transform: translateY(20px); }
.crm-line2 { font-family: 'Noto Serif SC', serif; font-size: 22px; font-weight: 700; color: var(--acc); letter-spacing: .2em; margin-top: 12px; opacity: 0; transform: translateY(15px); }
.crm-line3 { font-size: 14px; color: var(--muted); margin-top: 24px; opacity: 0; letter-spacing: .05em; }
.crm-date { font-size: 12px; color: #2a3555; margin-top: 40px; opacity: 0; letter-spacing: .08em; }
.crm-skip { font-size: 11px; color: #2a3555; margin-top: 18px; opacity: 0; animation: crmChar .4s 2.5s forwards; }
.crm-line1.in { animation: crmSlideUp .6s .3s ease forwards; }
.crm-line2.in { animation: crmSlideUp .5s 1.1s ease forwards; }
.crm-line3.in { animation: crmSlideUp .5s 1.6s ease forwards; }
.crm-date.in { animation: crmChar .4s 2s ease forwards; }
@keyframes crmFadeIn { to { opacity: 1; } }
@keyframes crmFadeOut { to { opacity: 0; pointer-events: none; } }
@keyframes crmSlideUp { to { opacity: 1; transform: translateY(0); } }
@keyframes crmChar { to { opacity: 1; } }
@keyframes crmPulse { 0%,100% { transform: scale(1); opacity: .5; } 50% { transform: scale(1.1); opacity: .8; } }
/* ══ Confirm Dialog ══ */
.confirm-bg { position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 200; backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; }
.confirm-box { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 24px; max-width: 420px; width: 90%; box-shadow: 0 20px 60px rgba(0,0,0,.6); }
.confirm-title { font-size: 16px; font-weight: 700; margin-bottom: 8px; }
.confirm-msg { font-size: 13px; color: var(--muted); margin-bottom: 14px; line-height: 1.5; }
.confirm-input { width: 100%; background: var(--panel2); border: 1px solid var(--line); border-radius: 7px; color: var(--text); padding: 8px 10px; font-size: 12px; outline: none; margin-bottom: 14px; }
.confirm-input:focus { border-color: var(--acc); }
.confirm-btns { display: flex; gap: 8px; justify-content: flex-end; }
/* ══ Toast ══ */
.toaster { position: fixed; bottom: 20px; right: 20px; display: flex; flex-direction: column; gap: 8px; z-index: 300; pointer-events: none; }
.toast { font-size: 13px; padding: 10px 16px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel); color: var(--text); box-shadow: 0 4px 20px rgba(0,0,0,.4); animation: tin .2s; max-width: 320px; pointer-events: auto; }
.toast.ok { border-color: #2ecc8a55; background: #0a1a10; }
.toast.err { border-color: #ff527055; background: #200a10; }
@keyframes tin { from { transform: translateX(40px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
/* ══ Subscription Config ══ */
.sub-config { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 18px; margin-bottom: 18px; }
.sub-section { margin-bottom: 16px; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
.sub-section:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
.sub-sec-title { font-size: 13px; font-weight: 700; margin-bottom: 10px; }
.sub-input { background: var(--panel2); border: 1px solid var(--line); border-radius: 7px; color: var(--text); padding: 7px 10px; font-size: 12px; outline: none; min-width: 0; }
.sub-input:focus { border-color: var(--acc); }
/* ══ Skills: missing sub-classes ══ */
.si-name { font-weight: 600; min-width: 100px; }
.si-desc { color: var(--muted); flex: 1; line-height: 1.4; }
.si-arrow { color: var(--muted); font-size: 14px; opacity: .3; transition: opacity .12s; }
.sk-item:hover .si-arrow { opacity: 1; }
.sk-emoji { font-size: 18px; }
.sk-name { font-size: 14px; font-weight: 700; }
.sk-cnt { font-size: 11px; color: var(--muted); margin-left: auto; }
.sk-empty { font-size: 12px; color: var(--muted); padding: 12px; text-align: center; opacity: .6; }
.sk-add { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 8px; font-size: 12px; color: var(--acc); cursor: pointer; border-top: 1px solid var(--line); transition: background .12s; }
.sk-add:hover { background: var(--panel2); }
/* skill detail modal */
.sk-modal-body { max-height: 70vh; overflow-y: auto; }
.sk-md { font-size: 13px; line-height: 1.7; color: var(--text); }
.sk-md h1, .sk-md h2, .sk-md h3 { margin: 16px 0 8px; color: var(--text); }
.sk-md h1 { font-size: 18px; }
.sk-md h2 { font-size: 15px; border-bottom: 1px solid var(--line); padding-bottom: 6px; }
.sk-md h3 { font-size: 13px; }
.sk-md p { margin: 6px 0; }
.sk-md ul, .sk-md ol { padding-left: 20px; margin: 6px 0; }
.sk-md li { margin: 3px 0; }
.sk-md code { font-size: 11px; background: var(--panel2); padding: 2px 6px; border-radius: 4px; font-family: monospace; }
.sk-md pre { background: var(--panel2); border: 1px solid var(--line); border-radius: 8px; padding: 12px; overflow-x: auto; margin: 8px 0; }
.sk-md pre code { background: none; padding: 0; }
.sk-md table { width: 100%; border-collapse: collapse; font-size: 12px; margin: 8px 0; }
.sk-md th, .sk-md td { padding: 6px 10px; border: 1px solid var(--line); text-align: left; }
.sk-md th { background: var(--panel2); }
.sk-md hr { border: none; border-top: 1px solid var(--line); margin: 14px 0; }
.sk-path { font-size: 10px; color: var(--muted); padding: 8px 0; word-break: break-all; border-top: 1px solid var(--line); margin-top: 12px; }
/* ══ Sessions: missing sub-classes ══ */
.sc-top { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.sc-emoji { font-size: 20px; }
.sc-agent { font-size: 13px; font-weight: 700; }
.sc-org { font-size: 11px; color: var(--muted); }
.sc-title { font-size: 13px; font-weight: 600; margin-bottom: 6px; line-height: 1.4; }
.sc-now { font-size: 11px; color: var(--muted); line-height: 1.5; margin-bottom: 6px; }
.sc-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.sc-id { font-size: 10px; color: var(--acc); font-weight: 600; }
.sc-time { font-size: 10px; color: var(--muted); margin-left: auto; }
/* ══ Model Config: missing sub-classes ══ */
.mc-st { font-size: 11px; margin-top: 6px; padding: 4px 8px; border-radius: 5px; display: none; }
.mc-st.ok { display: block; background: #0a2018; color: var(--ok); border: 1px solid #2ecc8a44; }
.mc-st.err { display: block; background: #200a10; color: var(--danger); border: 1px solid #ff527044; }
.mc-st.pending { display: block; background: #0a1228; color: var(--acc); border: 1px solid #6a9eff44; }
.cl-t { color: var(--muted); min-width: 115px; }
.cl-a { color: var(--acc); min-width: 80px; }
.cl-c { color: var(--muted); }
.cl-c b { color: var(--text); }
.cl-list { display: flex; flex-direction: column; }
/* ══ Memorial: missing sub-classes ══ */
.mem-icon { font-size: 28px; flex-shrink: 0; margin-top: 2px; }
.mem-info { flex: 1; min-width: 0; }
.mem-title { font-size: 14px; font-weight: 700; margin-bottom: 4px; }
.mem-sub { font-size: 11px; color: var(--muted); line-height: 1.5; }
.mem-tags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; }
.mem-tag { font-size: 10px; padding: 2px 8px; border-radius: 4px; background: var(--panel2); color: var(--muted); border: 1px solid var(--line); }
.mem-right { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex-shrink: 0; }
.mem-date { font-size: 10px; color: var(--muted); }
.mem-cost { font-size: 10px; color: var(--acc); }
.mem-empty { text-align: center; padding: 40px; color: var(--muted); font-size: 13px; }
/* memorial detail timeline */
.md-timeline { position: relative; padding-left: 24px; margin: 16px 0; }
.md-timeline::before { content: ''; position: absolute; left: 7px; top: 0; bottom: 0; width: 2px; background: var(--line); }
.md-tl-item { position: relative; margin-bottom: 14px; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
.md-tl-item:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
.md-tl-dot { position: absolute; left: -20px; top: 3px; width: 10px; height: 10px; border-radius: 50%; background: var(--acc); border: 2px solid var(--bg); }
.md-tl-dot.green { background: var(--ok); }
.md-tl-dot.yellow { background: var(--warn); }
.md-tl-dot.red { background: var(--danger); }
.md-tl-from { font-size: 11px; font-weight: 700; color: var(--acc); }
.md-tl-to { font-size: 11px; color: var(--muted); }
.md-tl-remark { font-size: 12px; margin-top: 3px; line-height: 1.5; }
.md-tl-time { font-size: 10px; color: var(--muted); margin-top: 2px; }
/* ══ Template: missing sub-classes ══ */
.tpl-top { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.tpl-icon { font-size: 24px; }
.tpl-name { font-size: 14px; font-weight: 700; }
.tpl-pop { font-size: 10px; color: var(--muted); margin-left: auto; }
.tpl-desc { font-size: 12px; color: var(--muted); line-height: 1.5; margin-bottom: 10px; flex: 1; }
.tpl-footer { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.tpl-dept { font-size: 10px; padding: 2px 6px; border-radius: 4px; background: var(--panel2); color: var(--acc); }
.tpl-est { font-size: 10px; color: var(--muted); margin-left: auto; }
.tpl-go { font-size: 11px; padding: 5px 14px; border-radius: 6px; background: var(--acc); color: #fff; border: none; cursor: pointer; font-weight: 600; margin-left: 8px; transition: opacity .12s; }
.tpl-go:hover { opacity: .85; }
.tpl-form { margin-top: 18px; }
.tpl-field { margin-bottom: 14px; }
.tpl-label { font-size: 12px; font-weight: 600; display: block; margin-bottom: 6px; }
.tpl-input { width: 100%; padding: 10px 12px; background: var(--bg); border: 1px solid var(--line); border-radius: 8px; color: var(--text); font-size: 13px; outline: none; }
/* ══ Morning Brief: missing sub-classes ══ */
.mb-img { width: 72px; height: 52px; border-radius: 7px; object-fit: cover; flex-shrink: 0; background: var(--panel2); display: flex; align-items: center; justify-content: center; font-size: 22px; overflow: hidden; }
.mb-img img { width: 100%; height: 100%; object-fit: cover; border-radius: 7px; }
.mb-info { flex: 1; min-width: 0; }
.mb-headline { font-size: 13px; font-weight: 700; line-height: 1.4; margin-bottom: 4px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.mb-summary { font-size: 11px; color: var(--muted); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.mb-meta { display: flex; align-items: center; gap: 8px; margin-top: 5px; }
.mb-source { font-size: 10px; color: var(--acc); }
.mb-time { font-size: 10px; color: var(--muted); }
.mb-cat-icon { font-size: 20px; }
.mb-cat-name { font-size: 14px; font-weight: 800; }
.mb-cat-cnt { font-size: 11px; color: var(--muted); margin-left: auto; }
.mb-empty { text-align: center; padding: 30px; color: var(--muted); font-size: 13px; }
.mb-loading { display: flex; align-items: center; justify-content: center; padding: 60px; color: var(--muted); font-size: 14px; gap: 10px; }
/* ══ Live Activity: missing sub-classes ══ */
.la-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--ok); margin-right: 4px; animation: pulse 1.5s infinite; }
.la-dot.idle { background: var(--muted); animation: none; }
.la-agent { font-size: 11px; color: var(--muted); }
.la-icon { flex-shrink: 0; font-size: 13px; margin-top: 1px; }
.la-body { flex: 1; min-width: 0; }
.la-time { font-size: 10px; color: var(--muted); flex-shrink: 0; min-width: 44px; text-align: right; }
.la-assistant { color: var(--text); }
.la-thinking { color: #a07aff; font-style: italic; opacity: .75; }
.la-tool { color: #44aaff; }
.la-tool-result { color: var(--muted); font-size: 11px; }
.la-tool-result.ok { color: var(--ok); }
.la-tool-result.err { color: var(--danger); }
.la-user { color: var(--warn); }
.la-tool-name { font-weight: 700; margin-right: 4px; }
.la-trunc { color: var(--muted); font-size: 10px; opacity: .6; }
.la-flow-wrap { display: flex; flex-direction: column; gap: 6px; }
.la-groups { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
.la-group { border: 1px solid var(--line); border-radius: 8px; background: var(--panel); }
.la-group-hd { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; border-bottom: 1px solid var(--line); font-size: 11px; color: var(--muted); }
.la-group-hd .name { font-weight: 700; color: var(--text); }
.la-group-bd { display: flex; flex-direction: column; gap: 4px; padding: 6px; }
/* ══ Scheduler: missing sub-classes ══ */
.sched-status { font-size: 10px; color: var(--muted); }
.sched-line { font-size: 11px; color: var(--muted); display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
.sched-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.sched-btn.warn:hover { border-color: #f5c842; color: #f5c842; }
.sched-btn.danger:hover { border-color: #ff5270; color: #ff5270; }
/* ══ Task Actions: missing btn-cancel ══ */
.btn-cancel { background: #88888822; color: #888; border: 1px solid #88888844; }
.btn-cancel:hover { background: #88888844; }
.btn-action:disabled { opacity: .4; cursor: not-allowed; }
/* ══ Todo: missing sub-classes ══ */
.todo-detail { display: none; padding: 4px 10px 10px 36px; font-size: 11px; line-height: 1.6; color: var(--text); white-space: pre-wrap; word-break: break-word; border-top: 1px solid var(--line); margin: 0 6px; opacity: .85; }
.todo-item.expanded .todo-detail { display: block; }
.todo-item .t-expand { color: var(--muted); font-size: 10px; transition: transform .2s; flex-shrink: 0; }
.todo-item.expanded .t-expand { transform: rotate(90deg); }
.todo-item .t-id { color: var(--muted); font-size: 10px; min-width: 20px; }
.todo-item.has-detail .t-row { cursor: pointer; }
/* ══ Officials: missing sub-classes ══ */
.act-label { color: var(--muted); flex-shrink: 0; }
.act-dot { display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border-radius: 999px; background: #0f1a38; border: 1px solid #1e2e50; margin: 2px; }
.act-dot.alive { border-color: #2ecc8a44; background: #0a2018; color: var(--ok); }
.act-dot.warn { border-color: #f5c84244; background: #201a08; color: var(--warn); }
.act-dot.idle { color: var(--muted); }
.orl-medal { font-size: 16px; min-width: 20px; text-align: center; }
.orl-emoji { font-size: 18px; }
.orl-name { flex: 1; }
.orl-role { font-size: 12px; font-weight: 700; }
.orl-org { font-size: 10px; color: var(--muted); }
.orl-score { font-size: 11px; font-weight: 700; color: var(--acc); }
.orl-hbdot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.orl-hbdot.active { background: var(--ok); }
.orl-hbdot.warn { background: var(--warn); }
.orl-hbdot.stalled { background: var(--danger); animation: pulse 1.2s infinite; }
.orl-hbdot.idle { background: #2a3a5a; }
.od-empty { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--muted); font-size: 13px; min-height: 200px; }
.od-hero { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
.od-emoji { font-size: 40px; }
.od-name { font-size: 22px; font-weight: 800; }
.od-role { font-size: 13px; color: var(--muted); margin-top: 2px; }
.od-rank-badge { font-size: 11px; padding: 3px 9px; border-radius: 999px; border: 1px solid #f5c84244; color: #f5c842; background: #201a08; margin-top: 4px; display: inline-block; }
.od-hb { margin-left: auto; text-align: right; }
.od-section { margin-bottom: 18px; }
.od-sec-title { font-size: 10px; font-weight: 700; color: var(--muted); letter-spacing: .07em; text-transform: uppercase; margin-bottom: 10px; }
.od-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.ods { background: var(--panel2); border-radius: 8px; padding: 10px; text-align: center; }
.ods-v { font-size: 20px; font-weight: 800; }
.ods-l { font-size: 10px; color: var(--muted); margin-top: 2px; }
.tbar { margin-bottom: 7px; }
.tbar-hdr { display: flex; justify-content: space-between; font-size: 11px; margin-bottom: 3px; }
.tbar-label { color: var(--muted); }
.tbar-val { font-weight: 600; }
.tbar-track { height: 6px; background: #0e1320; border-radius: 3px; overflow: hidden; }
.tbar-fill { height: 100%; border-radius: 3px; }
.od-cost-row { display: flex; gap: 10px; flex-wrap: wrap; }
.cost-chip { font-size: 12px; padding: 5px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel2); }
.cost-chip b { font-size: 15px; }
.cost-chip.hi { border-color: #ff527044; }
.cost-chip.md { border-color: #f5c84244; }
.cost-chip.lo { border-color: #2ecc8a44; }
.od-edict-list { display: flex; flex-direction: column; gap: 5px; }
.oe-item { display: flex; align-items: center; gap: 8px; padding: 7px 10px; background: var(--panel2); border-radius: 7px; font-size: 12px; cursor: pointer; }
.oe-item:hover { background: #141c30; }
.oe-id { font-size: 10px; color: var(--acc); font-weight: 700; min-width: 110px; }
.oe-title { flex: 1; color: var(--text); }
.oe-state { font-size: 10px; }
.kpi-v.gold { color: #f5c842; }
.kpi-v.green { color: var(--ok); }
.kpi-v.blue { color: var(--acc); }
.kpi-v.warn { color: var(--warn); }
/* ══ Subscription: missing sub-classes ══ */
.sub-cats { display: flex; flex-wrap: wrap; gap: 8px; }
.sub-cat { display: flex; align-items: center; gap: 6px; padding: 7px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel2); cursor: pointer; transition: all .15s; user-select: none; }
.sub-cat:hover { border-color: var(--acc); }
.sub-cat.active { border-color: var(--ok); background: #0a2018; }
.sub-cat .sc-check { width: 16px; height: 16px; border-radius: 4px; border: 1px solid var(--line); display: flex; align-items: center; justify-content: center; font-size: 10px; transition: all .15s; }
.sub-cat.active .sc-check { background: var(--ok); border-color: var(--ok); color: #000; }
.sub-cat .sc-label { font-size: 12px; font-weight: 600; }
.sub-cat .sc-count { font-size: 10px; color: var(--muted); }
.sub-kw-list { display: flex; flex-wrap: wrap; gap: 6px; }
.sub-kw { display: flex; align-items: center; gap: 4px; padding: 4px 8px 4px 10px; border-radius: 999px; background: #0f1a38; border: 1px solid #1e2e50; font-size: 11px; color: var(--acc); }
.sub-kw .kw-del { cursor: pointer; opacity: .5; font-size: 13px; padding: 0 2px; }
.sub-kw .kw-del:hover { opacity: 1; color: var(--danger); }
.sub-feed-list { display: flex; flex-direction: column; gap: 4px; }
.sub-feed { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--panel2); border-radius: 7px; font-size: 12px; }
.sub-feed .sf-name { font-weight: 600; min-width: 80px; color: var(--acc); }
.sub-feed .sf-url { flex: 1; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sub-feed .sf-cat { font-size: 10px; padding: 2px 6px; border-radius: 4px; border: 1px solid var(--line); }
.sub-feed .sf-del { cursor: pointer; color: var(--muted); font-size: 14px; }
.sub-feed .sf-del:hover { color: var(--danger); }
/* ══ Agent Status: missing sub-classes ══ */
.as-card .as-emoji { font-size: 22px; margin-bottom: 3px; }
.as-card .as-label { font-size: 12px; font-weight: 700; }
.as-card .as-role { font-size: 10px; color: var(--muted); }
.as-card .as-status { font-size: 10px; margin-top: 4px; }
.as-card .as-time { font-size: 9px; color: var(--muted); margin-top: 2px; }
.as-wake-btn:disabled { opacity: .4; cursor: not-allowed; }
.as-refresh { font-size: 11px; padding: 4px 12px; border-radius: 8px; border: 1px solid var(--line); color: var(--muted); background: transparent; cursor: pointer; transition: background .15s; }
.as-refresh:hover { background: var(--panel2); color: var(--text); }
.as-wake-all { font-size: 11px; padding: 4px 12px; border-radius: 8px; border: 1px solid var(--warn); color: var(--warn); background: transparent; cursor: pointer; transition: background .15s; margin-left: 6px; }
.as-wake-all:hover { background: var(--warn); color: #fff; }
.as-summary span { display: flex; align-items: center; gap: 4px; }
/* ══ Archive Bar: missing sub-classes ══ */
.archive-bar .ab-archive-all { font-size: 11px; padding: 4px 12px; border-radius: 6px; border: 1px solid #2ecc8a44; background: transparent; cursor: pointer; color: var(--ok); font-weight: 600; transition: all .15s; }
.archive-bar .ab-archive-all:hover { background: #0a2018; border-color: var(--ok); }
.archive-bar .ab-scan-detail { font-size: 11px; padding: 4px 10px; border-radius: 6px; border: 1px solid var(--line); background: transparent; cursor: pointer; color: var(--muted); font-weight: 600; transition: all .15s; }
.archive-bar .ab-scan-detail:hover { border-color: var(--acc); color: var(--text); }
.archive-bar .ab-scan-detail.active { border-color: var(--acc); color: var(--acc); background: #0f1a38; }
.archive-bar .ab-scan-copy { font-size: 11px; padding: 4px 10px; border-radius: 6px; border: 1px solid #2ecc8a44; background: transparent; cursor: pointer; color: var(--ok); font-weight: 600; transition: all .15s; }
.archive-bar .ab-scan-copy:hover { background: #0a2018; border-color: var(--ok); }
/* scan detail panel */
.global-scan-detail { display: none; margin-top: -4px; margin-bottom: 12px; background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; }
.global-scan-detail.open { display: block; }
.global-scan-detail .gs-empty { font-size: 11px; color: var(--muted); }
.global-scan-detail .gs-list { display: flex; flex-direction: column; gap: 6px; }
.global-scan-detail .gs-item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 8px; background: var(--panel); border: 1px solid var(--line); font-size: 11px; }
.global-scan-detail .gs-tag { font-size: 10px; border-radius: 10px; padding: 2px 8px; font-weight: 700; border: 1px solid var(--line); color: var(--muted); }
.global-scan-detail .gs-tag.retry { color: var(--acc); border-color: #6a9eff55; }
.global-scan-detail .gs-tag.escalate { color: #f5c842; border-color: #f5c84255; }
.global-scan-detail .gs-tag.rollback { color: #ff5270; border-color: #ff527055; }
.global-scan-detail .gs-task { font-weight: 700; color: var(--text); }
.global-scan-detail .gs-meta { color: var(--muted); }
.global-scan-detail .gs-hint { margin-top: 8px; font-size: 10px; color: var(--muted); }
/* ══ Confirm: missing sub-classes ══ */
.confirm-reason { width: 100%; background: var(--panel2); border: 1px solid var(--line); border-radius: 7px; color: var(--text); padding: 8px 10px; font-size: 12px; outline: none; margin-bottom: 14px; resize: vertical; min-height: 60px; }
.confirm-reason:focus { border-color: var(--acc); }
/* ══ General ══ */
.empty { text-align: center; padding: 40px 20px; color: var(--muted); font-size: 13px; }
.sec-title { font-size: 12px; font-weight: 700; color: var(--muted); letter-spacing: .07em; text-transform: uppercase; margin-bottom: 12px; }
code { font-size: 11px; background: var(--panel2); padding: 2px 6px; border-radius: 4px; font-family: monospace; }
/* ══ 朝堂议政动画 ══ */
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
@keyframes pulse { 0%, 100% { opacity: .6; } 50% { opacity: 1; } }
@keyframes bounceIn { 0% { transform: scale(0); } 60% { transform: scale(1.3); } 100% { transform: scale(1); } }
/* M3: checkpoint pulse */
@keyframes pulse-border { 0%, 100% { border-color: rgba(245, 158, 11, 0.4); } 50% { border-color: rgba(245, 158, 11, 0.9); } }
/* M3: Button styles */
.btn-primary {
padding: 8px 18px; border-radius: 8px; border: none; font-size: 13px; font-weight: 600;
background: linear-gradient(135deg, var(--acc), #c9a227); color: #1a1a2e; cursor: pointer;
transition: all 0.15s;
}
.btn-primary:hover { filter: brightness(1.1); }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; filter: none; }
.btn-ghost {
padding: 8px 18px; border-radius: 8px; font-size: 13px; font-weight: 600;
background: transparent; color: var(--fg); border: 1px solid var(--line); cursor: pointer;
transition: all 0.15s;
}
.btn-ghost:hover { background: var(--panel2); }
.btn-small {
padding: 4px 10px; border-radius: 6px; font-size: 11px; font-weight: 600;
background: transparent; color: var(--fg); border: 1px solid var(--line); cursor: pointer;
transition: all 0.15s; text-decoration: none; display: inline-block;
}
.btn-small:hover { background: var(--panel2); }
.tab-btn {
padding: 8px 16px; font-size: 13px; font-weight: 600; border: none;
background: none; cursor: pointer; transition: all 0.15s;
border-bottom: 2px solid transparent;
}
.tab-btn:hover { background: var(--panel2); }
.tag-done { background: #22c55e22; color: #22c55e; border: 1px solid #22c55e44; padding: 1px 6px; border-radius: 4px; font-size: 10px; }
.tag-exec { background: var(--acc)22; color: var(--acc); border: 1px solid var(--acc)44; padding: 1px 6px; border-radius: 4px; font-size: 10px; }
.tag-waiting { background: #f59e0b22; color: #f59e0b; border: 1px solid #f59e0b44; padding: 1px 6px; border-radius: 4px; font-size: 10px; }
-110
View File
@@ -1,110 +0,0 @@
// AI Briefing 页面
import React, { useState, useEffect, useCallback } from 'react';
import * as api from '../api';
import type { Task, Project } from '../types';
export function Briefing() {
const [projects, setProjects] = useState<Project[]>([]);
const [selectedProject, setSelectedProject] = useState<string>('');
const [tasks, setTasks] = useState<Task[]>([]);
const [briefing, setBriefing] = useState<string>('');
const loadProjects = useCallback(async () => {
try {
const data = await api.listProjects();
setProjects(data);
if (data.length > 0 && !selectedProject) {
setSelectedProject(data[0].id);
}
} catch { /* ignore */ }
}, [selectedProject]);
useEffect(() => { loadProjects(); }, [loadProjects]);
const generateBriefing = useCallback(async () => {
if (!selectedProject) return;
try {
const data = await api.listTasks(selectedProject);
setTasks(data);
// 本地生成简要汇报
const done = data.filter(t => t.status === 'done');
const working = data.filter(t => t.status === 'working');
const failed = data.filter(t => t.status === 'failed');
const pending = data.filter(t => t.status === 'pending');
const lines = [
`📊 项目日报`,
``,
`总任务数: ${data.length}`,
`✅ 已完成: ${done.length}`,
`🔄 进行中: ${working.length}`,
`⏳ 待处理: ${pending.length}`,
`❌ 失败: ${failed.length}`,
``,
];
if (working.length > 0) {
lines.push(`## 进行中的任务`);
working.forEach(t => lines.push(`- [${t.id.substring(0, 8)}] ${t.title} (${t.assignee || '未分配'})`));
lines.push('');
}
if (failed.length > 0) {
lines.push(`## 失败的任务`);
failed.forEach(t => lines.push(`- [${t.id.substring(0, 8)}] ${t.title}`));
lines.push('');
}
if (done.length > 0) {
lines.push(`## 最近完成`);
done.slice(-5).forEach(t => lines.push(`- [${t.id.substring(0, 8)}] ${t.title}`));
}
setBriefing(lines.join('\n'));
} catch { /* ignore */ }
}, [selectedProject]);
return (
<div>
<h2 className="page-title">AI Briefing</h2>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<select
value={selectedProject}
onChange={e => setSelectedProject(e.target.value)}
style={{
padding: '6px 10px',
borderRadius: 'var(--radius)',
border: '1px solid var(--line)',
background: 'var(--bg)',
color: 'var(--fg)',
}}
>
<option value=""></option>
{projects.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<button className="btn btn-primary" onClick={generateBriefing}>
</button>
</div>
{briefing && (
<div className="card">
<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'var(--font)', lineHeight: 1.6 }}>
{briefing}
</pre>
</div>
)}
{!briefing && (
<div className="card">
<p style={{ color: 'var(--muted)' }}>"生成汇报"</p>
</div>
)}
</div>
);
}
-130
View File
@@ -1,130 +0,0 @@
// 系统配置页面
import React, { useState } from 'react';
import { useProjects } from '../hooks/useApi';
import * as api from '../api';
export function Config() {
const { projects, loading, refresh } = useProjects();
const [newId, setNewId] = useState('');
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCreate = async () => {
if (!newName.trim()) return;
try {
setCreating(true);
setError(null);
await api.createProject({ id: newId || undefined, name: newName, description: newDesc || null });
setNewId('');
setNewName('');
setNewDesc('');
refresh();
} catch (e) {
setError(String(e));
} finally {
setCreating(false);
}
};
return (
<div>
<h2 className="page-title"></h2>
{/* 创建项目 */}
<div className="card" style={{ marginBottom: 20 }}>
<div className="card-title"></div>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<input
type="text"
placeholder="项目ID(可选,自动生成)"
value={newId}
onChange={e => setNewId(e.target.value)}
style={{
width: 200,
padding: '6px 10px',
borderRadius: 'var(--radius)',
border: '1px solid var(--line)',
background: 'var(--bg)',
color: 'var(--fg)',
}}
/>
<input
type="text"
placeholder="项目名称"
value={newName}
onChange={e => setNewName(e.target.value)}
style={{
flex: 1,
padding: '6px 10px',
borderRadius: 'var(--radius)',
border: '1px solid var(--line)',
background: 'var(--bg)',
color: 'var(--fg)',
}}
/>
<input
type="text"
placeholder="描述(可选)"
value={newDesc}
onChange={e => setNewDesc(e.target.value)}
style={{
flex: 2,
padding: '6px 10px',
borderRadius: 'var(--radius)',
border: '1px solid var(--line)',
background: 'var(--bg)',
color: 'var(--fg)',
}}
/>
<button
className="btn btn-primary"
onClick={handleCreate}
disabled={creating || !newName.trim()}
style={{ minWidth: 60 }}
>
{creating ? '创建中...' : '创建'}
</button>
</div>
{error && <p className="error" style={{ marginTop: 8 }}>{error}</p>}
</div>
{/* 项目列表 */}
<div className="card">
<div className="card-title"></div>
{loading ? (
<div className="loading">...</div>
) : (
<div className="table-wrapper">
<table>
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{projects.map(p => (
<tr key={p.id}>
<td style={{ fontFamily: 'monospace' }}>{p.id.substring(0, 8)}</td>
<td>{p.name}</td>
<td style={{ color: 'var(--muted)' }}>{p.description || '-'}</td>
<td>
<span className={`badge badge-${p.status === 'active' ? 'done' : 'blocked'}`}>
{p.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
-87
View File
@@ -1,87 +0,0 @@
// 全局监控页面
import React from 'react';
import { useDaemonStatus } from '../hooks/useApi';
import { useProjects } from '../hooks/useApi';
export function Monitor() {
const { status, refresh: refreshStatus } = useDaemonStatus();
const { projects, loading, refresh: refreshProjects } = useProjects();
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 className="page-title"></h2>
<button className="btn" onClick={() => { refreshStatus(); refreshProjects(); }}>
</button>
</div>
{/* Daemon 状态 */}
<div className="stat-grid">
<div className="stat-card">
<div className="value">{status?.status === 'running' ? '🟢' : '🔴'}</div>
<div className="label">Daemon </div>
</div>
<div className="stat-card">
<div className="value">{status?.tick_count ?? '-'}</div>
<div className="label">Tick </div>
</div>
<div className="stat-card">
<div className="value">{status?.active_projects ?? '-'}</div>
<div className="label"></div>
</div>
<div className="stat-card">
<div className="value">{status?.uptime_seconds ? Math.floor(status.uptime_seconds / 60) + 'm' : '-'}</div>
<div className="label"></div>
</div>
</div>
{/* 项目列表 */}
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}></h3>
{loading ? (
<div className="loading">...</div>
) : (
<div className="card">
<div className="table-wrapper">
<table>
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{projects.map(p => (
<tr key={p.id}>
<td>{p.id.substring(0, 8)}</td>
<td>{p.name}</td>
<td>
<span className={`badge badge-${p.status === 'active' ? 'done' : 'blocked'}`}>
{p.status}
</span>
</td>
<td>{p.created_at?.substring(0, 19) ?? '-'}</td>
</tr>
))}
{projects.length === 0 && (
<tr><td colSpan={4} style={{ color: 'var(--muted)' }}></td></tr>
)}
</tbody>
</table>
</div>
</div>
)}
{/* 最近事件 */}
<h3 style={{ fontSize: 15, fontWeight: 600, margin: '20px 0 12px' }}></h3>
<div className="card">
<div className="event-log">
<p style={{ color: 'var(--muted)' }}> SSE </p>
</div>
</div>
</div>
);
}
-74
View File
@@ -1,74 +0,0 @@
// 任务看板页面
import React from 'react';
import { useTasks } from '../hooks/useApi';
import type { Task } from '../types';
interface Props {
projectId: string | null;
onSelectTask: (task: Task) => void;
}
const STATUS_ORDER = ['pending', 'working', 'review', 'blocked', 'done', 'failed'];
export function TaskBoard({ projectId, onSelectTask }: Props) {
const { tasks, loading, error, refresh } = useTasks(projectId);
if (!projectId) {
return <div className="loading"></div>;
}
if (loading) return <div className="loading">...</div>;
if (error) return <div className="error">: {error}</div>;
// 按状态分组
const grouped: Record<string, Task[]> = {};
for (const task of tasks) {
const status = task.status || 'pending';
if (!grouped[status]) grouped[status] = [];
grouped[status].push(task);
}
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 className="page-title"></h2>
<button className="btn" onClick={refresh}></button>
</div>
{STATUS_ORDER.map(status => {
const statusTasks = grouped[status] || [];
if (statusTasks.length === 0) return null;
return (
<div key={status} style={{ marginBottom: 20 }}>
<h3 style={{ fontSize: 14, color: 'var(--muted)', marginBottom: 8, textTransform: 'uppercase' }}>
{status} ({statusTasks.length})
</h3>
<div className="task-board">
{statusTasks.map(task => (
<div
key={task.id}
className="task-card"
onClick={() => onSelectTask(task)}
>
<div className="title">{task.title}</div>
<div className="meta">
<span className={`badge badge-${status}`}>{status}</span>
{task.assignee && <span>👤 {task.assignee}</span>}
{task.risk_level && <span> {task.risk_level}</span>}
</div>
</div>
))}
</div>
</div>
);
})}
{tasks.length === 0 && (
<div className="card">
<p style={{ color: 'var(--muted)' }}> API CLI </p>
</div>
)}
</div>
);
}
+531
View File
@@ -0,0 +1,531 @@
/**
* Zustand Store
* Edict moziplus
* HTTP 5s WebSocket
*/
import { create } from 'zustand';
import {
api,
type Task,
type LiveStatus,
type AgentConfig,
type OfficialsData,
type AgentsStatusData,
type MorningBrief,
type SubConfig,
type ChangeLogEntry,
type TaskNode,
} from './api';
// ── Pipeline Definition (PIPE) ──
export const PIPE = [
{ key: 'created', dept: '主公', icon: '👑', action: '提交' },
{ key: 'planning', dept: '庞统', icon: '🐦', action: '拆解' },
{ key: 'challenge', dept: '司马懿', icon: '🦅', action: '审核' },
{ key: 'assigned', dept: '引擎', icon: '📮', action: '派发' },
{ key: 'executing', dept: '将军', icon: '⚔️', action: '执行' },
{ key: 'reviewing', dept: '司马懿', icon: '🔎', action: '验收' },
{ key: 'done', dept: '完成', icon: '🏆', action: '完成' },
] as const;
export const PIPE_STATE_IDX: Record<string, number> = {
// moziplus states
created: 0,
planning: 1,
challenging: 2,
assigned: 3,
executing: 4,
cancelling: 4, // BUG-6: 同 executing 位置
pausing: 4, // BUG-6: 同 executing 位置
paused: 4,
reviewing: 5,
completed: 6,
failed: 4,
cancelled: 6,
escalated: 4,
// legacy edict states (backward compat)
Inbox: 0, Menxia: 1, Zhongshu: 2, Assigned: 3,
Doing: 4, Review: 5, Done: 6, Blocked: 4, Cancelled: 6, Next: 0,
Taizi: 0, Pending: 0,
};
export const DEPT_COLOR: Record<string, string> = {
'主公': '#ffd700', '庞统': '#a07aff', '司马懿': '#6a9eff', '引擎': '#6aef9a',
'张飞': '#ff5270', '关羽': '#f5c842', '赵云': '#44aaff', '姜维': '#2ecc8a',
'诸葛亮': '#e8a040', '交付': '#2ecc8a',
};
export const STATE_LABEL: Record<string, string> = {
created: '新建', planning: '规划中', challenging: '审核中',
assigned: '已派发', executing: '执行中', paused: '已暂停',
cancelling: '取消中', pausing: '暂停中', // BUG-6
reviewing: '验收中', completed: '已完成', failed: '已失败',
cancelled: '已取消', escalated: '已升级',
};
export function deptColor(d: string): string {
return DEPT_COLOR[d] || '#6a9eff';
}
export function stateLabel(t: Task): string {
const r = t.review_round || 0;
const s = t.status || t.state; // moziplus status or legacy edict state
// moziplus states
if (s === 'challenging' && r > 1) return `审核中(第${r}轮)`;
if (s === 'planning' && r > 0) return `修订方案(第${r}轮)`;
// plan_status refinement
const planStatus = t.plan_status;
if (s === 'planning' && planStatus === 'approved') return '待派发';
if (s === 'planning' && planStatus === 'rejected') return '方案驳回';
if (s === 'planning' && planStatus === 'challenging') return '审核中';
// BUG-6: 中间态标签
if (s === 'cancelling') return '⏳ 取消中';
if (s === 'pausing') return '⏳ 暂停中';
return STATE_LABEL[s] || s;
}
export function isEdict(t: Task): boolean {
// moziplus tasks: UUIDs or random short IDs (M2-08); Edict tasks: TSK-xxx
const id = t.id || '';
return /^TSK-/i.test(id) || /^[0-9a-f]{8}-/i.test(id) || /^[A-Za-z0-9]{10,16}$/.test(id);
}
export function isSession(t: Task): boolean {
return /^(OC-|MC-)/i.test(t.id || '');
}
export function isArchived(t: Task): boolean {
return !!t.archived;
}
export type PipeStatus = { key: string; dept: string; icon: string; action: string; status: 'done' | 'active' | 'pending' };
export function getPipeStatus(t: Task): PipeStatus[] {
const planStatus = t.plan_status || '';
const nodes = t.nodes || [];
const s = t.status || t.state; // moziplus status or legacy edict state
let activeIdx = PIPE_STATE_IDX[s] ?? 4;
// Refine based on moziplus sub-state
if (s === 'planning') {
if (planStatus === 'challenging') activeIdx = 2; // 审核
else if (planStatus === 'approved') activeIdx = 3; // 派发
else if (planStatus === 'rejected') activeIdx = 1; // 回到拆解
else activeIdx = 1; // 拆解
} else if (s === 'executing') {
const hasReviewing = nodes.some((n: TaskNode) => n.status === 'reviewing');
activeIdx = hasReviewing ? 5 : 4; // 验收 vs 执行
} else if (s === 'failed' || s === 'cancelled') {
// 根据实际失败阶段定位进度条
if (planStatus === 'pending' || planStatus === 'rejected' || !planStatus) {
activeIdx = 1; // 拆解阶段失败/取消
} else if (planStatus === 'challenging') {
activeIdx = 2; // 审核阶段失败/取消
} else if (planStatus === 'approved') {
activeIdx = 4; // 执行阶段失败/取消
} else {
activeIdx = 4; // 默认显示在执行
}
} else if (s === 'escalated') {
activeIdx = 4;
} else if (s === 'cancelling' || s === 'pausing') {
// BUG-6: 停在执行阶段
activeIdx = 4;
}
return PIPE.map((stage, i) => ({
...stage,
status: (i < activeIdx ? 'done' : i === activeIdx ? 'active' : 'pending') as 'done' | 'active' | 'pending',
}));
}
// ── Tabs ──
export type TabKey =
| 'tasks' | 'court' | 'monitor' | 'agents'
| 'models' | 'skills' | 'sessions' | 'archives' | 'templates'
| 'usage' | 'settings' | 'officials' | 'morning';
export const TAB_DEFS: { key: TabKey; label: string; icon: string }[] = [
{ key: 'tasks', label: '任务看板', icon: '📜' },
{ key: 'court', label: '军议大厅', icon: '🏛️' },
{ key: 'monitor', label: '编排调度', icon: '🔌' },
{ key: 'agents', label: '将军总览', icon: '🤺' },
{ key: 'models', label: '模型配置', icon: '🤖' },
{ key: 'skills', label: '技能配置', icon: '🎯' },
{ key: 'sessions', label: '传令巡哨', icon: '🔭' },
{ key: 'usage', label: '花费总览', icon: '💰' },
{ key: 'archives', label: '奏折阁', icon: '📜' },
{ key: 'templates', label: '任务模板', icon: '📋' },
{ key: 'settings', label: '系统设置', icon: '⚙️' },
];
// ── DEPTS for monitor ──
export const DEPTS = [
{ id: 'pangtong', label: '庞统', emoji: '🐦', role: '副军师', rank: '凤雏' },
{ id: 'simayi', label: '司马懿', emoji: '🦅', role: '质量总监', rank: '仲达' },
{ id: 'zhugeliang', label: '诸葛亮', emoji: '🪶', role: '总军师', rank: '孔明' },
{ id: 'zhangfei', label: '张飞', emoji: '⚔️', role: '编码先锋', rank: '翼德' },
{ id: 'guanyu', label: '关羽', emoji: '🗡️', role: '风控守将', rank: '云长' },
{ id: 'zhaoyun', label: '赵云', emoji: '🏹', role: '数据总管', rank: '子龙' },
{ id: 'jiangwei', label: '姜维', emoji: '🛡️', role: '平台总督', rank: '伯约' },
];
// ── Templates ──
export interface TemplateParam {
key: string;
label: string;
type: 'text' | 'textarea' | 'select';
default?: string;
required?: boolean;
options?: string[];
}
export interface Template {
id: string;
cat: string;
icon: string;
name: string;
desc: string;
depts: string[];
est: string;
cost: string;
params: TemplateParam[];
command: string;
}
export const TEMPLATES: Template[] = [
{
id: 'tpl-blank', cat: '快速开始', icon: '📜', name: '空白军令',
desc: '自由输入需求描述,无预设参数',
depts: ['庞统'], est: '自定义', cost: '自定义',
params: [
{ key: 'requirement', label: '需求描述', type: 'textarea', required: true },
],
command: '{requirement}',
},
{
id: 'tpl-quant-strategy', cat: '量化策略', icon: '📈', name: '量化策略开发',
desc: '从需求分析到回测验证的完整策略开发流程',
depts: ['庞统', '张飞', '关羽'], est: '~60分钟', cost: '¥3',
params: [
{ key: 'strategy_name', label: '策略名称', type: 'text', required: true },
{ key: 'universe', label: '股票池', type: 'select', options: ['沪深300', '中证500', '全A'], default: '沪深300' },
{ key: 'period', label: '回测区间', type: 'text', default: '2024-01-01 ~ 2025-12-31' },
],
command: '开发量化策略「{strategy_name}」,股票池:{universe},回测区间:{period}',
},
{
id: 'tpl-data-download', cat: '数据采集', icon: '📊', name: '行情数据下载',
desc: '批量下载A股行情数据(日线/分钟线)到NAS',
depts: ['赵云'], est: '~30分钟', cost: '¥1',
params: [
{ key: 'data_type', label: '数据类型', type: 'select', options: ['日线', '1分钟线', '5分钟线', 'Tick'], default: '日线' },
{ key: 'symbols', label: '股票代码', type: 'text', default: '沪深300成分股' },
{ key: 'date_range', label: '日期范围', type: 'text', required: true },
],
command: '下载{data_type}数据,股票:{symbols},日期:{date_range}',
},
{
id: 'tpl-backtest', cat: '量化策略', icon: '🔬', name: '策略回测',
desc: '对指定策略进行标准化回测,输出绩效报告',
depts: ['姜维', '庞统'], est: '~45分钟', cost: '¥2',
params: [
{ key: 'strategy', label: '策略文件路径', type: 'text', required: true },
{ key: 'benchmark', label: '基准', type: 'select', options: ['沪深300', '中证500', '上证50'], default: '沪深300' },
],
command: '回测策略 {strategy},基准:{benchmark}',
},
{
id: 'tpl-risk-review', cat: '风控审查', icon: '🛡️', name: '风控审查',
desc: '审查策略的风控逻辑,评估止损/仓位/滑点设置',
depts: ['关羽', '司马懿'], est: '~20分钟', cost: '¥1.5',
params: [
{ key: 'target', label: '审查目标', type: 'text', required: true },
{ key: 'scope', label: '审查范围', type: 'select', options: ['止损规则', '仓位管理', '滑点控制', '全部'], default: '全部' },
],
command: '对 {target} 进行风控审查,范围:{scope}',
},
{
id: 'tpl-code-impl', cat: '编码开发', icon: '💻', name: '编码实现',
desc: '根据设计文档编码实现功能,强调最小实现、可运行优先',
depts: ['张飞'], est: '~40分钟', cost: '¥2',
params: [
{ key: 'design_doc', label: '设计文档路径', type: 'text', required: true },
{ key: 'module', label: '模块名称', type: 'text', required: true },
],
command: '根据 {design_doc} 实现模块 {module}',
},
{
id: 'tpl-deploy', cat: '编码开发', icon: '🚀', name: '环境部署',
desc: '部署运行环境,安装依赖,配置服务',
depts: ['姜维'], est: '~30分钟', cost: '¥1.5',
params: [
{ key: 'project', label: '项目名称', type: 'text', required: true },
{ key: 'env', label: '部署环境', type: 'select', options: ['Docker', 'NAS', '本地开发'], default: 'Docker' },
],
command: '部署 {project} 到 {env}',
},
{
id: 'tpl-data-verify', cat: '数据采集', icon: '✅', name: '数据验证',
desc: '验证数据完整性、正确性、质量检查',
depts: ['赵云'], est: '~15分钟', cost: '¥0.5',
params: [
{ key: 'data_path', label: '数据路径', type: 'text', required: true },
{ key: 'check', label: '检查项', type: 'select', options: ['完整性', '正确性', '异常值', '全部'], default: '全部' },
],
command: '验证数据 {data_path},检查:{check}',
},
{
id: 'tpl-requirement', cat: '需求分析', icon: '📝', name: '需求分析',
desc: '分析用户需求,撰写需求规格文档',
depts: ['庞统'], est: '~20分钟', cost: '¥1',
params: [
{ key: 'req', label: '需求描述', type: 'textarea', required: true },
{ key: 'domain', label: '领域', type: 'select', options: ['量化策略', '数据平台', '交易系统', '基础设施'], default: '量化策略' },
],
command: '分析需求:{req},领域:{domain}',
},
{
id: 'tpl-code-review', cat: '风控审查', icon: '🔍', name: '代码审查',
desc: '审查代码质量、逻辑正确性、异常处理',
depts: ['司马懿', '关羽'], est: '~25分钟', cost: '¥1.5',
params: [
{ key: 'repo', label: '仓库/文件路径', type: 'text', required: true },
{ key: 'scope', label: '审查范围', type: 'select', options: ['全量', '增量', '指定文件'], default: '增量' },
],
command: '审查 {repo},范围:{scope}',
},
];
export const TPL_CATS = [
{ name: '全部', icon: '📋' },
{ name: '量化策略', icon: '📈' },
{ name: '数据采集', icon: '📊' },
{ name: '风控审查', icon: '🛡️' },
{ name: '编码开发', icon: '💻' },
{ name: '需求分析', icon: '📝' },
];
// ── Main Store ──
interface AppStore {
// Data
liveStatus: LiveStatus | null;
agentConfig: AgentConfig | null;
changeLog: ChangeLogEntry[];
officialsData: OfficialsData | null;
agentsStatusData: AgentsStatusData | null;
morningBrief: MorningBrief | null;
subConfig: SubConfig | null;
// UI State
activeTab: TabKey;
edictFilter: 'active' | 'archived' | 'all';
statusFilter: string;
searchQuery: string;
sessFilter: string;
tplCatFilter: string;
selectedOfficial: string | null;
modalTaskId: string | null;
countdown: number;
// Toast
toasts: { id: number; msg: string; type: 'ok' | 'err' }[];
// Actions
setActiveTab: (tab: TabKey) => void;
setEdictFilter: (f: 'active' | 'archived' | 'all') => void;
setStatusFilter: (f: string) => void;
setSearchQuery: (q: string) => void;
setSessFilter: (f: string) => void;
setTplCatFilter: (f: string) => void;
setSelectedOfficial: (id: string | null) => void;
setModalTaskId: (id: string | null) => void;
setCountdown: (n: number) => void;
toast: (msg: string, type?: 'ok' | 'err') => void;
// Data fetching
loadLive: () => Promise<void>;
loadAgentConfig: () => Promise<void>;
loadOfficials: () => Promise<void>;
loadAgentsStatus: () => Promise<void>;
loadMorning: () => Promise<void>;
loadSubConfig: () => Promise<void>;
loadAll: () => Promise<void>;
}
let _toastId = 0;
export const useStore = create<AppStore>((set, get) => ({
liveStatus: null,
agentConfig: null,
changeLog: [],
officialsData: null,
agentsStatusData: null,
morningBrief: null,
subConfig: null,
activeTab: 'tasks',
edictFilter: 'active',
statusFilter: 'all',
searchQuery: '',
sessFilter: 'all',
tplCatFilter: '全部',
selectedOfficial: null,
modalTaskId: null,
countdown: 5,
toasts: [],
setActiveTab: (tab) => {
set({ activeTab: tab });
const s = get();
if (['models', 'skills', 'sessions'].includes(tab) && !s.agentConfig) s.loadAgentConfig();
if (tab === 'officials' && !s.officialsData) s.loadOfficials();
if (tab === 'monitor') s.loadAgentsStatus();
if (tab === 'morning' && !s.morningBrief) s.loadMorning();
},
setEdictFilter: (f) => set({ edictFilter: f }),
setStatusFilter: (f) => set({ statusFilter: f }),
setSearchQuery: (q) => set({ searchQuery: q }),
setSessFilter: (f) => set({ sessFilter: f }),
setTplCatFilter: (f) => set({ tplCatFilter: f }),
setSelectedOfficial: (id) => set({ selectedOfficial: id }),
setModalTaskId: (id) => set({ modalTaskId: id }),
setCountdown: (n) => set({ countdown: n }),
toast: (msg, type = 'ok') => {
const id = ++_toastId;
set((s) => ({ toasts: [...s.toasts, { id, msg, type }] }));
setTimeout(() => {
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
}, 3000);
},
loadLive: async () => {
try {
const data = await api.liveStatus();
set({ liveStatus: data });
// Also preload officials for monitor tab
const s = get();
if (!s.officialsData) {
api.officialsStats().then((d) => set({ officialsData: d })).catch(() => {});
}
} catch {
// silently fail
}
},
loadAgentConfig: async () => {
try {
const cfg = await api.agentConfig();
const log = await api.modelChangeLog();
set({ agentConfig: cfg, changeLog: log });
} catch {
// silently fail
}
},
loadOfficials: async () => {
try {
const data = await api.officialsStats();
set({ officialsData: data });
} catch {
// silently fail
}
},
loadAgentsStatus: async () => {
try {
const data = await api.agentsStatus();
set({ agentsStatusData: data });
} catch {
set({ agentsStatusData: null });
}
},
loadMorning: async () => {
try {
const [brief, config] = await Promise.all([api.morningBrief(), api.morningConfig()]);
set({ morningBrief: brief, subConfig: config });
} catch {
// silently fail
}
},
loadSubConfig: async () => {
try {
const config = await api.morningConfig();
set({ subConfig: config });
} catch {
// silently fail
}
},
loadAll: async () => {
const s = get();
await s.loadLive();
const tab = s.activeTab;
if (['models', 'skills'].includes(tab)) await s.loadAgentConfig();
},
}));
// ── Countdown & Polling ──
let _cdTimer: ReturnType<typeof setInterval> | null = null;
export function startPolling() {
if (_cdTimer) return;
useStore.getState().loadAll();
_cdTimer = setInterval(() => {
const s = useStore.getState();
const cd = s.countdown - 1;
if (cd <= 0) {
s.setCountdown(5);
s.loadAll();
} else {
s.setCountdown(cd);
}
}, 1000);
}
export function stopPolling() {
if (_cdTimer) {
clearInterval(_cdTimer);
_cdTimer = null;
}
}
// ── Utility ──
export function esc(s: string | undefined | null): string {
if (!s) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function timeAgo(iso: string | undefined): string {
if (!iso) return '';
try {
const d = new Date(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z');
if (isNaN(d.getTime())) return '';
const diff = Date.now() - d.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return '刚刚';
if (mins < 60) return mins + '分钟前';
const hrs = Math.floor(mins / 60);
if (hrs < 24) return hrs + '小时前';
return Math.floor(hrs / 24) + '天前';
} catch {
return '';
}
}
-244
View File
@@ -1,244 +0,0 @@
/* 墨子+ v2.0 Dashboard 全局样式 */
:root {
--bg: #0a1628;
--bg2: #111d33;
--panel: #162038;
--panel2: #1c2a48;
--fg: #e8eaf0;
--muted: #7a8ba8;
--acc: #4a9eff;
--acc2: #6cb6ff;
--line: #253350;
--ok: #22c55e;
--warn: #f59e0b;
--err: #ef4444;
--purple: #818cf8;
--radius: 8px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--fg);
line-height: 1.5;
font-size: 14px;
}
.app {
display: flex;
min-height: 100vh;
}
/* Sidebar */
.sidebar {
width: 200px;
background: var(--bg2);
border-right: 1px solid var(--line);
padding: 16px 0;
flex-shrink: 0;
}
.sidebar-title {
padding: 0 16px 16px;
font-size: 16px;
font-weight: 700;
color: var(--acc);
border-bottom: 1px solid var(--line);
margin-bottom: 8px;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 8px;
}
.nav-item {
padding: 8px 12px;
border-radius: var(--radius);
cursor: pointer;
color: var(--muted);
transition: all 0.15s;
font-size: 13px;
}
.nav-item:hover { background: var(--panel); color: var(--fg); }
.nav-item.active { background: var(--panel2); color: var(--acc); }
/* Main content */
.main {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.page-title {
font-size: 20px;
font-weight: 700;
margin-bottom: 20px;
}
/* Cards */
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 12px;
}
.card-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 8px;
}
/* Task board */
.task-board {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.task-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 14px;
cursor: pointer;
transition: border-color 0.15s;
}
.task-card:hover { border-color: var(--acc); }
.task-card .title {
font-weight: 600;
margin-bottom: 6px;
}
.task-card .meta {
font-size: 12px;
color: var(--muted);
display: flex;
gap: 12px;
}
/* Status badges */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.badge-pending { background: rgba(74, 158, 255, 0.15); color: var(--acc); }
.badge-working { background: rgba(245, 158, 11, 0.15); color: var(--warn); }
.badge-review { background: rgba(129, 140, 248, 0.15); color: var(--purple); }
.badge-done { background: rgba(34, 197, 94, 0.15); color: var(--ok); }
.badge-failed { background: rgba(239, 68, 68, 0.15); color: var(--err); }
.badge-blocked { background: rgba(122, 139, 168, 0.15); color: var(--muted); }
/* Monitor */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 16px;
text-align: center;
}
.stat-card .value {
font-size: 28px;
font-weight: 700;
color: var(--acc);
}
.stat-card .label {
font-size: 12px;
color: var(--muted);
margin-top: 4px;
}
/* Table */
.table-wrapper {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 8px 12px;
border-bottom: 1px solid var(--line);
font-size: 13px;
}
th {
color: var(--muted);
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
}
tr:hover td { background: var(--panel); }
/* Button */
.btn {
padding: 6px 14px;
border-radius: var(--radius);
border: 1px solid var(--line);
background: var(--panel2);
color: var(--fg);
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
}
.btn:hover { border-color: var(--acc); }
.btn-primary { background: var(--acc); color: #fff; border-color: var(--acc); }
.btn-primary:hover { background: var(--acc2); }
/* Loading / Error */
.loading { color: var(--muted); padding: 20px; }
.error { color: var(--err); padding: 20px; }
/* Event log */
.event-log {
max-height: 400px;
overflow-y: auto;
font-family: 'SF Mono', Monaco, monospace;
font-size: 12px;
}
.event-item {
padding: 4px 0;
border-bottom: 1px solid var(--line);
color: var(--muted);
}
.event-item .time {
color: var(--muted);
margin-right: 8px;
}
.event-item .type {
color: var(--acc);
margin-right: 8px;
}
+57
View File
@@ -0,0 +1,57 @@
function pad2(value: number): string {
return String(value).padStart(2, '0');
}
export function parseDashboardTimestamp(value: number | string | undefined | null): Date | null {
if (value === undefined || value === null || value === '') return null;
if (typeof value === 'number') {
const ms = Math.abs(value) < 1e12 ? value * 1000 : value;
const d = new Date(ms);
return Number.isNaN(d.getTime()) ? null : d;
}
const raw = String(value).trim();
if (!raw) return null;
if (/^\d+(\.\d+)?$/.test(raw)) {
return parseDashboardTimestamp(Number(raw));
}
let normalized = raw;
if (normalized.includes(' ') && !normalized.includes('T')) {
normalized = normalized.replace(' ', 'T');
}
const looksIso = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(normalized);
const hasTimezone = /(?:Z|[+\-]\d{2}:\d{2})$/i.test(normalized);
if (looksIso && !hasTimezone) {
normalized += 'Z';
}
const d = new Date(normalized);
return Number.isNaN(d.getTime()) ? null : d;
}
export function formatDashboardTime(
value: number | string | undefined | null,
{ showSeconds = true }: { showSeconds?: boolean } = {}
): string {
const d = parseDashboardTimestamp(value);
if (!d) return '';
const hh = pad2(d.getHours());
const mm = pad2(d.getMinutes());
if (!showSeconds) return `${hh}:${mm}`;
return `${hh}:${mm}:${pad2(d.getSeconds())}`;
}
export function formatDashboardDateTime(
value: number | string | undefined | null,
{ showSeconds = true }: { showSeconds?: boolean } = {}
): string {
const d = parseDashboardTimestamp(value);
if (!d) return '';
const date = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
const time = formatDashboardTime(d.getTime(), { showSeconds });
return `${date} ${time}`;
}