feat: support MCP elicitation and rebuild release
This commit is contained in:
195
public/app.js
195
public/app.js
@@ -256,6 +256,7 @@
|
||||
let directoryPickerState = null;
|
||||
let codexAppUserInputModal = null;
|
||||
let codexAppApprovalModal = null;
|
||||
let codexAppElicitationModal = null;
|
||||
let pendingNewSessionRequest = null;
|
||||
let pendingSessionSwitchRequest = null;
|
||||
let pendingSessionResumeRequest = null;
|
||||
@@ -1462,6 +1463,30 @@
|
||||
return sessions.find((s) => s.id === sessionId) || null;
|
||||
}
|
||||
|
||||
function normalizeGiteaSource(source) {
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) return null;
|
||||
const repository = String(source.repository || source.repo || '').trim().slice(0, 240);
|
||||
const resourceKind = String(source.resourceKind || source.kind || '').trim().toLowerCase();
|
||||
const number = Number(source.number);
|
||||
const title = String(source.title || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
if (!repository && (!Number.isSafeInteger(number) || number <= 0) && !title) return null;
|
||||
return {
|
||||
source: 'gitea',
|
||||
label: 'Gitea',
|
||||
repository,
|
||||
resourceKind: resourceKind === 'pull_request' || resourceKind === 'pr' ? 'pull_request' : 'issue',
|
||||
number: Number.isSafeInteger(number) && number > 0 ? number : null,
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
function giteaResourceLabel(source) {
|
||||
const normalized = normalizeGiteaSource(source);
|
||||
if (!normalized) return 'Gitea';
|
||||
const kind = normalized.resourceKind === 'pull_request' ? 'PR' : 'Issue';
|
||||
return [normalized.repository, normalized.number ? `${kind} #${normalized.number}` : ''].filter(Boolean).join(' · ') || 'Gitea';
|
||||
}
|
||||
|
||||
function compareSessionUpdatedDesc(a, b) {
|
||||
return new Date(b?.updated || 0) - new Date(a?.updated || 0);
|
||||
}
|
||||
@@ -2194,6 +2219,7 @@
|
||||
pinnedAt: payload.pinnedAt || null,
|
||||
titleSource: payload.titleSource || null,
|
||||
createdFromKind: payload.createdFromKind || null,
|
||||
giteaSource: normalizeGiteaSource(payload.giteaSource),
|
||||
hasUnread: !!payload.hasUnread,
|
||||
cwd: payload.cwd || null,
|
||||
projectName: payload.projectName || '',
|
||||
@@ -2310,6 +2336,7 @@
|
||||
pinnedAt: snapshot.pinnedAt || null,
|
||||
titleSource: snapshot.titleSource || null,
|
||||
createdFromKind: snapshot.createdFromKind || null,
|
||||
giteaSource: normalizeGiteaSource(snapshot.giteaSource),
|
||||
hasUnread: !!snapshot.hasUnread,
|
||||
isRunning: !!snapshot.isRunning,
|
||||
waitingOnChildren: !!snapshot.waitingOnChildren,
|
||||
@@ -2336,6 +2363,7 @@
|
||||
title: nextMeta.title || session.title,
|
||||
titleSource: nextMeta.titleSource || session.titleSource || null,
|
||||
createdFromKind: nextMeta.createdFromKind || session.createdFromKind || null,
|
||||
giteaSource: nextMeta.giteaSource || session.giteaSource || null,
|
||||
};
|
||||
});
|
||||
if (!found) {
|
||||
@@ -2364,6 +2392,7 @@
|
||||
snapshot.hasUnread = !!meta.hasUnread;
|
||||
snapshot.updated = meta.updated || snapshot.updated;
|
||||
snapshot.pinnedAt = meta.pinnedAt || null;
|
||||
snapshot.giteaSource = normalizeGiteaSource(meta.giteaSource);
|
||||
snapshot.isRunning = !!meta.isRunning;
|
||||
snapshot.waitingOnChildren = !!meta.waitingOnChildren;
|
||||
snapshot.pendingReplyCount = Number(meta.pendingReplyCount || 0);
|
||||
@@ -2684,6 +2713,124 @@
|
||||
overlay.querySelector('[data-codex-approval-action="approve"]')?.focus();
|
||||
}
|
||||
|
||||
function closeCodexAppElicitationModal(sendCancel = false) {
|
||||
if (!codexAppElicitationModal) return;
|
||||
const { overlay, escapeHandler, requestId, sessionId } = codexAppElicitationModal;
|
||||
if (escapeHandler) document.removeEventListener('keydown', escapeHandler);
|
||||
if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
codexAppElicitationModal = null;
|
||||
if (sendCancel && requestId) {
|
||||
send({
|
||||
type: 'codex_app_elicitation_response',
|
||||
action: 'cancel',
|
||||
sessionId,
|
||||
requestId,
|
||||
content: null,
|
||||
_meta: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function submitCodexAppElicitation(action) {
|
||||
if (!codexAppElicitationModal) return;
|
||||
const { requestId, sessionId, panel, fields, mode } = codexAppElicitationModal;
|
||||
const content = action === 'accept' && mode !== 'url' && panel ? collectCodexAppElicitationContent(panel, fields) : null;
|
||||
send({
|
||||
type: 'codex_app_elicitation_response',
|
||||
action,
|
||||
sessionId,
|
||||
requestId,
|
||||
content,
|
||||
_meta: null,
|
||||
});
|
||||
closeCodexAppElicitationModal(false);
|
||||
}
|
||||
|
||||
function codexAppElicitationOptions(schema = {}) {
|
||||
if (Array.isArray(schema.enum)) return schema.enum.map((value) => ({ value, label: value }));
|
||||
if (Array.isArray(schema.oneOf)) {
|
||||
return schema.oneOf
|
||||
.filter((item) => item && Object.prototype.hasOwnProperty.call(item, 'const'))
|
||||
.map((item) => ({ value: item.const, label: item.title || item.const }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function renderCodexAppElicitationField(id, schema = {}, required = false) {
|
||||
const type = String(schema.type || 'string');
|
||||
const options = codexAppElicitationOptions(schema);
|
||||
const escapedId = escapeHtml(id);
|
||||
const title = schema.title || id;
|
||||
const description = schema.description ? `<span class="codex-elicitation-desc">${escapeHtml(schema.description)}</span>` : '';
|
||||
let control = '';
|
||||
if (type === 'boolean') {
|
||||
control = `<label class="codex-elicitation-check"><input type="checkbox" data-codex-elicit-field="${escapedId}" data-codex-elicit-type="boolean"${schema.default === true ? ' checked' : ''}><span>是</span></label>`;
|
||||
} else if (type === 'array' && (schema.items?.enum || schema.items?.anyOf)) {
|
||||
const itemOptions = codexAppElicitationOptions(schema.items || {});
|
||||
control = itemOptions.map((option, index) => `<label class="codex-elicitation-check"><input type="checkbox" data-codex-elicit-field="${escapedId}" data-codex-elicit-type="array" value="${escapeHtml(option.value)}"${Array.isArray(schema.default) && schema.default.includes(option.value) ? ' checked' : ''}><span>${escapeHtml(option.label || option.value)}</span></label>`).join('');
|
||||
} else if (options.length > 0) {
|
||||
control = `<select class="codex-elicitation-input" data-codex-elicit-field="${escapedId}" data-codex-elicit-type="${escapeHtml(type)}">${options.map((option) => `<option value="${escapeHtml(option.value)}"${String(schema.default) === String(option.value) ? ' selected' : ''}>${escapeHtml(option.label || option.value)}</option>`).join('')}</select>`;
|
||||
} else {
|
||||
const inputType = type === 'number' || type === 'integer' ? 'number' : (schema.format === 'email' ? 'email' : (schema.format === 'date' ? 'date' : (schema.format === 'date-time' ? 'datetime-local' : (schema.format === 'uri' ? 'url' : 'text'))));
|
||||
const isLong = type === 'object' || schema.format === 'multiline' || Number(schema.maxLength || 0) > 180;
|
||||
const value = schema.default === undefined ? '' : (typeof schema.default === 'string' ? schema.default : JSON.stringify(schema.default));
|
||||
control = isLong
|
||||
? `<textarea class="codex-elicitation-input" data-codex-elicit-field="${escapedId}" data-codex-elicit-type="${escapedId ? escapeHtml(type) : 'string'}"${required ? ' required' : ''}>${escapeHtml(value)}</textarea>`
|
||||
: `<input class="codex-elicitation-input" type="${inputType}" data-codex-elicit-field="${escapedId}" data-codex-elicit-type="${escapeHtml(type)}" value="${escapeHtml(value)}"${required ? ' required' : ''}${schema.minLength ? ` minlength="${Number(schema.minLength)}"` : ''}${schema.maxLength ? ` maxlength="${Number(schema.maxLength)}"` : ''}${schema.minimum !== undefined ? ` min="${Number(schema.minimum)}"` : ''}${schema.maximum !== undefined ? ` max="${Number(schema.maximum)}"` : ''}>`;
|
||||
}
|
||||
return `<label class="codex-elicitation-field"><span class="codex-elicitation-label">${escapeHtml(title)}${required ? ' *' : ''}</span>${description}<span class="codex-elicitation-control">${control}</span></label>`;
|
||||
}
|
||||
|
||||
function collectCodexAppElicitationContent(panel, fields = []) {
|
||||
const content = {};
|
||||
for (const field of fields) {
|
||||
const id = String(field.id || '').trim();
|
||||
if (!id) continue;
|
||||
const selector = `[data-codex-elicit-field="${cssEscape(id)}"]`;
|
||||
const controls = Array.from(panel.querySelectorAll(selector));
|
||||
if (!controls.length) continue;
|
||||
const type = String(field.schema?.type || controls[0].dataset.codexElicitType || 'string');
|
||||
if (type === 'boolean') {
|
||||
content[id] = controls[0].checked;
|
||||
} else if (type === 'array') {
|
||||
content[id] = controls.filter((control) => control.checked).map((control) => control.value);
|
||||
} else {
|
||||
const value = controls[0].value;
|
||||
if (type === 'number' || type === 'integer') content[id] = value === '' ? null : Number(value);
|
||||
else if (type === 'object') {
|
||||
try { content[id] = value ? JSON.parse(value) : {}; } catch { content[id] = value; }
|
||||
} else content[id] = value;
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function showCodexAppElicitationModal(msg) {
|
||||
closeCodexAppElicitationModal(true);
|
||||
const mode = String(msg.mode || 'form');
|
||||
const schema = msg.requestedSchema && typeof msg.requestedSchema === 'object' ? msg.requestedSchema : {};
|
||||
const safeUrl = /^https?:\/\//i.test(String(msg.url || '')) ? String(msg.url) : '';
|
||||
const properties = schema.properties && typeof schema.properties === 'object' ? schema.properties : {};
|
||||
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
||||
const fields = Object.entries(properties).map(([id, fieldSchema]) => ({ id, schema: fieldSchema || {} }));
|
||||
const formHtml = mode === 'url'
|
||||
? `<div class="codex-elicitation-url-copy">请在新页面完成 ${escapeHtml(msg.serverName || 'MCP 服务')} 的操作,然后返回此处确认。</div>${safeUrl ? `<a class="codex-elicitation-url" href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer">打开授权页面</a>` : '<div class="modal-empty">MCP 未提供可打开的安全 URL。</div>'}`
|
||||
: (fields.length ? fields.map((field) => renderCodexAppElicitationField(field.id, field.schema, required.has(field.id))).join('') : '<div class="modal-empty">MCP 没有提供可填写的字段。</div>');
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay codex-elicitation-overlay';
|
||||
overlay.innerHTML = `<div class="modal-panel codex-elicitation-panel"><div class="modal-header"><span class="modal-title">${escapeHtml(msg.serverName ? `${msg.serverName} 请求信息` : 'MCP 请求信息')}</span><button class="modal-close-btn" type="button" data-codex-elicitation-cancel>✕</button></div><div class="modal-body codex-elicitation-body">${msg.message ? `<div class="codex-elicitation-message">${escapeHtml(msg.message)}</div>` : ''}<div class="codex-elicitation-mode">${escapeHtml(mode === 'url' ? '外部页面' : '表单')}</div>${formHtml}</div><div class="modal-footer codex-elicitation-footer"><button class="modal-btn-secondary" type="button" data-codex-elicitation-cancel>取消</button><button class="modal-btn-secondary codex-elicitation-decline" type="button" data-codex-elicitation-decline>拒绝</button><button class="modal-btn-primary" type="button" data-codex-elicitation-accept>接受并继续</button></div></div>`;
|
||||
document.body.appendChild(overlay);
|
||||
const panel = overlay.querySelector('.codex-elicitation-panel');
|
||||
const escapeHandler = (event) => { if (event.key === 'Escape') closeCodexAppElicitationModal(true); };
|
||||
document.addEventListener('keydown', escapeHandler);
|
||||
codexAppElicitationModal = { overlay, panel, fields, mode, requestId: msg.requestId || '', sessionId: msg.sessionId || '', escapeHandler };
|
||||
overlay.querySelectorAll('[data-codex-elicitation-cancel]').forEach((button) => button.addEventListener('click', () => closeCodexAppElicitationModal(true)));
|
||||
overlay.addEventListener('click', (event) => { if (event.target === overlay) closeCodexAppElicitationModal(true); });
|
||||
overlay.querySelector('[data-codex-elicitation-decline]')?.addEventListener('click', () => submitCodexAppElicitation('decline'));
|
||||
overlay.querySelector('[data-codex-elicitation-accept]')?.addEventListener('click', () => submitCodexAppElicitation('accept'));
|
||||
overlay.querySelector('input, select, textarea, button')?.focus();
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(String(value || ''));
|
||||
return String(value || '').replace(/["\\]/g, '\\$&');
|
||||
@@ -5703,15 +5850,17 @@
|
||||
const item = document.createElement('div');
|
||||
const isPinned = !!session.pinnedAt;
|
||||
const isLlmCreated = String(session.createdFromKind || '').toLowerCase() === 'mcp';
|
||||
const giteaSource = normalizeGiteaSource(session.giteaSource);
|
||||
const waitingOnChildren = !!session.waitingOnChildren;
|
||||
const readyReplyCount = Number(session.readyReplyCount || 0);
|
||||
const waitingLabel = readyReplyCount > 0 ? `子对话已返回 ${readyReplyCount}` : `等待子对话 ${Number(session.pendingReplyCount || 0) || ''}`.trim();
|
||||
item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}${isLlmCreated ? ' llm-created' : ''}${waitingOnChildren ? ' waiting-children' : ''}`;
|
||||
item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}${isLlmCreated ? ' llm-created' : ''}${giteaSource ? ' gitea-session' : ''}${waitingOnChildren ? ' waiting-children' : ''}`;
|
||||
item.dataset.id = session.id;
|
||||
const sessionProjectName = getSessionProjectName(session);
|
||||
item.title = buildSessionItemTooltip(sessionProjectName, session.title);
|
||||
item.innerHTML = `
|
||||
<div class="session-item-main">
|
||||
${giteaSource ? `<span class="session-item-source-badge" title="${escapeHtml(giteaResourceLabel(giteaSource))}">Gitea</span>` : ''}
|
||||
<span class="session-item-title">${escapeHtml(session.title || 'Untitled')}</span>
|
||||
${isPinned ? '<span class="session-item-pin-badge" title="已置顶">顶</span>' : ''}
|
||||
${session.isRunning ? '<span class="session-item-status">运行中</span>' : ''}
|
||||
@@ -7220,11 +7369,13 @@
|
||||
title: msg.title,
|
||||
...(msg.titleSource !== undefined ? { titleSource: msg.titleSource || null } : {}),
|
||||
...(msg.createdFromKind !== undefined ? { createdFromKind: msg.createdFromKind || null } : {}),
|
||||
...(msg.giteaSource !== undefined ? { giteaSource: normalizeGiteaSource(msg.giteaSource) } : {}),
|
||||
} : session);
|
||||
updateCachedSession(msg.sessionId, (snapshot) => {
|
||||
snapshot.title = msg.title;
|
||||
if (msg.titleSource !== undefined) snapshot.titleSource = msg.titleSource || null;
|
||||
if (msg.createdFromKind !== undefined) snapshot.createdFromKind = msg.createdFromKind || null;
|
||||
if (msg.giteaSource !== undefined) snapshot.giteaSource = normalizeGiteaSource(msg.giteaSource);
|
||||
if (msg.titleEvent) {
|
||||
snapshot.titleHistory = normalizeOutlineTitleHistory([...(snapshot.titleHistory || []), msg.titleEvent]);
|
||||
}
|
||||
@@ -7367,6 +7518,13 @@
|
||||
showCodexAppApprovalModal(msg);
|
||||
break;
|
||||
|
||||
case 'codex_app_elicitation_request':
|
||||
if (msg.sessionId && msg.sessionId !== currentSessionId) {
|
||||
showToast('MCP 请求输入', msg.sessionId);
|
||||
}
|
||||
showCodexAppElicitationModal(msg);
|
||||
break;
|
||||
|
||||
case 'ccweb_mcp_child_agent_update':
|
||||
applyCcwebMcpChildAgentUpdate(msg);
|
||||
break;
|
||||
@@ -8002,14 +8160,30 @@
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function getGiteaVisibleMessageContent(content) {
|
||||
const raw = String(content || '').trim();
|
||||
if (!raw) return '来自 gitea 消息:未提供具体指令,请读取工单上下文。';
|
||||
if (/^来自\s*gitea\s*消息[::]/i.test(raw)) return raw;
|
||||
// 兼容旧会话中已经保存的完整 Workflow Prompt,只提取用户指令段。
|
||||
const match = raw.match(/用户指令:\s*\n([\s\S]*?)(?:\n\n完成后必须使用官方 gitea-mcp|\n\n如果需要用户补充信息|$)/);
|
||||
const instruction = String(match?.[1] || '').trim();
|
||||
return `来自 gitea 消息:${instruction || '已收到工单请求,请读取工单上下文。'}`;
|
||||
}
|
||||
|
||||
function createMsgElement(role, content, attachments = [], meta = {}) {
|
||||
const div = document.createElement('div');
|
||||
const isCrossConversation = !!meta.crossConversation;
|
||||
const isCrossConversationReply = isCrossConversation && !!(meta.crossConversation.reply || meta.crossConversation.replyToRequestId);
|
||||
const canCollapseCrossConversationReply = role === 'assistant' && isCrossConversationReply;
|
||||
const isGoalMessage = role === 'user' && meta?.ccwebGoalCommand?.action === 'set';
|
||||
const hasGiteaPrompt = role === 'user' && typeof content === 'string'
|
||||
&& /你正在执行\s*cc-web\s*Gitea\s*Workflow\s*任务/i.test(content);
|
||||
const giteaSource = normalizeGiteaSource(meta.giteaSource)
|
||||
|| (hasGiteaPrompt ? { source: 'gitea', label: 'Gitea', repository: '', resourceKind: 'issue', number: null, title: '' } : null);
|
||||
const isGiteaMessage = role === 'user' && !!giteaSource;
|
||||
const visibleContent = isGiteaMessage ? getGiteaVisibleMessageContent(content) : content;
|
||||
const resolvedMessageId = meta?.messageId || meta?.id || createLocalId('user');
|
||||
div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}${isCrossConversationReply ? ' cross-conversation-reply' : ''}${isGoalMessage ? ' goal-message' : ''}`;
|
||||
div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}${isCrossConversationReply ? ' cross-conversation-reply' : ''}${isGoalMessage ? ' goal-message' : ''}${isGiteaMessage ? ' gitea-message' : ''}`;
|
||||
if (role === 'user') {
|
||||
div.id = `hapi-message-${resolvedMessageId}`;
|
||||
div.dataset.messageId = resolvedMessageId;
|
||||
@@ -8139,6 +8313,14 @@
|
||||
}
|
||||
|
||||
if (role === 'user') {
|
||||
if (isGiteaMessage) {
|
||||
const giteaLabel = document.createElement('div');
|
||||
giteaLabel.className = 'gitea-message-label';
|
||||
giteaLabel.textContent = 'Gitea';
|
||||
giteaLabel.title = giteaResourceLabel(giteaSource);
|
||||
giteaLabel.setAttribute('aria-label', `消息来源:${giteaResourceLabel(giteaSource)}`);
|
||||
bubble.appendChild(giteaLabel);
|
||||
}
|
||||
if (isGoalMessage) {
|
||||
const goalLabel = document.createElement('div');
|
||||
goalLabel.className = 'goal-message-label';
|
||||
@@ -8146,11 +8328,11 @@
|
||||
goalLabel.setAttribute('aria-label', '目标模式');
|
||||
bubble.appendChild(goalLabel);
|
||||
}
|
||||
if (content) {
|
||||
if (visibleContent) {
|
||||
const textNode = document.createElement('div');
|
||||
textNode.className = 'msg-text';
|
||||
textNode.style.whiteSpace = 'pre-wrap';
|
||||
textNode.textContent = content;
|
||||
textNode.textContent = visibleContent;
|
||||
bubble.appendChild(textNode);
|
||||
|
||||
const copyBtn = document.createElement('button');
|
||||
@@ -8166,7 +8348,7 @@
|
||||
`;
|
||||
copyBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
copyTextToClipboard(content, '用户消息已复制');
|
||||
copyTextToClipboard(visibleContent, '用户消息已复制');
|
||||
});
|
||||
bubble.appendChild(copyBtn);
|
||||
}
|
||||
@@ -8203,7 +8385,7 @@
|
||||
setCodexAppSteerStatusElement(div, meta.codexAppSteerStatus, meta.codexAppSteerMessage);
|
||||
}
|
||||
if (role === 'user') {
|
||||
registerUserMessage(resolvedMessageId, div, content, meta.timestamp);
|
||||
registerUserMessage(resolvedMessageId, div, visibleContent, meta.timestamp);
|
||||
}
|
||||
return div;
|
||||
}
|
||||
@@ -10474,6 +10656,7 @@
|
||||
active: session?.id === currentSessionId,
|
||||
pinnedAt: session?.pinnedAt || '',
|
||||
createdFromKind: String(session?.createdFromKind || '').toLowerCase(),
|
||||
giteaSource: session?.giteaSource ? JSON.stringify(session.giteaSource) : '',
|
||||
isRunning: !!session?.isRunning,
|
||||
hasUnread: !!session?.hasUnread,
|
||||
waitingOnChildren: !!session?.waitingOnChildren,
|
||||
|
||||
476
public/gitea-workflow.css
Normal file
476
public/gitea-workflow.css
Normal file
@@ -0,0 +1,476 @@
|
||||
.gitea-workflow-panel {
|
||||
--gitea-workflow-shell-bg: var(--bg-primary, #f7f8fa);
|
||||
--gitea-workflow-header-bg: var(--surface-strong, #ffffff);
|
||||
--gitea-workflow-surface: var(--surface-strong, #ffffff);
|
||||
--gitea-workflow-surface-soft: var(--bg-secondary, #f1f3f5);
|
||||
--gitea-workflow-surface-strong: color-mix(in srgb, var(--surface-strong, #ffffff) 92%, var(--bg-primary, #f7f8fa));
|
||||
--gitea-workflow-border: var(--border-color, #cbd5e1);
|
||||
--gitea-workflow-border-soft: color-mix(in srgb, var(--border-color, #cbd5e1) 62%, transparent);
|
||||
--gitea-workflow-text: var(--text-primary, #1f2937);
|
||||
--gitea-workflow-secondary: var(--text-secondary, #4b5563);
|
||||
--gitea-workflow-muted: var(--text-muted, #64748b);
|
||||
--gitea-workflow-accent: var(--accent, #4678da);
|
||||
--gitea-workflow-success: var(--success, #257942);
|
||||
--gitea-workflow-danger: var(--danger, #b33c45);
|
||||
--gitea-workflow-info: var(--info, #5b7ea1);
|
||||
--gitea-workflow-accent-soft: color-mix(in srgb, var(--gitea-workflow-accent) 14%, var(--surface-strong, #ffffff));
|
||||
--gitea-workflow-accent-soft-strong: color-mix(in srgb, var(--gitea-workflow-accent) 22%, var(--surface-strong, #ffffff));
|
||||
--gitea-workflow-success-soft: color-mix(in srgb, var(--gitea-workflow-success) 16%, var(--surface-strong, #ffffff));
|
||||
--gitea-workflow-danger-soft: color-mix(in srgb, var(--gitea-workflow-danger) 16%, var(--surface-strong, #ffffff));
|
||||
--gitea-workflow-info-soft: color-mix(in srgb, var(--gitea-workflow-info) 16%, var(--surface-strong, #ffffff));
|
||||
--gitea-workflow-muted-soft: color-mix(in srgb, var(--gitea-workflow-muted) 12%, var(--surface-strong, #ffffff));
|
||||
--gitea-workflow-row-hover: color-mix(in srgb, var(--gitea-workflow-accent) 6%, var(--surface-strong, #ffffff));
|
||||
--gitea-workflow-shadow: 0 24px 56px rgba(0, 0, 0, 0.14);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: var(--gitea-workflow-shell-bg);
|
||||
color: var(--gitea-workflow-text);
|
||||
color-scheme: inherit;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
:is(html[data-theme='washi'], html[data-theme='editorial'], html[data-theme='sage'], html[data-theme='ink'], html[data-theme='dawn']) .gitea-workflow-panel {
|
||||
--gitea-workflow-shell-bg: color-mix(in srgb, var(--bg-primary, #f7f8fa) 94%, var(--surface-strong, #ffffff) 6%);
|
||||
--gitea-workflow-header-bg: color-mix(in srgb, var(--surface-strong, #ffffff) 92%, var(--bg-primary, #f7f8fa) 8%);
|
||||
--gitea-workflow-surface: color-mix(in srgb, var(--surface-strong, #ffffff) 94%, var(--bg-secondary, #f1f3f5) 6%);
|
||||
--gitea-workflow-surface-soft: color-mix(in srgb, var(--bg-secondary, #f1f3f5) 86%, var(--surface-strong, #ffffff) 14%);
|
||||
--gitea-workflow-row-hover: color-mix(in srgb, var(--gitea-workflow-accent) 5%, var(--surface-strong, #ffffff));
|
||||
--gitea-workflow-shadow: 0 22px 50px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder'], html[data-theme='wasteland']) .gitea-workflow-panel {
|
||||
--gitea-workflow-shell-bg: color-mix(in srgb, var(--bg-primary, #0f1314) 94%, var(--surface-strong, #171d1e) 6%);
|
||||
--gitea-workflow-header-bg: color-mix(in srgb, var(--surface-strong, #171d1e) 92%, var(--bg-secondary, #151a1b) 8%);
|
||||
--gitea-workflow-surface: color-mix(in srgb, var(--surface-strong, #171d1e) 96%, var(--bg-secondary, #151a1b) 4%);
|
||||
--gitea-workflow-surface-soft: color-mix(in srgb, var(--bg-secondary, #151a1b) 80%, var(--surface-strong, #171d1e) 20%);
|
||||
--gitea-workflow-row-hover: color-mix(in srgb, var(--gitea-workflow-accent) 8%, var(--surface-strong, #171d1e));
|
||||
--gitea-workflow-shadow: 0 30px 72px rgba(0, 0, 0, 0.36);
|
||||
}
|
||||
|
||||
.gitea-workflow-panel[hidden] { display: none; }
|
||||
|
||||
.gitea-workflow-panel__header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .9rem;
|
||||
min-width: 0;
|
||||
padding: .9rem 1.2rem;
|
||||
border-bottom: 1px solid var(--gitea-workflow-border);
|
||||
background: var(--gitea-workflow-header-bg);
|
||||
box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--surface-strong, #ffffff) 62%, transparent);
|
||||
}
|
||||
|
||||
.gitea-workflow-panel__header > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gitea-workflow-panel__header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.25;
|
||||
letter-spacing: .01em;
|
||||
}
|
||||
|
||||
.gitea-workflow-panel__header p {
|
||||
margin: .16rem 0 0;
|
||||
color: var(--gitea-workflow-secondary);
|
||||
font-size: .78rem;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gitea-workflow-panel__root {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
padding: 1rem clamp(1rem, 3vw, 2.4rem) 2.4rem;
|
||||
}
|
||||
|
||||
.gitea-workflow__refresh,
|
||||
.gitea-workflow__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .28rem;
|
||||
min-height: 2.35rem;
|
||||
padding: .42rem .78rem;
|
||||
border: 1px solid var(--gitea-workflow-border);
|
||||
border-radius: .6rem;
|
||||
background: var(--gitea-workflow-surface);
|
||||
color: var(--gitea-workflow-text);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: .8rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: background-color .16s ease, border-color .16s ease, color .16s ease, box-shadow .16s ease, transform .16s ease;
|
||||
}
|
||||
|
||||
.gitea-workflow__refresh:hover:not(:disabled),
|
||||
.gitea-workflow__button:hover:not(:disabled) {
|
||||
border-color: var(--gitea-workflow-accent);
|
||||
background: var(--gitea-workflow-row-hover);
|
||||
}
|
||||
|
||||
.gitea-workflow__refresh:focus-visible,
|
||||
.gitea-workflow__button:focus-visible,
|
||||
.gitea-workflow__config-grid input:focus-visible,
|
||||
.gitea-workflow__operator input:focus-visible,
|
||||
.gitea-workflow__reason input:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--gitea-workflow-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--gitea-workflow-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
.gitea-workflow__refresh:disabled,
|
||||
.gitea-workflow__button:disabled {
|
||||
opacity: .6;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.gitea-workflow__refresh {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gitea-workflow__toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.gitea-workflow__toolbar > div:first-child {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gitea-workflow__toolbar strong {
|
||||
font-size: .98rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.gitea-workflow__toolbar small {
|
||||
color: var(--gitea-workflow-secondary);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gitea-workflow__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: .45rem;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gitea-workflow__config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||||
gap: .75rem;
|
||||
padding: .9rem;
|
||||
}
|
||||
|
||||
.gitea-workflow__config-grid label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .28rem;
|
||||
min-width: 0;
|
||||
color: var(--gitea-workflow-secondary);
|
||||
font-size: .72rem;
|
||||
}
|
||||
|
||||
.gitea-workflow__config-grid input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--gitea-workflow-border);
|
||||
border-radius: .48rem;
|
||||
padding: .45rem .58rem;
|
||||
background: var(--gitea-workflow-surface);
|
||||
color: var(--gitea-workflow-text);
|
||||
font: inherit;
|
||||
line-height: 1.3;
|
||||
transition: background-color .16s ease, border-color .16s ease, box-shadow .16s ease, color .16s ease;
|
||||
}
|
||||
|
||||
.gitea-workflow__config-grid input::placeholder {
|
||||
color: var(--gitea-workflow-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.gitea-workflow__config-actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.gitea-workflow__button[data-danger] {
|
||||
border-color: color-mix(in srgb, var(--gitea-workflow-danger) 30%, var(--gitea-workflow-border));
|
||||
background: var(--gitea-workflow-danger-soft);
|
||||
color: var(--gitea-workflow-danger);
|
||||
}
|
||||
|
||||
.gitea-workflow__button[data-danger]:hover:not(:disabled) {
|
||||
border-color: var(--gitea-workflow-danger);
|
||||
background: color-mix(in srgb, var(--gitea-workflow-danger) 22%, var(--surface-strong, #ffffff));
|
||||
}
|
||||
|
||||
.gitea-workflow__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: .72rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.gitea-workflow__metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .18rem;
|
||||
min-width: 0;
|
||||
padding: .82rem .9rem;
|
||||
border: 1px solid var(--gitea-workflow-border);
|
||||
border-radius: .72rem;
|
||||
background:
|
||||
linear-gradient(180deg, var(--gitea-workflow-surface), var(--gitea-workflow-surface-soft));
|
||||
box-shadow: 0 1px 0 color-mix(in srgb, var(--surface-strong, #ffffff) 72%, transparent);
|
||||
}
|
||||
|
||||
.gitea-workflow__metric span {
|
||||
display: block;
|
||||
color: var(--gitea-workflow-secondary);
|
||||
font-size: .72rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.gitea-workflow__metric strong {
|
||||
display: block;
|
||||
margin-top: .16rem;
|
||||
font-size: 1.38rem;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -.02em;
|
||||
}
|
||||
|
||||
.gitea-workflow__section {
|
||||
margin-top: 1rem;
|
||||
border: 1px solid var(--gitea-workflow-border);
|
||||
border-radius: .78rem;
|
||||
background: var(--gitea-workflow-surface);
|
||||
box-shadow: var(--gitea-workflow-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gitea-workflow__section h3 {
|
||||
margin: 0;
|
||||
padding: .78rem .95rem;
|
||||
border-bottom: 1px solid var(--gitea-workflow-border);
|
||||
background: color-mix(in srgb, var(--gitea-workflow-surface-soft) 72%, var(--gitea-workflow-surface) 28%);
|
||||
font-size: .85rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.gitea-workflow__table-wrap {
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.gitea-workflow__table {
|
||||
width: 100%;
|
||||
min-width: 760px;
|
||||
border-collapse: collapse;
|
||||
font-size: .78rem;
|
||||
}
|
||||
|
||||
.gitea-workflow__table th,
|
||||
.gitea-workflow__table td {
|
||||
padding: .62rem .72rem;
|
||||
border-bottom: 1px solid var(--gitea-workflow-border-soft);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.gitea-workflow__table th {
|
||||
color: var(--gitea-workflow-secondary);
|
||||
font-size: .7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: .01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gitea-workflow__table td {
|
||||
color: var(--gitea-workflow-text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gitea-workflow__table tbody tr {
|
||||
transition: background-color .14s ease;
|
||||
}
|
||||
|
||||
.gitea-workflow__table tbody tr:hover td {
|
||||
background: var(--gitea-workflow-row-hover);
|
||||
}
|
||||
|
||||
.gitea-workflow__table tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.gitea-workflow__mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: .72rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gitea-workflow__muted {
|
||||
color: var(--gitea-workflow-muted);
|
||||
}
|
||||
|
||||
.gitea-workflow__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .28rem;
|
||||
min-height: 1.45rem;
|
||||
padding: .18rem .5rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: var(--gitea-workflow-accent-soft);
|
||||
color: var(--gitea-workflow-accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gitea-workflow__pill[data-state="active"],
|
||||
.gitea-workflow__pill[data-state="enabled"],
|
||||
.gitea-workflow__pill[data-state="online"],
|
||||
.gitea-workflow__pill[data-state="ready"] {
|
||||
background: var(--gitea-workflow-success-soft);
|
||||
color: var(--gitea-workflow-success);
|
||||
}
|
||||
|
||||
.gitea-workflow__pill[data-state="queued"],
|
||||
.gitea-workflow__pill[data-state="preparing"] {
|
||||
background: var(--gitea-workflow-info-soft);
|
||||
color: var(--gitea-workflow-info);
|
||||
}
|
||||
|
||||
.gitea-workflow__pill[data-state="running"],
|
||||
.gitea-workflow__pill[data-state="verifying_reply"] {
|
||||
background: color-mix(in srgb, var(--gitea-workflow-info) 18%, var(--surface-strong, #ffffff));
|
||||
color: var(--gitea-workflow-info);
|
||||
}
|
||||
|
||||
.gitea-workflow__pill[data-state="waiting_user"],
|
||||
.gitea-workflow__pill[data-state="retry_wait"] {
|
||||
background: color-mix(in srgb, var(--gitea-workflow-accent) 18%, var(--surface-strong, #ffffff));
|
||||
color: var(--gitea-workflow-accent);
|
||||
}
|
||||
|
||||
.gitea-workflow__pill[data-state="blocked_workspace"],
|
||||
.gitea-workflow__pill[data-state="failed"],
|
||||
.gitea-workflow__pill[data-state="failed_reply"],
|
||||
.gitea-workflow__pill[data-state="aborting"] {
|
||||
background: var(--gitea-workflow-danger-soft);
|
||||
color: var(--gitea-workflow-danger);
|
||||
}
|
||||
|
||||
.gitea-workflow__pill[data-state="succeeded"],
|
||||
.gitea-workflow__pill[data-state="succeeded_with_rest_fallback"] {
|
||||
background: var(--gitea-workflow-success-soft);
|
||||
color: var(--gitea-workflow-success);
|
||||
}
|
||||
|
||||
.gitea-workflow__pill[data-state="cancelled"],
|
||||
.gitea-workflow__pill[data-state="aborted"],
|
||||
.gitea-workflow__pill[data-state="disabled"] {
|
||||
background: var(--gitea-workflow-muted-soft);
|
||||
color: var(--gitea-workflow-muted);
|
||||
}
|
||||
|
||||
.gitea-workflow__dirty {
|
||||
max-width: 18rem;
|
||||
color: color-mix(in srgb, var(--gitea-workflow-accent) 58%, var(--gitea-workflow-secondary));
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gitea-workflow__log {
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
padding: .2rem .9rem .7rem;
|
||||
}
|
||||
|
||||
.gitea-workflow__log-entry {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(9rem, 10rem) minmax(4.25rem, 5rem) minmax(0, 1fr);
|
||||
gap: .55rem;
|
||||
padding: .42rem 0;
|
||||
border-bottom: 1px dashed var(--gitea-workflow-border-soft);
|
||||
font-size: .74rem;
|
||||
}
|
||||
|
||||
.gitea-workflow__log-entry:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.gitea-workflow__empty {
|
||||
padding: 1rem;
|
||||
color: var(--gitea-workflow-muted);
|
||||
text-align: center;
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.gitea-workflow__error {
|
||||
margin-bottom: .75rem;
|
||||
padding: .68rem .82rem;
|
||||
border: 1px solid color-mix(in srgb, var(--gitea-workflow-danger) 28%, var(--gitea-workflow-border));
|
||||
border-radius: .6rem;
|
||||
background: var(--gitea-workflow-danger-soft);
|
||||
color: color-mix(in srgb, var(--gitea-workflow-danger) 82%, var(--gitea-workflow-text));
|
||||
font-size: .8rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.gitea-workflow__metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.gitea-workflow-panel__header {
|
||||
padding: .8rem .85rem;
|
||||
}
|
||||
|
||||
.gitea-workflow-panel__root {
|
||||
padding: .85rem .8rem 1.35rem;
|
||||
}
|
||||
|
||||
.gitea-workflow__toolbar {
|
||||
gap: .8rem;
|
||||
}
|
||||
|
||||
.gitea-workflow__config-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.gitea-workflow__log-entry {
|
||||
grid-template-columns: 1fr;
|
||||
gap: .12rem;
|
||||
}
|
||||
|
||||
.gitea-workflow__table {
|
||||
min-width: 720px;
|
||||
}
|
||||
}
|
||||
161
public/gitea-workflow.js
Normal file
161
public/gitea-workflow.js
Normal file
@@ -0,0 +1,161 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const openButton = $('#gitea-workflow-open');
|
||||
const panel = $('#gitea-workflow-panel');
|
||||
const root = $('#gitea-workflow-root');
|
||||
const status = $('#gitea-workflow-status');
|
||||
const refreshButton = $('#gitea-workflow-refresh');
|
||||
const closeButton = $('#gitea-workflow-close');
|
||||
if (!openButton || !panel || !root) return;
|
||||
|
||||
const SYSTEM_AUDIT_ACTOR = 'system';
|
||||
const SYSTEM_AUDIT_REASON = 'cc-web 管理页自动操作';
|
||||
|
||||
let overview = null;
|
||||
let loading = false;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||
.replaceAll('"', '"').replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function token() { return localStorage.getItem('cc-web-token') || ''; }
|
||||
function api(path, init = {}) {
|
||||
const headers = { ...(init.body ? { 'Content-Type': 'application/json' } : {}), ...(init.headers || {}) };
|
||||
if (token()) headers.Authorization = `Bearer ${token()}`;
|
||||
return fetch(path, { ...init, headers }).then(async (response) => {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw Object.assign(new Error(data.message || data.code || `HTTP ${response.status}`), { response, data });
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
function stateLabel(task) {
|
||||
return task.stateLabel || ({ queued: '排队中', preparing: '准备中', running: '运行中', waiting_user: '等待用户',
|
||||
verifying_reply: '核验回帖', retry_wait: '等待重试', blocked_workspace: '工作区阻塞', aborting: '中止中',
|
||||
succeeded: '已完成', succeeded_with_rest_fallback: '已完成(REST 兜底)', failed: '失败', failed_reply: '回帖失败',
|
||||
cancelled: '已取消', aborted: '已中止' }[task.state] || task.state);
|
||||
}
|
||||
|
||||
function date(value) {
|
||||
if (!value) return '—';
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? escapeHtml(value) : escapeHtml(parsed.toLocaleString());
|
||||
}
|
||||
|
||||
function actionLabel(value) {
|
||||
const labels = {
|
||||
'admin.pause': '暂停工作流',
|
||||
'admin.resume': '恢复工作流',
|
||||
'admin.enable': '启用仓库',
|
||||
'admin.disable': '停用仓库',
|
||||
'admin.cancel': '取消排队',
|
||||
'admin.abort': '中止 Turn',
|
||||
'admin.configuration_updated': '更新配置',
|
||||
};
|
||||
return labels[value] || String(value || '工作流操作').replace(/^admin\./, '');
|
||||
}
|
||||
|
||||
function systemAuditFields(requestId = '') {
|
||||
const payload = {
|
||||
actor: SYSTEM_AUDIT_ACTOR,
|
||||
reason: SYSTEM_AUDIT_REASON,
|
||||
};
|
||||
if (requestId) payload.requestId = requestId;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function actionButtonLabel(paused) {
|
||||
return paused ? '恢复全局' : '暂停全局';
|
||||
}
|
||||
|
||||
function render() {
|
||||
const data = overview || { summary: {}, control: {}, repositories: [], tasks: [], audits: [], logs: [] };
|
||||
const control = data.control || {};
|
||||
const summary = data.summary || {};
|
||||
const paused = !!control.globalPaused;
|
||||
const repositories = Array.isArray(data.repositories) ? data.repositories : [];
|
||||
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
|
||||
const audits = Array.isArray(data.audits) ? data.audits : [];
|
||||
const logs = Array.isArray(data.logs) ? data.logs : [];
|
||||
const settings = data.settings || {};
|
||||
root.innerHTML = `<div class="gitea-workflow__toolbar">
|
||||
<div><strong>${paused ? '全局已暂停' : '工作流运行中'}</strong><br><small>${paused ? '工作流已暂停' : '暂停只阻止新任务领取,运行中任务不受影响'} · ${date(data.generatedAt)}</small></div>
|
||||
<div class="gitea-workflow__actions"><button class="gitea-workflow__button" data-action="${paused ? 'resume' : 'pause'}">${actionButtonLabel(paused)}</button></div>
|
||||
</div>
|
||||
${!data.available ? '<div class="gitea-workflow__error">核心 Workflow 服务尚未挂接:当前页面只展示已持久化的镜像状态,控制操作会安全返回 503。</div>' : ''}
|
||||
<section class="gitea-workflow__section"><h3>Gitea 连接与工作区配置</h3><div class="gitea-workflow__config-grid">
|
||||
<label>Gitea 地址<input id="gitea-config-host" value="${escapeHtml(settings.host || '')}" placeholder="https://gitea.example"></label>
|
||||
<label>Bot 用户名<input id="gitea-config-bot-login" value="${escapeHtml(settings.botLogin || 'ccweb-bot')}" placeholder="ccweb-bot"></label>
|
||||
<label>工作区根目录<input id="gitea-config-workspace" value="${escapeHtml(settings.workspaceRoot || '')}" placeholder="/var/lib/ccweb/workspaces"></label>
|
||||
<label>默认分支覆盖<input id="gitea-config-branch" value="${escapeHtml(settings.defaultBranch || '')}" placeholder="留空使用仓库默认分支"></label>
|
||||
<label>Bot Token<input id="gitea-config-bot-token" type="password" placeholder="${settings.botTokenConfigured ? '已配置,留空保持不变' : '填写评论/Git Token'}"></label>
|
||||
<div class="gitea-workflow__config-actions"><button class="gitea-workflow__button" data-config-save>保存配置</button></div>
|
||||
</div></section>
|
||||
<div class="gitea-workflow__metrics"><div class="gitea-workflow__metric"><span>运行中</span><strong>${Number(summary.running || 0)}</strong></div><div class="gitea-workflow__metric"><span>队列</span><strong>${Number(summary.queued || 0)}</strong></div><div class="gitea-workflow__metric"><span>仓库</span><strong>${Number(summary.repositories || 0)}</strong></div><div class="gitea-workflow__metric"><span>脏目录 / 阻塞</span><strong>${Number(summary.blocked || 0)}</strong></div></div>
|
||||
<section class="gitea-workflow__section"><h3>仓库与工作区</h3><div class="gitea-workflow__table-wrap"><table class="gitea-workflow__table"><thead><tr><th>仓库</th><th>状态</th><th>工作区</th><th>脏目录原因</th><th>队列</th><th>操作</th></tr></thead><tbody>${repositories.length ? repositories.map((repo) => `<tr><td><strong>${escapeHtml(repo.owner ? `${repo.owner}/${repo.name}` : repo.repoKey)}</strong><br><span class="gitea-workflow__muted gitea-workflow__mono">${escapeHtml(repo.repoKey)}</span></td><td><span class="gitea-workflow__pill" data-state="${escapeHtml(repo.status)}">${repo.enabled ? '启用' : '停用'}</span></td><td class="gitea-workflow__mono">${escapeHtml(repo.workspacePath || '—')}</td><td class="gitea-workflow__dirty">${repo.dirty ? escapeHtml(repo.dirtyReason || '检测到未提交修改或冲突') : '—'}</td><td>${Number(repo.queuedCount || 0)}</td><td><button class="gitea-workflow__button" data-repo-action="${repo.enabled ? 'disable' : 'enable'}" data-repo-key="${escapeHtml(repo.repoKey)}">${repo.enabled ? '停用' : '启用'}</button></td></tr>`).join('') : '<tr><td colspan="6" class="gitea-workflow__empty">暂无仓库记录</td></tr>'}</tbody></table></div></section>
|
||||
<section class="gitea-workflow__section"><h3>任务队列与 Turn</h3><div class="gitea-workflow__table-wrap"><table class="gitea-workflow__table"><thead><tr><th>任务 / 资源</th><th>状态</th><th>Turn</th><th>脏目录原因</th><th>更新时间</th><th>操作</th></tr></thead><tbody>${tasks.length ? tasks.map((task) => `<tr><td><strong class="gitea-workflow__mono">${escapeHtml(task.taskId || '—')}</strong><br><span class="gitea-workflow__muted">${escapeHtml(task.resourceKey || task.repoKey || '—')}</span></td><td><span class="gitea-workflow__pill" data-state="${escapeHtml(task.state)}">${escapeHtml(stateLabel(task))}</span>${task.errorMessage ? `<br><span class="gitea-workflow__dirty">${escapeHtml(task.errorMessage)}</span>` : ''}</td><td><span class="gitea-workflow__mono">${escapeHtml(task.turnId || '—')}</span><br><span class="gitea-workflow__muted">${escapeHtml(task.turnState || '—')}</span></td><td class="gitea-workflow__dirty">${escapeHtml(task.dirtyReason || '—')}</td><td>${date(task.updatedAt || task.turnUpdatedAt || task.createdAt)}</td><td>${task.state === 'queued' || task.state === 'waiting_user' ? `<button class="gitea-workflow__button" data-task-action="cancel" data-task-id="${escapeHtml(task.taskId)}">取消排队</button>` : ''}${task.state === 'running' || task.state === 'preparing' || task.state === 'aborting' ? `<button class="gitea-workflow__button" data-danger data-task-action="abort" data-task-id="${escapeHtml(task.taskId)}">中止 Turn</button>` : ''}</td></tr>`).join('') : '<tr><td colspan="6" class="gitea-workflow__empty">暂无任务记录</td></tr>'}</tbody></table></div></section>
|
||||
<section class="gitea-workflow__section"><h3>运行日志</h3><div class="gitea-workflow__log">${logs.length ? logs.slice().reverse().map((entry) => `<div class="gitea-workflow__log-entry"><span class="gitea-workflow__muted">${date(entry.timestamp)}</span><span>${escapeHtml(entry.level)}</span><span>${escapeHtml(entry.message || entry.event || '—')}</span></div>`).join('') : '<div class="gitea-workflow__empty">暂无日志</div>'}</div></section>
|
||||
<section class="gitea-workflow__section"><h3>操作记录</h3><div class="gitea-workflow__table-wrap"><table class="gitea-workflow__table"><thead><tr><th>时间</th><th>动作</th><th>任务 / 仓库</th></tr></thead><tbody>${audits.length ? audits.slice().reverse().map((entry) => `<tr><td>${date(entry.timestamp)}</td><td class="gitea-workflow__mono">${escapeHtml(actionLabel(entry.action))}</td><td class="gitea-workflow__mono">${escapeHtml(entry.taskId || entry.repoKey || '—')}</td></tr>`).join('') : '<tr><td colspan="3" class="gitea-workflow__empty">暂无记录</td></tr>'}</tbody></table></div></section>`;
|
||||
root.querySelectorAll('[data-action], [data-repo-action], [data-task-action], [data-config-save]').forEach((button) => button.addEventListener('click', button.dataset.configSave !== undefined ? saveConfig : onAction));
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
status.textContent = '正在刷新工作流状态…';
|
||||
try {
|
||||
const [nextOverview, config] = await Promise.all([api('/api/gitea-workflow/overview'), api('/api/gitea-workflow/config')]);
|
||||
overview = { ...nextOverview, settings: config.settings || {} };
|
||||
render();
|
||||
status.textContent = overview.available ? '状态已更新' : '核心服务未挂接,显示镜像状态';
|
||||
} catch (error) {
|
||||
root.innerHTML = `<div class="gitea-workflow__error">加载失败:${escapeHtml(error.message)}</div>`;
|
||||
status.textContent = '加载失败';
|
||||
} finally { loading = false; }
|
||||
}
|
||||
|
||||
async function saveConfig(event) {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await api('/api/gitea-workflow/config', { method: 'PUT', body: JSON.stringify({
|
||||
...systemAuditFields(),
|
||||
host: $('#gitea-config-host')?.value || '', botLogin: $('#gitea-config-bot-login')?.value || '',
|
||||
workspaceRoot: $('#gitea-config-workspace')?.value || '', defaultBranch: $('#gitea-config-branch')?.value || '',
|
||||
botToken: $('#gitea-config-bot-token')?.value || '',
|
||||
}) });
|
||||
await load();
|
||||
} catch (error) { window.alert(`配置保存失败:${error.message}`); } finally { button.disabled = false; }
|
||||
}
|
||||
|
||||
async function onAction(event) {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
try {
|
||||
let path;
|
||||
let method = 'POST';
|
||||
if (button.dataset.action) path = `/api/gitea-workflow/control/${button.dataset.action}`;
|
||||
if (button.dataset.repoAction) path = `/api/gitea-workflow/repos/${encodeURIComponent(button.dataset.repoKey)}/${button.dataset.repoAction}`;
|
||||
if (button.dataset.taskAction) path = `/api/gitea-workflow/tasks/${encodeURIComponent(button.dataset.taskId)}/${button.dataset.taskAction}`;
|
||||
await api(path, { method, body: JSON.stringify({
|
||||
...systemAuditFields(window.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`),
|
||||
}) });
|
||||
await load();
|
||||
} catch (error) {
|
||||
window.alert(`操作未执行:${error.message}`);
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
panel.hidden = false; panel.setAttribute('aria-hidden', 'false'); openButton.setAttribute('aria-expanded', 'true'); load();
|
||||
}
|
||||
function close() { panel.hidden = true; panel.setAttribute('aria-hidden', 'true'); openButton.setAttribute('aria-expanded', 'false'); }
|
||||
openButton.addEventListener('click', open);
|
||||
closeButton?.addEventListener('click', close);
|
||||
refreshButton?.addEventListener('click', load);
|
||||
})();
|
||||
@@ -26,6 +26,7 @@
|
||||
</script>
|
||||
<link rel="stylesheet" href="style.css?v=20260818-goal-mode-label">
|
||||
<link rel="stylesheet" href="task-board.css?v=__CC_WEB_FRONTEND_ASSET_VERSION__">
|
||||
<link rel="stylesheet" href="gitea-workflow.css?v=__CC_WEB_GITEA_WORKFLOW_ASSET_VERSION__">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -78,6 +79,9 @@
|
||||
<button id="task-board-open" class="usage-dashboard-open" type="button" title="任务看板" aria-label="打开任务看板" aria-controls="task-board-panel" aria-expanded="false" hidden>
|
||||
<span aria-hidden="true">▦</span>
|
||||
</button>
|
||||
<button id="gitea-workflow-open" class="usage-dashboard-open" type="button" title="Gitea Workflow" aria-label="打开 Gitea Workflow 管理" aria-controls="gitea-workflow-panel" aria-expanded="false">
|
||||
<span aria-hidden="true">⌘</span>
|
||||
</button>
|
||||
</div>
|
||||
<span class="brand">CC-Web</span>
|
||||
</div>
|
||||
@@ -129,6 +133,18 @@
|
||||
<div id="task-board-root" class="task-board-panel__root"></div>
|
||||
</section>
|
||||
|
||||
<section id="gitea-workflow-panel" class="gitea-workflow-panel" role="dialog" aria-labelledby="gitea-workflow-title" aria-hidden="true" hidden>
|
||||
<header class="gitea-workflow-panel__header">
|
||||
<button id="gitea-workflow-close" type="button" class="usage-dashboard__icon-button" title="返回聊天" aria-label="返回聊天">‹</button>
|
||||
<div>
|
||||
<h2 id="gitea-workflow-title">Gitea Workflow</h2>
|
||||
<p id="gitea-workflow-status" role="status" aria-live="polite">正在加载工作流状态…</p>
|
||||
</div>
|
||||
<button id="gitea-workflow-refresh" type="button" class="gitea-workflow__refresh">刷新</button>
|
||||
</header>
|
||||
<div id="gitea-workflow-root" class="gitea-workflow-panel__root"></div>
|
||||
</section>
|
||||
|
||||
<!-- Slash command menu -->
|
||||
<div id="cmd-menu" class="cmd-menu" hidden></div>
|
||||
|
||||
@@ -401,6 +417,7 @@
|
||||
<script src="vendor/echarts.min.js?v=5.6.0"></script>
|
||||
<script>window.ccWebFrontendAssetVersion = '__CC_WEB_FRONTEND_ASSET_VERSION__';</script>
|
||||
<script src="task-board.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__"></script>
|
||||
<script src="gitea-workflow.js?v=__CC_WEB_GITEA_WORKFLOW_ASSET_VERSION__"></script>
|
||||
<script src="app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
148
public/style.css
148
public/style.css
@@ -1505,6 +1505,16 @@ body.session-loading-active {
|
||||
border-radius: 4px 0 0 0;
|
||||
opacity: 0.88;
|
||||
}
|
||||
.session-item.gitea-session:not(.pinned)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 3px;
|
||||
border-radius: 999px;
|
||||
background: #3f8f73;
|
||||
}
|
||||
.session-item-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -1520,6 +1530,20 @@ body.session-loading-active {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.session-item-source-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid rgba(63, 143, 115, 0.34);
|
||||
border-radius: 5px;
|
||||
background: rgba(63, 143, 115, 0.12);
|
||||
color: #2d755e;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.session-item.active .session-item-title { color: var(--accent); font-weight: 500; }
|
||||
.session-item-pin-badge {
|
||||
flex-shrink: 0;
|
||||
@@ -2799,6 +2823,10 @@ html[data-theme='wasteland'] .advanced-search-panel {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.msg.user .msg-avatar { background: var(--bg-bubble-user); color: #fff; }
|
||||
.msg.gitea-message .msg-avatar {
|
||||
background: #3f8f73;
|
||||
color: #fff;
|
||||
}
|
||||
.msg.assistant .msg-avatar { background: var(--success); color: #fff; }
|
||||
.msg-avatar svg { display: block; flex-shrink: 0; }
|
||||
/* Claude avatar: transparent bg, fixed-color pixel crab */
|
||||
@@ -2991,6 +3019,31 @@ html[data-theme='wasteland'] .advanced-search-panel {
|
||||
border-bottom-right-radius: 4px;
|
||||
padding-right: 42px;
|
||||
}
|
||||
.msg.gitea-message .msg-bubble {
|
||||
border: 1px solid rgba(63, 143, 115, 0.36);
|
||||
box-shadow: 0 8px 18px rgba(63, 143, 115, 0.12);
|
||||
}
|
||||
.gitea-message-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
gap: 5px;
|
||||
margin: -2px 0 7px;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid rgba(63, 143, 115, 0.34);
|
||||
border-radius: 5px;
|
||||
background: rgba(63, 143, 115, 0.16);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.gitea-message-label::before {
|
||||
content: '↗';
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
.msg.user.goal-message .msg-bubble {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -6403,6 +6456,101 @@ html[data-theme='coolvibe'] .settings-back:hover {
|
||||
.codex-approval-deny-btn {
|
||||
color: var(--danger);
|
||||
}
|
||||
.codex-elicitation-panel {
|
||||
max-width: 620px;
|
||||
}
|
||||
.codex-elicitation-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-height: min(68vh, 680px);
|
||||
overflow: auto;
|
||||
}
|
||||
.codex-elicitation-message {
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.codex-elicitation-mode {
|
||||
align-self: flex-start;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 999px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.codex-elicitation-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.codex-elicitation-label {
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.codex-elicitation-desc {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.codex-elicitation-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
.codex-elicitation-input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.codex-elicitation-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
textarea.codex-elicitation-input {
|
||||
min-height: 90px;
|
||||
resize: vertical;
|
||||
}
|
||||
.codex-elicitation-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.codex-elicitation-url-copy {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.codex-elicitation-url {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
.codex-elicitation-url:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.codex-elicitation-footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.codex-elicitation-decline {
|
||||
color: var(--danger);
|
||||
}
|
||||
.modal-quick-picks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user