mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-01 08:37:41 +02:00
Add files via upload
This commit is contained in:
+26
-21
@@ -49,35 +49,40 @@ monitor:
|
||||
# 对话相关配置
|
||||
# ============================================
|
||||
|
||||
# AI 模型配置(支持 OpenAI 兼容 API)
|
||||
# 必填项:api_key, base_url, model 必须填写才能正常运行
|
||||
# AI 通道配置(支持保存多个 OpenAI 兼容 / Claude 通道)
|
||||
# default_channel 指定新对话与未显式选择通道的任务使用哪个通道。
|
||||
# 每个 channels.<id> 必填:api_key, base_url, model。
|
||||
# 支持的 API 服务商:
|
||||
# - OpenAI: https://api.openai.com/v1
|
||||
# - DeepSeek: https://api.deepseek.com/v1
|
||||
# - 其他兼容 OpenAI 协议的 API
|
||||
# 常用模型: gpt-4, gpt-3.5-turbo, deepseek-chat, claude-3-opus 等
|
||||
# provider: 可选值 openai(默认) | claude(自动桥接到 Anthropic Claude Messages API)
|
||||
openai:
|
||||
provider: openai # API 提供商: openai(默认,兼容OpenAI协议) | claude(自动桥接到Anthropic Claude Messages API)
|
||||
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1 # API 基础 URL(必填)
|
||||
api_key: sk-xxxxxxx # API 密钥(必填)
|
||||
model: qwen3-max # 模型名称(必填)
|
||||
max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置)
|
||||
max_completion_tokens: 16384 # 单次生成上限(含 reasoning 与可见输出),防止依赖网关隐式默认值
|
||||
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort;Claude 4.6+ 为 adaptive + output_config.effort(仅显式配置 effort 时下发);3.7 为 enabled+budget_tokens:10000(文档示例),effort 不映射,自定义预算用 extra_request_fields
|
||||
reasoning:
|
||||
mode: on # auto | on | off;off:OpenAI/Claude 不附加推理字段,DeepSeek 发送 thinking.type=disabled(其默认开启思考)
|
||||
effort: high # low | medium | high | max | xhigh(最高档:OpenAI 常用 xhigh,部分网关用 max,原样下发);空表示不指定
|
||||
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
|
||||
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
|
||||
# extra_request_fields: {} # 可选:管理员自定义根级 JSON 片段(高级)
|
||||
# provider: 可选值 openai_compatible(默认) | claude(自动桥接到 Anthropic Claude Messages API)
|
||||
ai:
|
||||
default_channel: qwen-max
|
||||
channels:
|
||||
qwen-max:
|
||||
name: Qwen Max
|
||||
provider: openai_compatible
|
||||
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
api_key: sk-xxxxxxx
|
||||
model: qwen3-max
|
||||
max_total_tokens: 120000
|
||||
max_completion_tokens: 16384
|
||||
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort;Claude 4.6+ 为 adaptive + output_config.effort(仅显式配置 effort 时下发);3.7 为 enabled+budget_tokens:10000(文档示例),effort 不映射,自定义预算用 extra_request_fields
|
||||
reasoning:
|
||||
mode: on # auto | on | off;off:OpenAI/Claude 不附加推理字段,DeepSeek 发送 thinking.type=disabled(其默认开启思考)
|
||||
effort: high # low | medium | high | max | xhigh(最高档:OpenAI 常用 xhigh,部分网关用 max,原样下发);空表示不指定
|
||||
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
|
||||
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
|
||||
# extra_request_fields: {} # 可选:管理员自定义根级 JSON 片段(高级)
|
||||
# 视觉分析(analyze_image MCP 工具;图片仅在单次 VL 调用中出现,Agent 上下文只保留文字摘要)
|
||||
vision:
|
||||
enabled: false # true 且 model 非空时注册 analyze_image
|
||||
model: qwen-vl # VL 模型名(enabled 时必填)
|
||||
api_key: "" # 留空则复用 openai.api_key
|
||||
base_url: "" # 留空则复用 openai.base_url
|
||||
provider: # 留空则复用 openai.provider(openai | claude)
|
||||
api_key: "" # 留空则复用默认 AI 通道 api_key
|
||||
base_url: "" # 留空则复用默认 AI 通道 base_url
|
||||
provider: # 留空则复用默认 AI 通道 provider(openai_compatible | claude)
|
||||
max_image_bytes: 5242880 # 原始文件上限(字节),默认 5MB
|
||||
max_dimension: 2048 # 长边缩放像素
|
||||
jpeg_quality: 82
|
||||
@@ -110,7 +115,7 @@ agent:
|
||||
tool_wait_timeout_seconds: 300 # 工具本轮最多等待(秒);到时返回 execution_id,worker 继续后台执行,可用 wait_tool_execution 继续等待;0=等到完成
|
||||
external_mcp_max_concurrent_per_server: 5 # 单个外部 MCP server 同时运行的工具数;0=默认2;负数=不限制
|
||||
external_mcp_max_concurrent_total: 16 # 所有外部 MCP 工具全局并发上限;0=默认16;负数=不限制
|
||||
external_mcp_circuit_failure_threshold: 5 # 单个外部 MCP server 连续失败多少次后熔断;0=默认3;负数=关闭熔断
|
||||
external_mcp_circuit_failure_threshold: 15 # 单个外部 MCP server 连续失败多少次后熔断;0=默认3;负数=关闭熔断
|
||||
external_mcp_circuit_cooldown_seconds: 60 # 熔断冷却秒数;0=默认60
|
||||
shell_no_output_timeout_seconds: 1200 # execute/exec 连续无新输出则终止(秒);通用防挂死;0=默认300;-1=关闭
|
||||
workspace_root_dir: "" # 会话工作目录根路径(curl/wget 下载、read_file/glob/grep 本地分析);空=tmp/workspace,其下按 projects/{id} 或 conversations/{id} 隔离;勿用系统 /tmp
|
||||
|
||||
+1338
-288
File diff suppressed because it is too large
Load Diff
+1348
-42
File diff suppressed because it is too large
Load Diff
@@ -708,6 +708,12 @@
|
||||
"reasoningCompactAria": "Open model reasoning options",
|
||||
"reasoningPanelTitle": "Model reasoning",
|
||||
"reasoningPanelHint": "Only Eino single- and multi-agent requests use these; merged with defaults in Settings.",
|
||||
"sessionSettingsTitle": "Session settings",
|
||||
"sessionSettingsAria": "Open session settings",
|
||||
"sessionSettingsHint": "AI channel, reasoning, and HITL settings only affect future messages.",
|
||||
"aiChannelLabel": "AI channel",
|
||||
"aiChannelDefault": "Use default channel",
|
||||
"aiChannelDefaultShort": "Default channel",
|
||||
"reasoningSummaryFollow": "System",
|
||||
"reasoningSummaryDash": "—",
|
||||
"agentModeOrchPlanExecute": "Plan-Exec",
|
||||
@@ -729,7 +735,7 @@
|
||||
"hitlWhitelistHint": "Separate with commas or new lines; shown merged with the global allowlist in config.",
|
||||
"hitlApply": "Apply",
|
||||
"hitlApplyOkSync": "HITL settings saved and synced to the server.",
|
||||
"hitlApplyOkWhitelistYaml": "Tool whitelist merged into config.yaml and active. Mode and timeout still require selecting a conversation and clicking Apply to sync session settings to the server.",
|
||||
"hitlApplyOkWhitelistYaml": "Tool whitelist merged into config.yaml and active. Session settings are saved automatically.",
|
||||
"hitlApplyOkLocal": "Saved in this browser.",
|
||||
"hitlApplyFail": "Failed to sync to server",
|
||||
"hitlStatusOff": "Human-in-the-loop: Off"
|
||||
@@ -2588,11 +2594,34 @@
|
||||
},
|
||||
"settingsBasic": {
|
||||
"basicTitle": "Basic settings",
|
||||
"openaiConfig": "OpenAI config",
|
||||
"openaiConfig": "AI channel config",
|
||||
"aiChannelCurrent": "Current channel",
|
||||
"aiChannelSave": "Save changes",
|
||||
"aiChannelSetDefault": "Set default",
|
||||
"aiChannelNew": "New",
|
||||
"aiChannelCopy": "Copy",
|
||||
"aiChannelDelete": "Delete",
|
||||
"aiChannelHint": "Saved channels appear on the left. Saving writes to ai.channels; the default channel is used by new chats and tasks without an explicit channel.",
|
||||
"aiChannelSavedList": "Saved channels",
|
||||
"aiChannelListAria": "AI channel list",
|
||||
"aiChannelEditing": "Editing",
|
||||
"aiChannelDefaultBadge": "Default",
|
||||
"aiChannelReady": "Ready",
|
||||
"aiChannelDraft": "Incomplete",
|
||||
"aiChannelDefaultMeta": "Default channel",
|
||||
"aiChannelCustomMeta": "Custom channel",
|
||||
"aiChannelOpenAICompat": "OpenAI compatible",
|
||||
"aiChannelModelMissing": "Model missing",
|
||||
"aiChannelName": "Channel name",
|
||||
"aiChannelUntitled": "New Channel",
|
||||
"aiChannelDeleteConfirm": "Delete AI channel \"{name}\"?",
|
||||
"aiChannelSaved": "Channel saved",
|
||||
"aiChannelDefaultSaved": "Default channel saved",
|
||||
"aiChannelCount": "{count} channel(s) saved",
|
||||
"apiProvider": "API Provider",
|
||||
"providerOpenAI": "OpenAI / OpenAI-compatible API",
|
||||
"providerClaude": "Claude (Anthropic Messages API)",
|
||||
"visionProviderReuseOpenAI": "Reuse OpenAI config (leave empty)",
|
||||
"visionProviderReuseOpenAI": "Reuse default AI channel",
|
||||
"fofaConfig": "FOFA config",
|
||||
"fofaConfigHint": "Used for asset discovery and import; only an API key is required.",
|
||||
"agentConfig": "Agent config",
|
||||
@@ -2613,8 +2642,11 @@
|
||||
"maxTotalTokens": "Max Context Tokens",
|
||||
"maxTotalTokensPlaceholder": "120000",
|
||||
"maxTotalTokensHint": "Shared by memory compression and attack chain building. Default: 120000",
|
||||
"openaiReasoningTitle": "Model reasoning",
|
||||
"openaiReasoningHint": "Works with the reasoning controls on the chat page.",
|
||||
"maxCompletionTokens": "Max Output Tokens",
|
||||
"maxCompletionTokensPlaceholder": "16384",
|
||||
"maxCompletionTokensHint": "Maximum tokens for a single model response. Default: 16384",
|
||||
"openaiReasoningTitle": "Reasoning settings",
|
||||
"openaiReasoningHint": "Default reasoning settings for this AI channel; chat Session settings can override them.",
|
||||
"openaiReasoningProfile": "Wire profile",
|
||||
"openaiReasoningAllowClient": "Allow chat page to override reasoning options",
|
||||
"fofaBaseUrlPlaceholder": "https://fofa.info/api/v1/search/all (optional)",
|
||||
@@ -3839,6 +3871,11 @@
|
||||
"infoLastCheckin": "Last check-in",
|
||||
"infoNote": "Note",
|
||||
"infoNoteEmpty": "No notes",
|
||||
"infoCopy": "Copy",
|
||||
"noteEdit": "Edit",
|
||||
"notePlaceholder": "Add a note to identify this session…",
|
||||
"toastNoteSaved": "Note saved",
|
||||
"toastNoteTooLong": "Note must be at most 2000 characters",
|
||||
"infoSectionIdentity": "Identity",
|
||||
"infoSectionSystem": "System",
|
||||
"infoSectionNetwork": "Network & beacon",
|
||||
@@ -3952,7 +3989,20 @@
|
||||
"paginationPrev": "Previous",
|
||||
"paginationPage": "Page {{current}} / {{total}}",
|
||||
"paginationNext": "Next",
|
||||
"paginationLast": "Last"
|
||||
"paginationLast": "Last",
|
||||
"statTotal": "Filtered",
|
||||
"timePresets": "Quick",
|
||||
"preset15m": "Last 15 min",
|
||||
"preset1h": "Last 1 hour",
|
||||
"preset24h": "Last 24 hours",
|
||||
"preset7d": "Last 7 days",
|
||||
"presetAll": "All",
|
||||
"filterAllStatuses": "All statuses",
|
||||
"filterAllTypes": "All types",
|
||||
"filterSession": "Session ID",
|
||||
"filterSessionPlaceholder": "s_xxxxxxxx",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset"
|
||||
},
|
||||
"payloads": {
|
||||
"386": "386",
|
||||
|
||||
@@ -696,6 +696,12 @@
|
||||
"reasoningCompactAria": "打开模型推理选项",
|
||||
"reasoningPanelTitle": "模型推理",
|
||||
"reasoningPanelHint": "仅 Eino 单代理与多代理请求会带上这些参数;与系统设置中的默认值合并。",
|
||||
"sessionSettingsTitle": "会话设置",
|
||||
"sessionSettingsAria": "打开会话设置",
|
||||
"sessionSettingsHint": "AI 通道、推理设置与人机协同只影响后续消息。",
|
||||
"aiChannelLabel": "AI 通道",
|
||||
"aiChannelDefault": "跟随默认通道",
|
||||
"aiChannelDefaultShort": "默认通道",
|
||||
"reasoningSummaryFollow": "系统",
|
||||
"reasoningSummaryDash": "—",
|
||||
"agentModeOrchPlanExecute": "Plan-Exec",
|
||||
@@ -717,7 +723,7 @@
|
||||
"hitlWhitelistHint": "白名单内工具免审批;每行一个或逗号分隔,与 config 全局白名单合并。",
|
||||
"hitlApply": "应用",
|
||||
"hitlApplyOkSync": "人机协同配置已保存并同步到服务器。",
|
||||
"hitlApplyOkWhitelistYaml": "免审批工具已合并进 config.yaml 并生效。协同模式、超时等仍须选中会话后再点「应用」才会写入服务器。",
|
||||
"hitlApplyOkWhitelistYaml": "免审批工具已合并进 config.yaml 并生效。会话配置会自动保存。",
|
||||
"hitlApplyOkLocal": "已保存到本浏览器。",
|
||||
"hitlApplyFail": "同步到服务器失败",
|
||||
"hitlStatusOff": "人机协同:关闭"
|
||||
@@ -2576,11 +2582,34 @@
|
||||
},
|
||||
"settingsBasic": {
|
||||
"basicTitle": "基本设置",
|
||||
"openaiConfig": "OpenAI 配置",
|
||||
"openaiConfig": "AI 通道配置",
|
||||
"aiChannelCurrent": "当前通道",
|
||||
"aiChannelSave": "保存更改",
|
||||
"aiChannelSetDefault": "设为默认",
|
||||
"aiChannelNew": "新增",
|
||||
"aiChannelCopy": "复制",
|
||||
"aiChannelDelete": "删除",
|
||||
"aiChannelHint": "已保存的通道会显示在左侧;保存后写入 ai.channels,默认通道用于新对话和未指定通道的任务。",
|
||||
"aiChannelSavedList": "已保存通道",
|
||||
"aiChannelListAria": "AI 通道列表",
|
||||
"aiChannelEditing": "正在编辑",
|
||||
"aiChannelDefaultBadge": "默认",
|
||||
"aiChannelReady": "可用",
|
||||
"aiChannelDraft": "待完善",
|
||||
"aiChannelDefaultMeta": "默认通道",
|
||||
"aiChannelCustomMeta": "自定义通道",
|
||||
"aiChannelOpenAICompat": "OpenAI 兼容",
|
||||
"aiChannelModelMissing": "未填写模型",
|
||||
"aiChannelName": "通道名称",
|
||||
"aiChannelUntitled": "新通道",
|
||||
"aiChannelDeleteConfirm": "确定删除 AI 通道「{name}」吗?",
|
||||
"aiChannelSaved": "通道已保存",
|
||||
"aiChannelDefaultSaved": "已设为默认通道",
|
||||
"aiChannelCount": "已保存 {count} 个通道",
|
||||
"apiProvider": "API 提供商",
|
||||
"providerOpenAI": "OpenAI / 兼容 OpenAI 协议",
|
||||
"providerClaude": "Claude (Anthropic Messages API)",
|
||||
"visionProviderReuseOpenAI": "OpenAI 配置(留空复用)",
|
||||
"visionProviderReuseOpenAI": "默认 AI 通道(留空复用)",
|
||||
"fofaConfig": "FOFA 配置",
|
||||
"fofaConfigHint": "用于资产发现与导入,仅需配置 API Key。",
|
||||
"agentConfig": "Agent 配置",
|
||||
@@ -2601,8 +2630,11 @@
|
||||
"maxTotalTokens": "最大上下文 Token 数",
|
||||
"maxTotalTokensPlaceholder": "120000",
|
||||
"maxTotalTokensHint": "内存压缩和攻击链构建共用此配置,默认 120000",
|
||||
"openaiReasoningTitle": "模型推理",
|
||||
"openaiReasoningHint": "与对话页「模型推理」下拉配合使用。",
|
||||
"maxCompletionTokens": "最大输出 Token 数",
|
||||
"maxCompletionTokensPlaceholder": "16384",
|
||||
"maxCompletionTokensHint": "单次模型回复的输出上限,默认 16384",
|
||||
"openaiReasoningTitle": "推理设置",
|
||||
"openaiReasoningHint": "作为该 AI 通道的默认推理设置;对话页「会话设置」可覆盖。",
|
||||
"openaiReasoningProfile": "线路 profile",
|
||||
"openaiReasoningAllowClient": "允许对话页覆盖推理选项",
|
||||
"fofaBaseUrlPlaceholder": "https://fofa.info/api/v1/search/all(可选)",
|
||||
@@ -3827,6 +3859,11 @@
|
||||
"infoLastCheckin": "上次心跳",
|
||||
"infoNote": "备注",
|
||||
"infoNoteEmpty": "暂无备注",
|
||||
"infoCopy": "复制",
|
||||
"noteEdit": "编辑",
|
||||
"notePlaceholder": "添加备注,便于识别该会话…",
|
||||
"toastNoteSaved": "备注已保存",
|
||||
"toastNoteTooLong": "备注最多 2000 字",
|
||||
"infoSectionIdentity": "身份信息",
|
||||
"infoSectionSystem": "系统环境",
|
||||
"infoSectionNetwork": "网络与信标",
|
||||
@@ -3940,7 +3977,20 @@
|
||||
"paginationPrev": "上一页",
|
||||
"paginationPage": "第 {{current}} / {{total}} 页",
|
||||
"paginationNext": "下一页",
|
||||
"paginationLast": "末页"
|
||||
"paginationLast": "末页",
|
||||
"statTotal": "当前筛选",
|
||||
"timePresets": "快捷",
|
||||
"preset15m": "最近15分钟",
|
||||
"preset1h": "最近1小时",
|
||||
"preset24h": "最近24小时",
|
||||
"preset7d": "最近7天",
|
||||
"presetAll": "全部",
|
||||
"filterAllStatuses": "全部状态",
|
||||
"filterAllTypes": "全部类型",
|
||||
"filterSession": "会话 ID",
|
||||
"filterSessionPlaceholder": "s_xxxxxxxx",
|
||||
"applyFilters": "应用筛选",
|
||||
"resetFilters": "重置"
|
||||
},
|
||||
"payloads": {
|
||||
"386": "386",
|
||||
|
||||
@@ -868,7 +868,7 @@ async function openAssetProjectModal() {
|
||||
}
|
||||
const subtitle = document.getElementById('asset-project-subtitle');
|
||||
if (subtitle) subtitle.textContent = assetT('assets.bindProjectCount', `将更新 ${assets.length} 个资产`, { count: assets.length });
|
||||
if (typeof openAppModal === 'function') openAppModal('asset-project-modal');
|
||||
if (typeof openAppModal === 'function') openAppModal('asset-project-modal', { display: 'flex' });
|
||||
else document.getElementById('asset-project-modal').style.display = 'flex';
|
||||
if (select) select.focus();
|
||||
}
|
||||
|
||||
+593
-88
@@ -14,6 +14,8 @@
|
||||
tasksPageSize: 10,
|
||||
tasksTotal: 0,
|
||||
tasksPendingQueuedCount: null,
|
||||
tasksStatusCounts: { success: 0, failed: 0, pending: 0, cancelled: 0 },
|
||||
tasksFilter: { status: '', task_type: '', session_id: '', since: '', timePreset: 'all' },
|
||||
events: [],
|
||||
eventsPage: 1,
|
||||
eventsPageSize: 10,
|
||||
@@ -47,6 +49,111 @@
|
||||
// API 基础路径
|
||||
const API_BASE = '/api/c2';
|
||||
|
||||
function activeC2ProjectId() {
|
||||
try {
|
||||
return (typeof getActiveProjectId === 'function' ? getActiveProjectId() : '') || '';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function c2ResourceProjectId(item) {
|
||||
return String((item && (item.project_id || item.projectId)) || '').trim();
|
||||
}
|
||||
|
||||
function c2LooksLikeUuid(value) {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
function c2ProjectNameMap() {
|
||||
try {
|
||||
return (window.projectNameById && typeof window.projectNameById === 'object')
|
||||
? window.projectNameById
|
||||
: {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** 下拉展示用项目名:优先可读名称,绝不回退成 UUID */
|
||||
function c2ProjectDisplayName(id, nameHint) {
|
||||
const idStr = String(id || '').trim();
|
||||
if (!idStr) return '';
|
||||
let name = String(nameHint || '').trim();
|
||||
if (!name || name === idStr || c2LooksLikeUuid(name)) {
|
||||
const mapped = String(c2ProjectNameMap()[idStr] || '').trim();
|
||||
if (mapped && mapped !== idStr && !c2LooksLikeUuid(mapped)) name = mapped;
|
||||
}
|
||||
if (!name || name === idStr || c2LooksLikeUuid(name)) {
|
||||
if (typeof window.getProjectName === 'function') {
|
||||
const viaFn = String(window.getProjectName(idStr) || '').trim();
|
||||
if (viaFn && viaFn !== idStr && !c2LooksLikeUuid(viaFn)) name = viaFn;
|
||||
}
|
||||
}
|
||||
if (!name || name === idStr || c2LooksLikeUuid(name)) {
|
||||
return c2t('batchManageModal.unknownProject') || '未知项目';
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function c2ProjectOptionsHtml(selectedId, projectList) {
|
||||
const selected = String(selectedId || '').trim();
|
||||
let html = `<option value="">${escapeHtml(c2t('assets.unboundProject') || '暂不绑定')}</option>`;
|
||||
let entries = [];
|
||||
if (Array.isArray(projectList) && projectList.length) {
|
||||
entries = projectList
|
||||
.filter((p) => p && p.id)
|
||||
.map((p) => [String(p.id), String(p.name || '').trim()]);
|
||||
} else {
|
||||
entries = Object.entries(c2ProjectNameMap());
|
||||
}
|
||||
const seen = new Set();
|
||||
entries.sort((a, b) => c2ProjectDisplayName(a[0], a[1]).localeCompare(c2ProjectDisplayName(b[0], b[1]), undefined, { sensitivity: 'base' }));
|
||||
entries.forEach(([id, name]) => {
|
||||
if (!id || seen.has(id)) return;
|
||||
seen.add(id);
|
||||
const label = c2ProjectDisplayName(id, name);
|
||||
html += `<option value="${escapeHtml(id)}"${id === selected ? ' selected' : ''}>${escapeHtml(label)}</option>`;
|
||||
});
|
||||
if (selected && !seen.has(selected)) {
|
||||
html += `<option value="${escapeHtml(selected)}" selected>${escapeHtml(c2ProjectDisplayName(selected))}</option>`;
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function ensureC2ProjectsLoaded() {
|
||||
if (typeof window.ensureProjectsLoaded === 'function') return window.ensureProjectsLoaded();
|
||||
if (typeof window.fetchAllProjects === 'function') {
|
||||
return window.fetchAllProjects(false).then((list) => {
|
||||
if (typeof window.rebuildProjectNameMap === 'function') window.rebuildProjectNameMap(list || []);
|
||||
return list || [];
|
||||
});
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
/** 确保当前选中项目有可读名称(孤儿 / 未进缓存的 ID) */
|
||||
function ensureC2SelectedProjectNamed(projectId) {
|
||||
const id = String(projectId || '').trim();
|
||||
if (!id) return Promise.resolve();
|
||||
const mapped = String(c2ProjectNameMap()[id] || '').trim();
|
||||
if (mapped && mapped !== id && !c2LooksLikeUuid(mapped)) return Promise.resolve();
|
||||
if (typeof window.fetchProjectSummary !== 'function') return Promise.resolve();
|
||||
return window.fetchProjectSummary(id).then((p) => {
|
||||
if (p && p.id && typeof window.rememberProjectsInNameMap === 'function') {
|
||||
window.rememberProjectsInNameMap([p]);
|
||||
}
|
||||
}).catch(function () { /* ignore */ });
|
||||
}
|
||||
|
||||
function c2ProjectBindSelectHtml(listener) {
|
||||
return `<select class="c2-project-bind-select" data-id="${escapeHtml(listener.id || '')}" title="${escapeHtml(c2t('assets.project') || '所属项目')}" onclick="event.stopPropagation()" onchange="C2.bindListenerProject(this.dataset.id, this.value)">${c2ProjectOptionsHtml(c2ResourceProjectId(listener))}</select>`;
|
||||
}
|
||||
|
||||
function withC2ProjectQuery(url) {
|
||||
return url;
|
||||
}
|
||||
|
||||
window.__c2DownloadPayload = function(filename) {
|
||||
const url = `${API_BASE}/payloads/${filename}/download`;
|
||||
const fetchFn = (typeof apiFetch === 'function') ? apiFetch : fetch;
|
||||
@@ -339,56 +446,116 @@
|
||||
return String(os || '?').substring(0, 3).toLowerCase();
|
||||
}
|
||||
|
||||
function isEmptyInfoValue(value) {
|
||||
if (value == null) return true;
|
||||
const s = String(value).trim();
|
||||
if (!s || s === '-') return true;
|
||||
const lower = s.toLowerCase();
|
||||
return lower === 'unknown' || lower === 'n/a' || lower === 'null' || lower === 'undefined';
|
||||
}
|
||||
|
||||
function displayInfoValue(value, fallback) {
|
||||
if (isEmptyInfoValue(value)) return fallback != null ? fallback : '—';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function sessionMetaLine(s) {
|
||||
const user = displayInfoValue(s.username, '');
|
||||
const os = displayInfoValue(s.os, '');
|
||||
const arch = displayInfoValue(s.arch, '');
|
||||
const right = [os, arch].filter(Boolean).join('/') || '—';
|
||||
if (!user) return right;
|
||||
return user + '@' + right;
|
||||
}
|
||||
|
||||
function sessionInfoRow(label, value, opts) {
|
||||
const empty = isEmptyInfoValue(value);
|
||||
const display = empty ? '—' : String(value);
|
||||
const valueClasses = ['c2-session-info-dl__value'];
|
||||
if (opts && opts.mono) valueClasses.push('is-mono');
|
||||
if (opts && opts.accent) valueClasses.push('is-accent');
|
||||
if (opts && opts.warn) valueClasses.push('is-warn');
|
||||
if (opts && opts.accent && !empty) valueClasses.push('is-accent');
|
||||
if (opts && opts.warn && !empty) valueClasses.push('is-warn');
|
||||
if (empty) valueClasses.push('is-empty');
|
||||
const rowCls = opts && opts.full ? ' c2-session-info-dl__row--full' : '';
|
||||
const copyBtn = (opts && opts.copy && !empty)
|
||||
? `<button type="button" class="c2-session-info-copy" title="${escapeHtml(c2t('c2.sessions.infoCopy'))}" aria-label="${escapeHtml(c2t('c2.sessions.infoCopy'))}" onclick="event.stopPropagation(); C2.copyText(${JSON.stringify(String(value))})">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2" stroke="currentColor" stroke-width="1.8"/><path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
|
||||
</button>`
|
||||
: '';
|
||||
return `
|
||||
<div class="c2-session-info-dl__row${rowCls}">
|
||||
<dt class="c2-session-info-dl__label">${escapeHtml(label)}</dt>
|
||||
<dd class="${valueClasses.join(' ')}">${escapeHtml(value == null || value === '' ? '-' : String(value))}</dd>
|
||||
<dd class="${valueClasses.join(' ')}">
|
||||
<span class="c2-session-info-dl__text" title="${escapeHtml(empty ? '' : display)}">${escapeHtml(display)}</span>
|
||||
${copyBtn}
|
||||
</dd>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderSessionInfoPanel(s, adminVal, sleepLine) {
|
||||
const lastCheckin = formatTime(s.lastCheckIn);
|
||||
const lastRel = formatRelativeTime(s.lastCheckIn);
|
||||
const checkinDisplay = lastRel ? `${lastCheckin} (${lastRel})` : lastCheckin;
|
||||
const checkinDisplay = lastRel ? `${lastCheckin} · ${lastRel}` : lastCheckin;
|
||||
const noteText = s.note && String(s.note).trim() ? String(s.note) : '';
|
||||
const noteEmpty = !noteText;
|
||||
return `
|
||||
<div class="c2-session-info-panel">
|
||||
<section class="c2-session-info-block">
|
||||
<div class="c2-session-info-block__head">${escapeHtml(c2t('c2.sessions.infoSectionIdentity'))}</div>
|
||||
<dl class="c2-session-info-dl">
|
||||
${sessionInfoRow(c2t('c2.sessions.infoSessionId'), s.id, { mono: true, accent: true, full: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoImplantUuid'), s.implantUuid, { mono: true, full: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoHostname'), s.hostname)}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoUsername'), s.username)}
|
||||
</dl>
|
||||
</section>
|
||||
<section class="c2-session-info-block">
|
||||
<div class="c2-session-info-block__head">${escapeHtml(c2t('c2.sessions.infoSectionSystem'))}</div>
|
||||
<dl class="c2-session-info-dl">
|
||||
${sessionInfoRow(c2t('c2.sessions.infoOs'), s.os)}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoArch'), s.arch, { mono: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoPid'), s.pid, { mono: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoProcess'), s.processName || '-', { mono: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoAdmin'), adminVal, { warn: s.isAdmin, full: true })}
|
||||
</dl>
|
||||
</section>
|
||||
<section class="c2-session-info-block">
|
||||
<div class="c2-session-info-block__head">${escapeHtml(c2t('c2.sessions.infoSectionNetwork'))}</div>
|
||||
<dl class="c2-session-info-dl">
|
||||
${sessionInfoRow(c2t('c2.sessions.infoInternalIp'), s.internalIp || '-', { mono: true, accent: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoSleep'), sleepLine)}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoFirstSeen'), formatTime(s.firstSeenAt), { mono: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoLastCheckin'), checkinDisplay, { mono: true, accent: true })}
|
||||
</dl>
|
||||
</section>
|
||||
<section class="c2-session-info-block c2-session-info-block--note">
|
||||
<div class="c2-session-info-block__head">${escapeHtml(c2t('c2.sessions.infoSectionNote'))}</div>
|
||||
<div class="c2-session-info-note">${escapeHtml(s.note || c2t('c2.sessions.infoNoteEmpty'))}</div>
|
||||
<div class="c2-session-info-grid">
|
||||
<section class="c2-session-info-block">
|
||||
<div class="c2-session-info-block__head">
|
||||
<span class="c2-session-info-block__icon c2-session-info-block__icon--id" aria-hidden="true"></span>
|
||||
<span>${escapeHtml(c2t('c2.sessions.infoSectionIdentity'))}</span>
|
||||
</div>
|
||||
<dl class="c2-session-info-dl">
|
||||
${sessionInfoRow(c2t('c2.sessions.infoSessionId'), s.id, { mono: true, accent: true, copy: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoImplantUuid'), s.implantUuid, { mono: true, copy: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoHostname'), s.hostname)}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoUsername'), s.username)}
|
||||
</dl>
|
||||
</section>
|
||||
<section class="c2-session-info-block">
|
||||
<div class="c2-session-info-block__head">
|
||||
<span class="c2-session-info-block__icon c2-session-info-block__icon--sys" aria-hidden="true"></span>
|
||||
<span>${escapeHtml(c2t('c2.sessions.infoSectionSystem'))}</span>
|
||||
</div>
|
||||
<dl class="c2-session-info-dl">
|
||||
${sessionInfoRow(c2t('c2.sessions.infoOs'), s.os)}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoArch'), s.arch, { mono: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoPid'), s.pid, { mono: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoProcess'), s.processName || '-', { mono: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoAdmin'), adminVal, { warn: !!s.isAdmin })}
|
||||
</dl>
|
||||
</section>
|
||||
<section class="c2-session-info-block">
|
||||
<div class="c2-session-info-block__head">
|
||||
<span class="c2-session-info-block__icon c2-session-info-block__icon--net" aria-hidden="true"></span>
|
||||
<span>${escapeHtml(c2t('c2.sessions.infoSectionNetwork'))}</span>
|
||||
</div>
|
||||
<dl class="c2-session-info-dl">
|
||||
${sessionInfoRow(c2t('c2.sessions.infoInternalIp'), s.internalIp || '-', { mono: true, accent: true, copy: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoSleep'), sleepLine)}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoFirstSeen'), formatTime(s.firstSeenAt), { mono: true })}
|
||||
${sessionInfoRow(c2t('c2.sessions.infoLastCheckin'), checkinDisplay, { mono: true, accent: true })}
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
<section class="c2-session-info-block c2-session-info-block--note${noteEmpty ? ' is-empty' : ''}" id="c2-session-note-block">
|
||||
<div class="c2-session-info-block__head">
|
||||
<span class="c2-session-info-block__icon c2-session-info-block__icon--note" aria-hidden="true"></span>
|
||||
<span>${escapeHtml(c2t('c2.sessions.infoSectionNote'))}</span>
|
||||
<button type="button" class="c2-session-note-edit-btn" data-require-permission="c2:write" onclick='C2.beginEditSessionNote(${JSON.stringify(s.id)})'>${escapeHtml(c2t('c2.sessions.noteEdit'))}</button>
|
||||
</div>
|
||||
<div class="c2-session-info-note${noteEmpty ? ' is-empty' : ''}" id="c2-session-note-view">${escapeHtml(noteText || c2t('c2.sessions.infoNoteEmpty'))}</div>
|
||||
<div class="c2-session-note-editor" id="c2-session-note-editor" hidden>
|
||||
<textarea id="c2-session-note-input" class="c2-session-note-textarea" maxlength="2000" rows="4" placeholder="${escapeHtml(c2t('c2.sessions.notePlaceholder'))}">${escapeHtml(noteText)}</textarea>
|
||||
<div class="c2-session-note-editor__footer">
|
||||
<span class="c2-session-note-counter" id="c2-session-note-counter">${noteText.length}/2000</span>
|
||||
<div class="c2-session-note-editor__actions">
|
||||
<button type="button" class="btn-secondary btn-sm" onclick="C2.cancelEditSessionNote()">${escapeHtml(c2t('common.cancel'))}</button>
|
||||
<button type="button" class="btn-primary btn-sm" id="c2-session-note-save" onclick='C2.saveSessionNote(${JSON.stringify(s.id)})'>${escapeHtml(c2t('common.save'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>`;
|
||||
}
|
||||
@@ -398,6 +565,7 @@
|
||||
// ============================================================================
|
||||
|
||||
function apiRequest(method, url, data) {
|
||||
if (method === 'GET') url = withC2ProjectQuery(url);
|
||||
const options = {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
@@ -448,6 +616,21 @@
|
||||
return c2t('c2.fmt.durationMin', { n: (ms / 60000).toFixed(1) });
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
const num = Number(n);
|
||||
if (!Number.isFinite(num) || num < 0) return String(n == null ? '-' : n);
|
||||
if (num < 1024) return String(Math.round(num)) + ' B';
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let v = num / 1024;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i += 1;
|
||||
}
|
||||
const digits = v >= 100 ? 0 : (v >= 10 ? 1 : 2);
|
||||
return v.toFixed(digits) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
@@ -556,6 +739,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
C2.copyText = function(text) {
|
||||
if (text == null || text === '') return;
|
||||
copyToClipboard(String(text));
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 页面初始化
|
||||
// ============================================================================
|
||||
@@ -611,7 +799,8 @@
|
||||
C2.loadListeners = function() {
|
||||
Promise.all([
|
||||
apiRequest('GET', `${API_BASE}/listeners`),
|
||||
apiRequest('GET', `${API_BASE}/profiles`).catch(function() { return {}; })
|
||||
apiRequest('GET', `${API_BASE}/profiles`).catch(function() { return {}; }),
|
||||
ensureC2ProjectsLoaded().catch(function() { return []; })
|
||||
]).then(function(results) {
|
||||
var ldata = results[0];
|
||||
var pdata = results[1];
|
||||
@@ -670,6 +859,7 @@
|
||||
? '<div class="c2-listener-kv"><span class="c2-listener-kv-label">' + escapeHtml(c2t('c2.listeners.callbackShort')) + '</span><span class="c2-listener-kv-val c2-listener-mono">' + escapeHtml(cb) + '</span></div>'
|
||||
: '';
|
||||
const remarkRow = l.remark ? '<div class="c2-listener-remark">' + escapeHtml(l.remark) + '</div>' : '';
|
||||
const projectRow = '<div class="c2-listener-kv"><span class="c2-listener-kv-label">' + escapeHtml(c2t('assets.project') || '所属项目') + '</span><span class="c2-listener-kv-val">' + c2ProjectBindSelectHtml(l) + '</span></div>';
|
||||
const startedHtml = formatListenerStartedHtml(l.startedAt);
|
||||
const pillLabel = escapeHtml(listenerCardStatusPillLabel(st));
|
||||
const typeMark = escapeHtml(listenerTypeShortLabel(l.type));
|
||||
@@ -697,6 +887,7 @@
|
||||
<span class="c2-listener-kv-val c2-listener-mono"><span class="c2-status-dot ${escapeHtml(st)}"></span>${bindVal}</span>
|
||||
</div>
|
||||
${cbRow}
|
||||
${projectRow}
|
||||
${profileBadge}
|
||||
${remarkRow}
|
||||
${startedHtml}
|
||||
@@ -751,8 +942,15 @@
|
||||
</div>
|
||||
`;
|
||||
|
||||
C2.ensureProfilesLoaded().then(() => {
|
||||
const createSelectedProjectId = activeC2ProjectId();
|
||||
Promise.all([
|
||||
C2.ensureProfilesLoaded(),
|
||||
ensureC2ProjectsLoaded().catch(() => []),
|
||||
ensureC2SelectedProjectNamed(createSelectedProjectId)
|
||||
]).then(function (results) {
|
||||
const projects = results[1] || [];
|
||||
const profileOpts = listenerProfileSelectHtml('');
|
||||
const projectOpts = c2ProjectOptionsHtml(createSelectedProjectId, projects);
|
||||
const emptyProfHintCreate = (C2.profiles && C2.profiles.length > 0)
|
||||
? ''
|
||||
: `<div class="form-hint form-hint--warning" style="margin-bottom:6px;">${escapeHtml(c2t('c2.listeners.malleableProfileEmptyListHint'))}</div>`;
|
||||
@@ -777,6 +975,10 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="c2-form-group">
|
||||
<label>${escapeHtml(c2t('assets.project') || '所属项目')}</label>
|
||||
<select id="c2-listener-project-id" class="form-control c2-form-select-native">${projectOpts}</select>
|
||||
</div>
|
||||
<div class="c2-form-row">
|
||||
<div class="c2-form-group">
|
||||
<label>${escapeHtml(c2t('c2.listeners.bindHost'))}</label>
|
||||
@@ -864,6 +1066,7 @@
|
||||
const body = {
|
||||
name, type, bind_host: bindHost, bind_port: bindPort, remark,
|
||||
callback_host: callbackHost,
|
||||
project_id: (document.getElementById('c2-listener-project-id')?.value || '').trim(),
|
||||
profile_id: profileId
|
||||
};
|
||||
if (type === 'tcp_reverse' && legacyShell) {
|
||||
@@ -909,6 +1112,38 @@
|
||||
});
|
||||
};
|
||||
|
||||
C2.bindListenerProject = function(id, projectId) {
|
||||
if (typeof requirePermission === 'function' && !requirePermission('c2:write')) {
|
||||
C2.renderListeners();
|
||||
return;
|
||||
}
|
||||
const listener = C2.listeners.find(x => x.id === id);
|
||||
if (!listener) return;
|
||||
const cfg = C2.getListenerConfig(listener);
|
||||
const body = {
|
||||
name: listener.name || '',
|
||||
bind_host: listener.bindHost || '127.0.0.1',
|
||||
bind_port: parseInt(listener.bindPort, 10) || 0,
|
||||
remark: listener.remark || '',
|
||||
callback_host: C2.getListenerCallbackHost(listener),
|
||||
project_id: String(projectId || '').trim(),
|
||||
profile_id: listenerResolvedProfileId(listener),
|
||||
config: cfg
|
||||
};
|
||||
apiRequest('PUT', `${API_BASE}/listeners/${id}`, body).then(data => {
|
||||
if (data && data.error) {
|
||||
showToast(data.error, 'error');
|
||||
C2.renderListeners();
|
||||
return;
|
||||
}
|
||||
showToast(c2t('projects.projectBound') || '已绑定项目', 'success');
|
||||
C2.loadListeners();
|
||||
}).catch(err => {
|
||||
showToast(err && err.message ? err.message : '绑定项目失败', 'error');
|
||||
C2.renderListeners();
|
||||
});
|
||||
};
|
||||
|
||||
C2.editListener = function(id) {
|
||||
const l = C2.listeners.find(x => x.id === id);
|
||||
if (!l) return;
|
||||
@@ -931,9 +1166,16 @@
|
||||
</div>
|
||||
`;
|
||||
|
||||
C2.ensureProfilesLoaded().then(() => {
|
||||
const editSelectedProjectId = c2ResourceProjectId(l);
|
||||
Promise.all([
|
||||
C2.ensureProfilesLoaded(),
|
||||
ensureC2ProjectsLoaded().catch(() => []),
|
||||
ensureC2SelectedProjectNamed(editSelectedProjectId)
|
||||
]).then(function (results) {
|
||||
const projects = results[1] || [];
|
||||
const resolvedPid = listenerResolvedProfileId(l);
|
||||
const profileOpts = listenerProfileSelectHtml(resolvedPid);
|
||||
const projectOpts = c2ProjectOptionsHtml(editSelectedProjectId, projects);
|
||||
const lt = String(l.type || '').toLowerCase();
|
||||
const httpHint = (lt === 'http_beacon' || lt === 'https_beacon')
|
||||
? ''
|
||||
@@ -951,6 +1193,10 @@
|
||||
<label>${escapeHtml(c2t('c2.listeners.name'))}</label>
|
||||
<input type="text" id="c2-listener-name" class="form-control" value="${escapeHtml(l.name)}">
|
||||
</div>
|
||||
<div class="c2-form-group">
|
||||
<label>${escapeHtml(c2t('assets.project') || '所属项目')}</label>
|
||||
<select id="c2-listener-project-id" class="form-control c2-form-select-native">${projectOpts}</select>
|
||||
</div>
|
||||
<div class="c2-form-row">
|
||||
<div class="c2-form-group">
|
||||
<label>${escapeHtml(c2t('c2.listeners.bindHost'))}</label>
|
||||
@@ -1009,6 +1255,7 @@
|
||||
const body = {
|
||||
name, bind_host: bindHost, bind_port: bindPort, remark,
|
||||
callback_host: callbackHost,
|
||||
project_id: (document.getElementById('c2-listener-project-id')?.value || '').trim(),
|
||||
profile_id: profileId
|
||||
};
|
||||
if (legacyEl) {
|
||||
@@ -1119,9 +1366,16 @@
|
||||
const batchBtn = document.getElementById('c2-sessions-batch-delete');
|
||||
const filteredBtn = document.getElementById('c2-sessions-delete-filtered');
|
||||
const ids = C2.collectCheckedSessionIds();
|
||||
if (batchBtn) batchBtn.disabled = ids.length === 0;
|
||||
if (batchBtn) {
|
||||
batchBtn.disabled = ids.length === 0;
|
||||
batchBtn.classList.toggle('btn-danger', ids.length > 0);
|
||||
batchBtn.classList.toggle('btn-secondary', ids.length === 0);
|
||||
}
|
||||
const total = (C2.sessions || []).length;
|
||||
if (filteredBtn) filteredBtn.disabled = total === 0;
|
||||
if (filteredBtn) {
|
||||
filteredBtn.disabled = total === 0;
|
||||
filteredBtn.classList.toggle('is-danger-soft', total > 0);
|
||||
}
|
||||
const countEl = document.getElementById('c2-sessions-count');
|
||||
if (countEl) {
|
||||
countEl.textContent = c2t('c2.sessions.filterCount', { n: total, selected: ids.length });
|
||||
@@ -1191,7 +1445,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = C2.sessions.map(s => `
|
||||
list.innerHTML = C2.sessions.map(s => {
|
||||
const userLabel = displayInfoValue(s.username, '—');
|
||||
const osArch = [displayInfoValue(s.os, ''), displayInfoValue(s.arch, '')].filter(Boolean).join('/') || '—';
|
||||
const userEmpty = isEmptyInfoValue(s.username);
|
||||
const osEmpty = isEmptyInfoValue(s.os) && isEmptyInfoValue(s.arch);
|
||||
return `
|
||||
<div class="c2-session-item ${s.id === C2.selectedSessionId ? 'active' : ''}"
|
||||
data-status="${escapeHtml(s.status || '')}"
|
||||
onclick="C2.selectSession('${s.id}')">
|
||||
@@ -1206,21 +1465,21 @@
|
||||
<span class="c2-session-status ${s.status}">${escapeHtml(sessionStatusLabel(s.status))}</span>
|
||||
</div>
|
||||
<div class="c2-session-chips">
|
||||
<span class="c2-session-chip">${escapeHtml(s.username)}</span>
|
||||
<span class="c2-session-chip c2-session-chip--mono">${escapeHtml(s.os)}/${escapeHtml(s.arch)}</span>
|
||||
<span class="c2-session-chip${userEmpty ? ' c2-session-chip--empty' : ''}">${escapeHtml(userLabel)}</span>
|
||||
<span class="c2-session-chip c2-session-chip--mono${osEmpty ? ' c2-session-chip--empty' : ''}">${escapeHtml(osArch)}</span>
|
||||
${s.isAdmin ? '<span class="c2-session-chip c2-session-chip--warn">' + escapeHtml(c2t('c2.sessions.rootBadge')) + '</span>' : ''}
|
||||
</div>
|
||||
<div class="c2-session-chips c2-session-chips--sub">
|
||||
<span class="c2-session-chip c2-session-chip--dim">${escapeHtml(s.internalIp || '-')}</span>
|
||||
<span class="c2-session-chip c2-session-chip--dim">PID ${s.pid}</span>
|
||||
<span class="c2-session-chip c2-session-chip--dim">${escapeHtml(s.internalIp || '—')}</span>
|
||||
<span class="c2-session-chip c2-session-chip--dim">PID ${escapeHtml(String(s.pid != null ? s.pid : '—'))}</span>
|
||||
</div>
|
||||
<div class="c2-session-item-footer">
|
||||
<span class="c2-session-meta c2-session-item-time" title="${escapeHtml(formatTime(s.lastCheckIn))}">${escapeHtml(formatRelativeTime(s.lastCheckIn) || formatTime(s.lastCheckIn))}</span>
|
||||
<button type="button" class="c2-session-card-delete" data-require-permission="c2:delete" onclick="event.stopPropagation(); C2.deleteSessionRecord('${s.id}');">${escapeHtml(c2t('c2.sessions.cardDeleteSession'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
if (C2.selectedSessionId && !C2.sessions.find(s => s.id === C2.selectedSessionId)) {
|
||||
C2.selectedSessionId = null;
|
||||
@@ -1270,11 +1529,11 @@
|
||||
<span class="c2-session-badge ${escapeHtml(s.status || '')}">${escapeHtml(sessionStatusLabel(s.status))}</span>
|
||||
${s.isAdmin ? '<span class="c2-session-hero-root">' + escapeHtml(c2t('c2.sessions.rootBadge')) + '</span>' : ''}
|
||||
</div>
|
||||
<div class="c2-session-hero__sub">${escapeHtml(s.username)}@${escapeHtml(s.os)}/${escapeHtml(s.arch)}</div>
|
||||
<div class="c2-session-hero__sub${isEmptyInfoValue(s.username) && isEmptyInfoValue(s.os) ? ' is-muted' : ''}">${escapeHtml(sessionMetaLine(s))}</div>
|
||||
<div class="c2-session-hero__chips">
|
||||
<span class="c2-session-hero-chip is-mono" title="${escapeHtml(c2t('c2.sessions.infoSessionId'))}">${escapeHtml(s.id)}</span>
|
||||
<span class="c2-session-hero-chip">${escapeHtml(s.internalIp || '-')}</span>
|
||||
<span class="c2-session-hero-chip">PID ${s.pid}</span>
|
||||
<span class="c2-session-hero-chip">${escapeHtml(s.internalIp || '—')}</span>
|
||||
<span class="c2-session-hero-chip">PID ${escapeHtml(String(s.pid != null ? s.pid : '—'))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1601,6 +1860,88 @@
|
||||
});
|
||||
};
|
||||
|
||||
C2.beginEditSessionNote = function(id) {
|
||||
const block = document.getElementById('c2-session-note-block');
|
||||
const view = document.getElementById('c2-session-note-view');
|
||||
const editor = document.getElementById('c2-session-note-editor');
|
||||
const input = document.getElementById('c2-session-note-input');
|
||||
const editBtn = block && block.querySelector('.c2-session-note-edit-btn');
|
||||
if (!view || !editor || !input) return;
|
||||
const s = (C2.sessions || []).find(x => x.id === id);
|
||||
const note = s && s.note ? String(s.note) : '';
|
||||
input.value = note;
|
||||
view.hidden = true;
|
||||
editor.hidden = false;
|
||||
if (block) block.classList.remove('is-empty');
|
||||
if (editBtn) editBtn.hidden = true;
|
||||
C2.syncSessionNoteCounter();
|
||||
if (!input.dataset.boundCounter) {
|
||||
input.dataset.boundCounter = '1';
|
||||
input.addEventListener('input', function () { C2.syncSessionNoteCounter(); });
|
||||
}
|
||||
input.focus();
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
};
|
||||
|
||||
C2.syncSessionNoteCounter = function() {
|
||||
const input = document.getElementById('c2-session-note-input');
|
||||
const counter = document.getElementById('c2-session-note-counter');
|
||||
if (!input || !counter) return;
|
||||
counter.textContent = String(input.value.length) + '/2000';
|
||||
};
|
||||
|
||||
C2.cancelEditSessionNote = function() {
|
||||
const block = document.getElementById('c2-session-note-block');
|
||||
const view = document.getElementById('c2-session-note-view');
|
||||
const editor = document.getElementById('c2-session-note-editor');
|
||||
const editBtn = block && block.querySelector('.c2-session-note-edit-btn');
|
||||
if (!view || !editor) return;
|
||||
editor.hidden = true;
|
||||
view.hidden = false;
|
||||
if (editBtn) editBtn.hidden = false;
|
||||
if (block) {
|
||||
const s = (C2.sessions || []).find(x => x.id === C2.selectedSessionId);
|
||||
const empty = !(s && s.note && String(s.note).trim());
|
||||
block.classList.toggle('is-empty', empty);
|
||||
}
|
||||
};
|
||||
|
||||
C2.saveSessionNote = function(id) {
|
||||
if (!id) return;
|
||||
const input = document.getElementById('c2-session-note-input');
|
||||
const saveBtn = document.getElementById('c2-session-note-save');
|
||||
if (!input) return;
|
||||
const note = String(input.value || '').trim();
|
||||
if (note.length > 2000) {
|
||||
showToast(c2t('c2.sessions.toastNoteTooLong'), 'warn');
|
||||
return;
|
||||
}
|
||||
if (saveBtn) saveBtn.disabled = true;
|
||||
apiRequest('PUT', `${API_BASE}/sessions/${encodeURIComponent(id)}/note`, { note: note }).then(data => {
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
if (data.error) {
|
||||
showToast(String(data.error), 'error');
|
||||
return;
|
||||
}
|
||||
const saved = data.note != null ? String(data.note) : note;
|
||||
const s = (C2.sessions || []).find(x => x.id === id);
|
||||
if (s) s.note = saved;
|
||||
showToast(c2t('c2.sessions.toastNoteSaved'), 'success');
|
||||
const block = document.getElementById('c2-session-note-block');
|
||||
const view = document.getElementById('c2-session-note-view');
|
||||
const empty = !saved.trim();
|
||||
if (view) {
|
||||
view.textContent = empty ? c2t('c2.sessions.infoNoteEmpty') : saved;
|
||||
view.classList.toggle('is-empty', empty);
|
||||
}
|
||||
if (block) block.classList.toggle('is-empty', empty);
|
||||
C2.cancelEditSessionNote();
|
||||
}).catch(err => {
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
showToast(err.message || String(err), 'error');
|
||||
});
|
||||
};
|
||||
|
||||
C2.killSession = function(id) {
|
||||
if (!confirm(c2t('c2.sessions.confirmExitSession'))) return;
|
||||
apiRequest('POST', `${API_BASE}/tasks`, {
|
||||
@@ -2072,6 +2413,8 @@
|
||||
});
|
||||
container.setAttribute('tabindex', '0');
|
||||
|
||||
C2.bindTerminalScrollbarAutoHide(container);
|
||||
|
||||
C2.terminalInstance = term;
|
||||
|
||||
if (C2.terminalResizeObserver) {
|
||||
@@ -2090,6 +2433,42 @@
|
||||
});
|
||||
};
|
||||
|
||||
C2.bindTerminalScrollbarAutoHide = function(container) {
|
||||
if (!container) return;
|
||||
if (C2._terminalScrollbarHideTimer) {
|
||||
clearTimeout(C2._terminalScrollbarHideTimer);
|
||||
C2._terminalScrollbarHideTimer = null;
|
||||
}
|
||||
const show = function () {
|
||||
container.classList.add('is-scrollbar-visible');
|
||||
if (C2._terminalScrollbarHideTimer) clearTimeout(C2._terminalScrollbarHideTimer);
|
||||
C2._terminalScrollbarHideTimer = setTimeout(function () {
|
||||
container.classList.remove('is-scrollbar-visible');
|
||||
C2._terminalScrollbarHideTimer = null;
|
||||
}, 900);
|
||||
};
|
||||
const bindViewport = function () {
|
||||
const vp = container.querySelector('.xterm-viewport');
|
||||
if (!vp) return false;
|
||||
if (vp.dataset.scrollbarBound === '1') return true;
|
||||
vp.dataset.scrollbarBound = '1';
|
||||
vp.addEventListener('scroll', show, { passive: true });
|
||||
return true;
|
||||
};
|
||||
if (!container.dataset.scrollbarUiBound) {
|
||||
container.dataset.scrollbarUiBound = '1';
|
||||
container.addEventListener('wheel', show, { passive: true });
|
||||
container.addEventListener('touchmove', show, { passive: true });
|
||||
}
|
||||
if (!bindViewport()) {
|
||||
const mo = new MutationObserver(function () {
|
||||
if (bindViewport()) mo.disconnect();
|
||||
});
|
||||
mo.observe(container, { childList: true, subtree: true });
|
||||
setTimeout(function () { mo.disconnect(); }, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
C2.fitTerminal = function() {
|
||||
const container = document.getElementById('c2-terminal-container');
|
||||
if (!container || !C2.terminalFitAddon || !C2.terminalInstance) return;
|
||||
@@ -2392,18 +2771,22 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
${entries.map(function(entry) {
|
||||
const sizeLabel = entry.isDir ? '—' : formatBytes(entry.size);
|
||||
const iconCls = entry.isDir ? 'c2-file-icon c2-file-icon--dir' : 'c2-file-icon c2-file-icon--file';
|
||||
return `
|
||||
<tr>
|
||||
<td class="c2-file-name">
|
||||
<span class="c2-file-icon">${entry.isDir ? '📁' : '📄'}</span>
|
||||
${escapeHtml(entry.name)}
|
||||
<td>
|
||||
<div class="c2-file-name">
|
||||
<span class="${iconCls}" aria-hidden="true"></span>
|
||||
<span class="c2-file-name-text" title="${escapeHtml(entry.name)}">${escapeHtml(entry.name)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>${escapeHtml(entry.size)}</td>
|
||||
<td title="${escapeHtml(String(entry.size || ''))}">${escapeHtml(sizeLabel)}</td>
|
||||
<td>${escapeHtml(entry.mode)}</td>
|
||||
<td>
|
||||
${entry.isDir
|
||||
? `<button class="btn-ghost btn-sm" onclick='C2.openDirectory(${JSON.stringify(entry.name)})'>${escapeHtml(c2t('c2.files.open'))}</button>`
|
||||
: `<button class="btn-ghost btn-sm" onclick='C2.downloadFile(${JSON.stringify(entry.name)})'>${escapeHtml(c2t('c2.files.download'))}</button>`
|
||||
? `<button class="btn-ghost btn-sm c2-file-action-btn" onclick='C2.openDirectory(${JSON.stringify(entry.name)})'>${escapeHtml(c2t('c2.files.open'))}</button>`
|
||||
: `<button class="btn-ghost btn-sm c2-file-action-btn" onclick='C2.downloadFile(${JSON.stringify(entry.name)})'>${escapeHtml(c2t('c2.files.download'))}</button>`
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -2717,16 +3100,20 @@
|
||||
// ============================================================================
|
||||
|
||||
C2.loadTasks = function(page) {
|
||||
bindTasksFilterUiOnce();
|
||||
readTasksFilterFromDom();
|
||||
const p = page != null ? page : (C2.tasksPage || 1);
|
||||
C2.tasksPage = p;
|
||||
const ps = C2.tasksPageSize || 10;
|
||||
apiRequest('GET', `${API_BASE}/tasks?page=${encodeURIComponent(String(p))}&page_size=${encodeURIComponent(String(ps))}`).then(data => {
|
||||
const qs = buildTasksQueryParams(p, ps);
|
||||
apiRequest('GET', `${API_BASE}/tasks?${qs}`).then(data => {
|
||||
if (data.error) {
|
||||
showToast(String(data.error), 'error');
|
||||
return;
|
||||
}
|
||||
C2.tasks = data.tasks || [];
|
||||
C2.tasksTotal = typeof data.total === 'number' ? data.total : (C2.tasks.length || 0);
|
||||
C2.tasksStatusCounts = data.status_counts || { success: 0, failed: 0, pending: 0, cancelled: 0 };
|
||||
if (typeof data.pending_queued_count === 'number') {
|
||||
C2.tasksPendingQueuedCount = data.pending_queued_count;
|
||||
}
|
||||
@@ -2736,8 +3123,10 @@
|
||||
return;
|
||||
}
|
||||
C2.renderTasks();
|
||||
C2.renderTasksSummary();
|
||||
C2.renderTasksPagination();
|
||||
C2.syncTasksToolbar();
|
||||
syncTasksTimePresetButtons();
|
||||
}).catch(err => {
|
||||
showToast(err.message || String(err), 'error');
|
||||
});
|
||||
@@ -2857,6 +3246,105 @@
|
||||
}).catch(err => showToast(err.message || String(err), 'error'));
|
||||
};
|
||||
|
||||
function readTasksFilterFromDom() {
|
||||
const statusEl = document.getElementById('c2-tasks-filter-status');
|
||||
const typeEl = document.getElementById('c2-tasks-filter-type');
|
||||
const sessionEl = document.getElementById('c2-tasks-filter-session');
|
||||
C2.tasksFilter = C2.tasksFilter || { status: '', task_type: '', session_id: '', since: '', timePreset: 'all' };
|
||||
C2.tasksFilter.status = statusEl ? statusEl.value.trim() : '';
|
||||
C2.tasksFilter.task_type = typeEl ? typeEl.value.trim() : '';
|
||||
C2.tasksFilter.session_id = sessionEl ? sessionEl.value.trim() : '';
|
||||
}
|
||||
|
||||
function buildTasksQueryParams(page, pageSize) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page));
|
||||
params.set('page_size', String(pageSize));
|
||||
const f = C2.tasksFilter || {};
|
||||
if (f.status) params.set('status', f.status);
|
||||
if (f.task_type) params.set('task_type', f.task_type);
|
||||
if (f.session_id) params.set('session_id', f.session_id);
|
||||
if (f.since) params.set('since', f.since);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function tasksTimePresetToSince(preset) {
|
||||
if (!preset || preset === 'all') return '';
|
||||
const now = Date.now();
|
||||
const map = { '15m': 15 * 60 * 1000, '1h': 60 * 60 * 1000, '24h': 24 * 60 * 60 * 1000, '7d': 7 * 24 * 60 * 60 * 1000 };
|
||||
const ms = map[preset];
|
||||
if (!ms) return '';
|
||||
return new Date(now - ms).toISOString();
|
||||
}
|
||||
|
||||
function syncTasksTimePresetButtons() {
|
||||
const wrap = document.getElementById('c2-tasks-time-presets');
|
||||
if (!wrap) return;
|
||||
const active = (C2.tasksFilter && C2.tasksFilter.timePreset) || 'all';
|
||||
wrap.querySelectorAll('.c2-tasks-time-preset-btn').forEach(btn => {
|
||||
btn.classList.toggle('is-active', btn.getAttribute('data-preset') === active);
|
||||
});
|
||||
}
|
||||
|
||||
function bindTasksFilterUiOnce() {
|
||||
if (document.documentElement.dataset.c2TasksFilterBound === '1') return;
|
||||
document.documentElement.dataset.c2TasksFilterBound = '1';
|
||||
|
||||
const presets = document.getElementById('c2-tasks-time-presets');
|
||||
if (presets) {
|
||||
presets.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.c2-tasks-time-preset-btn');
|
||||
if (!btn) return;
|
||||
const preset = btn.getAttribute('data-preset') || 'all';
|
||||
C2.tasksFilter = C2.tasksFilter || { status: '', task_type: '', session_id: '', since: '', timePreset: 'all' };
|
||||
C2.tasksFilter.timePreset = preset;
|
||||
C2.tasksFilter.since = tasksTimePresetToSince(preset);
|
||||
syncTasksTimePresetButtons();
|
||||
C2.loadTasks(1);
|
||||
});
|
||||
}
|
||||
|
||||
const sessionInput = document.getElementById('c2-tasks-filter-session');
|
||||
if (sessionInput) {
|
||||
sessionInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') C2.applyTasksFilters();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
C2.applyTasksFilters = function() {
|
||||
readTasksFilterFromDom();
|
||||
C2.loadTasks(1);
|
||||
};
|
||||
|
||||
C2.resetTasksFilters = function() {
|
||||
C2.tasksFilter = { status: '', task_type: '', session_id: '', since: '', timePreset: 'all' };
|
||||
const statusEl = document.getElementById('c2-tasks-filter-status');
|
||||
const typeEl = document.getElementById('c2-tasks-filter-type');
|
||||
const sessionEl = document.getElementById('c2-tasks-filter-session');
|
||||
if (statusEl) statusEl.value = '';
|
||||
if (typeEl) typeEl.value = '';
|
||||
if (sessionEl) sessionEl.value = '';
|
||||
syncTasksTimePresetButtons();
|
||||
C2.loadTasks(1);
|
||||
};
|
||||
|
||||
C2.renderTasksSummary = function() {
|
||||
const summary = document.getElementById('c2-tasks-summary');
|
||||
if (!summary) return;
|
||||
const total = C2.tasksTotal || 0;
|
||||
const counts = C2.tasksStatusCounts || { success: 0, failed: 0, pending: 0 };
|
||||
const set = (id, val) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = String(val);
|
||||
};
|
||||
set('c2-tasks-stat-total', total);
|
||||
set('c2-tasks-stat-success', counts.success || 0);
|
||||
set('c2-tasks-stat-failed', counts.failed || 0);
|
||||
set('c2-tasks-stat-pending', counts.pending || 0);
|
||||
summary.hidden = total === 0;
|
||||
};
|
||||
|
||||
C2.loadSessionTasks = function(sessionId) {
|
||||
apiRequest('GET', `${API_BASE}/tasks?session_id=${encodeURIComponent(sessionId)}&limit=50`).then(data => {
|
||||
const container = document.getElementById('c2-session-tasks-list');
|
||||
@@ -2941,7 +3429,7 @@
|
||||
}
|
||||
|
||||
if (C2.tasks.length === 0) {
|
||||
container.innerHTML = '<div class="c2-empty">' + escapeHtml(c2t('c2.tasks.emptyAll')) + '</div>';
|
||||
container.innerHTML = '<div class="c2-tasks-empty">' + escapeHtml(c2t('c2.tasks.emptyAll')) + '</div>';
|
||||
if (selAll) selAll.disabled = true;
|
||||
C2.syncTasksToolbar();
|
||||
return;
|
||||
@@ -2949,59 +3437,76 @@
|
||||
if (selAll) selAll.disabled = false;
|
||||
|
||||
const delTitle = escapeHtml(c2t('c2.tasks.deleteOne'));
|
||||
const cancelTitle = escapeHtml(c2t('c2.tasks.cancelBtn'));
|
||||
const dash = '<span class="c2-tasks-cell-muted">—</span>';
|
||||
const deleteIcon = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>';
|
||||
|
||||
container.innerHTML = `
|
||||
<table class="c2-task-table">
|
||||
<table class="c2-tasks-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="c2-task-table-col-check"></th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colTask'))}</th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colSession'))}</th>
|
||||
<th class="c2-tasks-table-col-check">
|
||||
<label class="c2-task-check-label" title="${escapeHtml(c2t('c2.tasks.selectAll'))}">
|
||||
<input type="checkbox" id="c2-tasks-select-all" onchange="C2.onTasksSelectAll(this.checked)">
|
||||
</label>
|
||||
</th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colCreated'))}</th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colStatus'))}</th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colType'))}</th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colCommand'))}</th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colStatus'))}</th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colSession'))}</th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colTask'))}</th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colDuration'))}</th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colCreated'))}</th>
|
||||
<th class="c2-task-table-col-actions">${escapeHtml(c2t('c2.tasks.colActions'))}</th>
|
||||
<th class="c2-tasks-table-col-actions">${escapeHtml(c2t('c2.tasks.colActions'))}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${C2.tasks.map(t => {
|
||||
const rawId = t.id || '';
|
||||
const shortTaskId = rawId.length > 14 ? escapeHtml(rawId.substring(0, 12)) + '\u2026' : escapeHtml(rawId);
|
||||
const sid = t.sessionId ? escapeHtml(String(t.sessionId).substring(0, 8)) + '\u2026' : '-';
|
||||
const tid = escapeHtml(rawId);
|
||||
const status = String(t.status || '');
|
||||
const rowStatus = escapeHtml(status || 'queued');
|
||||
const typeCat = taskTypeCategory(t.taskType);
|
||||
const sessionFull = t.sessionId ? String(t.sessionId) : '';
|
||||
const sessionShort = sessionFull
|
||||
? escapeHtml(sessionFull.substring(0, 10)) + (sessionFull.length > 10 ? '\u2026' : '')
|
||||
: '';
|
||||
const taskShort = rawId
|
||||
? escapeHtml(rawId.substring(0, 10)) + (rawId.length > 10 ? '\u2026' : '')
|
||||
: '';
|
||||
const cmd = formatTaskCommand(t);
|
||||
const cmdShort = truncateCommand(cmd, 48);
|
||||
const cmdEsc = escapeHtml(cmd);
|
||||
const canCancel = status === 'queued' || status === 'sent';
|
||||
return `
|
||||
<tr>
|
||||
<td class="c2-task-table-col-check">
|
||||
<label class="c2-task-check-label" onclick="event.stopPropagation();">
|
||||
<input type="checkbox" class="c2-task-row-check" data-id="${escapeHtml(rawId)}" onchange="C2.syncTasksToolbar()">
|
||||
<tr class="c2-tasks-row c2-tasks-row--${rowStatus}" data-task-id="${tid}" onclick="C2.viewTask('${tid}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();C2.viewTask('${tid}')}" role="button" tabindex="0">
|
||||
<td class="c2-tasks-table-col-check" onclick="event.stopPropagation();">
|
||||
<label class="c2-task-check-label">
|
||||
<input type="checkbox" class="c2-task-row-check" data-id="${tid}" onchange="C2.syncTasksToolbar()">
|
||||
</label>
|
||||
</td>
|
||||
<td>${shortTaskId}</td>
|
||||
<td>${sid}</td>
|
||||
<td>${escapeHtml(t.taskType || '')}</td>
|
||||
<td class="c2-task-command-cell" title="${escapeHtml(cmd)}">${cmdShort ? escapeHtml(cmdShort) : '<span class="c2-muted">-</span>'}</td>
|
||||
<td><span class="c2-status-badge ${escapeHtml(t.status || '')}">${escapeHtml(taskStatusLabel(t.status))}</span></td>
|
||||
<td>${formatDuration(t.durationMs)}</td>
|
||||
<td>${formatTime(t.createdAt)}</td>
|
||||
<td class="c2-task-table-col-actions">
|
||||
<div class="c2-task-table-actions">
|
||||
<button type="button" class="btn-secondary btn-small" data-c2-task-action="view" data-task-id="${escapeHtml(rawId)}">${escapeHtml(c2t('c2.tasks.view'))}</button>
|
||||
${t.status === 'queued' || t.status === 'sent'
|
||||
? `<button type="button" class="btn-danger btn-small" data-c2-task-action="cancel" data-task-id="${escapeHtml(rawId)}">${escapeHtml(c2t('c2.tasks.cancelBtn'))}</button>`
|
||||
: ''}
|
||||
<button type="button" class="btn-danger btn-small" data-c2-task-action="delete" data-task-id="${escapeHtml(rawId)}" title="${delTitle}" aria-label="${delTitle}">${escapeHtml(c2t('c2.tasks.deleteBtn'))}</button>
|
||||
<td class="c2-tasks-col-time">${escapeHtml(formatTime(t.createdAt))}</td>
|
||||
<td><span class="c2-status-badge ${escapeHtml(status)}">${escapeHtml(taskStatusLabel(status))}</span></td>
|
||||
<td><span class="c2-task-type-badge c2-task-type-badge--${typeCat}">${escapeHtml(t.taskType || '-')}</span></td>
|
||||
<td class="c2-tasks-col-command" title="${cmdEsc}">${cmdEsc || dash}</td>
|
||||
<td class="c2-tasks-col-mono" title="${escapeHtml(sessionFull)}">${sessionShort || dash}</td>
|
||||
<td class="c2-tasks-col-mono" title="${tid}">${taskShort || dash}</td>
|
||||
<td class="c2-tasks-col-duration">${formatDuration(t.durationMs)}</td>
|
||||
<td class="c2-tasks-table-col-actions" onclick="event.stopPropagation();">
|
||||
<div class="c2-tasks-row-actions">
|
||||
${canCancel
|
||||
? `<button type="button" class="c2-tasks-cancel-btn" data-c2-task-action="cancel" data-task-id="${tid}" title="${cancelTitle}" aria-label="${cancelTitle}">${escapeHtml(c2t('c2.tasks.cancelBtn'))}</button>`
|
||||
: ''}
|
||||
<button type="button" class="c2-tasks-delete-btn" data-require-permission="c2:delete" data-c2-task-action="delete" data-task-id="${tid}" title="${delTitle}" aria-label="${delTitle}">${deleteIcon}</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
C2.syncTasksToolbar();
|
||||
if (typeof applyTranslations === 'function') applyTranslations(container);
|
||||
if (typeof rbacAfterDynamicRender === 'function') rbacAfterDynamicRender(container);
|
||||
};
|
||||
|
||||
C2.viewTask = function(id) {
|
||||
|
||||
+354
-10
@@ -80,11 +80,16 @@ let chatAttachmentSeq = 0;
|
||||
|
||||
// 对话模式:eino_single = Eino ADK 单代理(/api/eino-agent/stream);deep / plan_execute / supervisor = Eino 多代理(/api/multi-agent/stream,请求体 orchestration)
|
||||
const AGENT_MODE_STORAGE_KEY = 'cyberstrike-chat-agent-mode';
|
||||
const AI_CHANNEL_STORAGE_KEY = 'cyberstrike-chat-ai-channel';
|
||||
const REASONING_MODE_LS = 'cyberstrike-chat-reasoning-mode';
|
||||
const REASONING_EFFORT_LS = 'cyberstrike-chat-reasoning-effort';
|
||||
const CHAT_AI_CHANNEL_SUMMARY_NAME_MAX = 10;
|
||||
const CHAT_AGENT_MODE_EINO_SINGLE = 'eino_single';
|
||||
const CHAT_AGENT_EINO_MODES = ['deep', 'plan_execute', 'supervisor'];
|
||||
let multiAgentAPIEnabled = false;
|
||||
let chatAIChannels = {};
|
||||
let chatDefaultAIChannel = '';
|
||||
let chatAIChannelIdByNormalizedId = {};
|
||||
|
||||
// 人机协同(HITL)会话级配置
|
||||
const HITL_STORAGE_PREFIX = 'cyberstrike-chat-hitl';
|
||||
@@ -96,6 +101,190 @@ const HITL_MODE_APPROVAL = 'approval';
|
||||
const HITL_MODE_REVIEW_EDIT = 'review_edit';
|
||||
const HITL_MODE_OPTIONS = [HITL_MODE_OFF, HITL_MODE_APPROVAL, HITL_MODE_REVIEW_EDIT];
|
||||
let hitlApplyFeedbackTimer = null;
|
||||
let hitlAutoSaveTimer = null;
|
||||
const sessionSettingsSelects = new Map();
|
||||
let sessionSettingsSelectDocBound = false;
|
||||
|
||||
function sessionSettingsSelectLabel(option) {
|
||||
return option ? (option.textContent || option.label || option.value || '') : '';
|
||||
}
|
||||
|
||||
function syncSessionSettingsSelect(select) {
|
||||
const reg = sessionSettingsSelects.get(select);
|
||||
if (!reg) return;
|
||||
const selected = select.options[select.selectedIndex];
|
||||
reg.value.textContent = sessionSettingsSelectLabel(selected);
|
||||
reg.trigger.disabled = !!select.disabled;
|
||||
reg.wrapper.classList.toggle('is-disabled', !!select.disabled);
|
||||
reg.menu.innerHTML = '';
|
||||
|
||||
Array.prototype.forEach.call(select.options, function (option, index) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'session-settings-select-option';
|
||||
item.setAttribute('role', 'option');
|
||||
item.setAttribute('data-index', String(index));
|
||||
item.setAttribute('aria-selected', option.selected ? 'true' : 'false');
|
||||
item.disabled = !!option.disabled;
|
||||
item.classList.toggle('is-selected', !!option.selected);
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'session-settings-select-option-label';
|
||||
label.textContent = sessionSettingsSelectLabel(option);
|
||||
item.appendChild(label);
|
||||
reg.menu.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function closeSessionSettingsSelect(select) {
|
||||
const reg = sessionSettingsSelects.get(select);
|
||||
if (!reg) return;
|
||||
reg.wrapper.classList.remove('open');
|
||||
reg.trigger.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
function closeAllSessionSettingsSelects() {
|
||||
sessionSettingsSelects.forEach(function (_reg, select) {
|
||||
closeSessionSettingsSelect(select);
|
||||
});
|
||||
}
|
||||
|
||||
function enhanceSessionSettingsSelect(select) {
|
||||
if (!select || select.dataset.sessionSettingsSelect === '1') {
|
||||
if (select) syncSessionSettingsSelect(select);
|
||||
return;
|
||||
}
|
||||
const panel = select.closest && select.closest('.conversation-reasoning-card');
|
||||
if (!panel) return;
|
||||
|
||||
select.dataset.sessionSettingsSelect = '1';
|
||||
select.classList.add('session-settings-native-select');
|
||||
select.tabIndex = -1;
|
||||
select.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'session-settings-select';
|
||||
const trigger = document.createElement('button');
|
||||
trigger.type = 'button';
|
||||
trigger.className = 'session-settings-select-trigger';
|
||||
trigger.setAttribute('aria-haspopup', 'listbox');
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
const value = document.createElement('span');
|
||||
value.className = 'session-settings-select-value';
|
||||
const caret = document.createElement('span');
|
||||
caret.className = 'session-settings-select-caret';
|
||||
caret.setAttribute('aria-hidden', 'true');
|
||||
caret.textContent = '⌄';
|
||||
trigger.appendChild(value);
|
||||
trigger.appendChild(caret);
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'session-settings-select-menu';
|
||||
menu.setAttribute('role', 'listbox');
|
||||
|
||||
select.parentNode.insertBefore(wrapper, select);
|
||||
wrapper.appendChild(trigger);
|
||||
wrapper.appendChild(menu);
|
||||
wrapper.appendChild(select);
|
||||
sessionSettingsSelects.set(select, { wrapper: wrapper, trigger: trigger, value: value, menu: menu });
|
||||
|
||||
trigger.addEventListener('click', function (event) {
|
||||
event.stopPropagation();
|
||||
if (select.disabled) return;
|
||||
const willOpen = !wrapper.classList.contains('open');
|
||||
closeAllSessionSettingsSelects();
|
||||
wrapper.classList.toggle('open', willOpen);
|
||||
trigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
|
||||
});
|
||||
|
||||
trigger.addEventListener('keydown', function (event) {
|
||||
if (select.disabled) return;
|
||||
const enabled = Array.prototype.filter.call(select.options, function (option) { return !option.disabled; });
|
||||
if (!enabled.length) return;
|
||||
const currentOption = select.options[select.selectedIndex];
|
||||
const current = Math.max(0, enabled.indexOf(currentOption));
|
||||
let next = current;
|
||||
if (event.key === 'ArrowDown') next = Math.min(enabled.length - 1, current + 1);
|
||||
else if (event.key === 'ArrowUp') next = Math.max(0, current - 1);
|
||||
else if (event.key === 'Home') next = 0;
|
||||
else if (event.key === 'End') next = enabled.length - 1;
|
||||
else if (event.key === 'Escape') {
|
||||
closeSessionSettingsSelect(select);
|
||||
return;
|
||||
} else if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
wrapper.classList.add('open');
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
return;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const nextOption = enabled[next];
|
||||
if (nextOption && select.value !== nextOption.value) {
|
||||
select.value = nextOption.value;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
syncSessionSettingsSelect(select);
|
||||
});
|
||||
|
||||
menu.addEventListener('click', function (event) {
|
||||
const item = event.target.closest('.session-settings-select-option');
|
||||
if (!item || item.disabled) return;
|
||||
event.stopPropagation();
|
||||
const option = select.options[Number(item.dataset.index)];
|
||||
if (option && !option.disabled && select.value !== option.value) {
|
||||
select.value = option.value;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
syncSessionSettingsSelect(select);
|
||||
closeSessionSettingsSelect(select);
|
||||
});
|
||||
|
||||
select.addEventListener('change', function () {
|
||||
syncSessionSettingsSelect(select);
|
||||
});
|
||||
syncSessionSettingsSelect(select);
|
||||
}
|
||||
|
||||
function initSessionSettingsSelects() {
|
||||
const panel = document.getElementById('conversation-reasoning-body');
|
||||
if (!panel) return;
|
||||
panel.querySelectorAll('select').forEach(enhanceSessionSettingsSelect);
|
||||
if (!sessionSettingsSelectDocBound) {
|
||||
document.addEventListener('click', closeAllSessionSettingsSelects);
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape') closeAllSessionSettingsSelects();
|
||||
});
|
||||
sessionSettingsSelectDocBound = true;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSessionSettingsSelects() {
|
||||
sessionSettingsSelects.forEach(function (_reg, select) {
|
||||
syncSessionSettingsSelect(select);
|
||||
});
|
||||
}
|
||||
|
||||
function syncChatReasoningBarHeight() {
|
||||
const inputBar = document.getElementById('chat-input-container');
|
||||
const reasoning = document.getElementById('chat-reasoning-wrapper');
|
||||
if (!inputBar || !reasoning) return;
|
||||
const h = Math.ceil(inputBar.getBoundingClientRect().height || 0);
|
||||
if (h > 0) {
|
||||
reasoning.style.setProperty('--chat-input-bar-height', h + 'px');
|
||||
}
|
||||
}
|
||||
|
||||
function initChatReasoningBarHeightSync() {
|
||||
syncChatReasoningBarHeight();
|
||||
window.addEventListener('resize', syncChatReasoningBarHeight);
|
||||
const inputBar = document.getElementById('chat-input-container');
|
||||
if (inputBar && typeof ResizeObserver !== 'undefined') {
|
||||
const ro = new ResizeObserver(syncChatReasoningBarHeight);
|
||||
ro.observe(inputBar);
|
||||
}
|
||||
}
|
||||
|
||||
/** 非阻塞提示(与 chat-files-toast 样式共用) */
|
||||
function showChatToast(message, type) {
|
||||
@@ -391,7 +580,7 @@ function readHitlConfigFromForm() {
|
||||
}
|
||||
|
||||
function updateHitlStatusUI(_cfg) {
|
||||
/* 侧栏已改为「应用」按钮生效,不再用角标展示模式 */
|
||||
/* 侧栏已改为自动保存,不再用角标展示模式 */
|
||||
}
|
||||
|
||||
function applyHitlConfigToUI(cfg) {
|
||||
@@ -409,6 +598,7 @@ function applyHitlConfigToUI(cfg) {
|
||||
}
|
||||
if (toolsEl) toolsEl.value = toolsVal;
|
||||
updateHitlStatusUI(conf);
|
||||
refreshSessionSettingsSelects();
|
||||
}
|
||||
|
||||
function bindHitlSidebarModeListener() {
|
||||
@@ -417,6 +607,9 @@ function bindHitlSidebarModeListener() {
|
||||
modeEl.dataset.hitlModeBound = '1';
|
||||
modeEl.addEventListener('change', function () {
|
||||
applyHitlConfigToUI(readHitlConfigFromForm());
|
||||
refreshSessionSettingsSelects();
|
||||
scheduleHitlSidebarAutosave(0);
|
||||
updateChatReasoningSummary();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -458,7 +651,7 @@ function showHitlApplyFeedback(text, isError, partial) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 侧栏人机协同:修改模式/白名单后点此写入本地、合并展示并同步服务端 */
|
||||
/** 侧栏人机协同:自动写入本地、合并展示并尽量同步服务端 */
|
||||
async function applyHitlSidebarConfig() {
|
||||
const btn = document.getElementById('hitl-apply-btn');
|
||||
showHitlApplyFeedback('', false);
|
||||
@@ -486,7 +679,7 @@ async function applyHitlSidebarConfig() {
|
||||
const ok = typeof window.t === 'function' ? window.t('chat.hitlApplyOkSync') : '人机协同配置已保存并同步到服务器。';
|
||||
showHitlApplyFeedback(ok, false);
|
||||
} else if (yamlMerged) {
|
||||
const okYaml = typeof window.t === 'function' ? window.t('chat.hitlApplyOkWhitelistYaml') : '免审批工具已合并进 config.yaml 并生效。协同模式、超时等仍须选中会话后再点「应用」才会写入服务器。';
|
||||
const okYaml = typeof window.t === 'function' ? window.t('chat.hitlApplyOkWhitelistYaml') : '免审批工具已合并进 config.yaml 并生效。会话配置会自动保存。';
|
||||
showHitlApplyFeedback(okYaml, false);
|
||||
} else {
|
||||
const localOnly = typeof window.t === 'function' ? window.t('chat.hitlApplyOkLocal') : '已保存到本浏览器。';
|
||||
@@ -505,6 +698,29 @@ async function applyHitlSidebarConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleHitlSidebarAutosave(delayMs) {
|
||||
if (hitlAutoSaveTimer) {
|
||||
clearTimeout(hitlAutoSaveTimer);
|
||||
hitlAutoSaveTimer = null;
|
||||
}
|
||||
hitlAutoSaveTimer = setTimeout(function () {
|
||||
hitlAutoSaveTimer = null;
|
||||
applyHitlSidebarConfig();
|
||||
}, typeof delayMs === 'number' ? delayMs : 500);
|
||||
}
|
||||
|
||||
function bindHitlSensitiveToolsAutosaveListener() {
|
||||
const toolsEl = document.getElementById('hitl-sensitive-tools');
|
||||
if (!toolsEl || toolsEl.dataset.hitlAutosaveBound === '1') return;
|
||||
toolsEl.dataset.hitlAutosaveBound = '1';
|
||||
toolsEl.addEventListener('input', function () {
|
||||
scheduleHitlSidebarAutosave(700);
|
||||
});
|
||||
toolsEl.addEventListener('blur', function () {
|
||||
scheduleHitlSidebarAutosave(0);
|
||||
});
|
||||
}
|
||||
|
||||
/** 将 localStorage 规范为 eino_single | deep | plan_execute | supervisor */
|
||||
function chatAgentModeNormalizeStored(stored, cfg) {
|
||||
const pub = cfg && cfg.multi_agent ? cfg.multi_agent : null;
|
||||
@@ -536,6 +752,7 @@ if (typeof window !== 'undefined') {
|
||||
window.getHitlConfigForConversation = getHitlConfigForConversation;
|
||||
bindHitlSidebarModeListener();
|
||||
bindHitlReviewerToggleListeners();
|
||||
bindHitlSensitiveToolsAutosaveListener();
|
||||
window.setHitlReviewerUI = setHitlReviewerUI;
|
||||
window.onHitlReviewerChanged = onHitlReviewerChanged;
|
||||
window.bindHitlReviewerToggleListeners = bindHitlReviewerToggleListeners;
|
||||
@@ -638,10 +855,85 @@ function syncReasoningRowVisibility(modeVal) {
|
||||
if (!show) {
|
||||
closeChatReasoningPanel();
|
||||
} else {
|
||||
syncChatReasoningBarHeight();
|
||||
updateChatReasoningSummary();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeChatAIChannelId(s) {
|
||||
return String(s || '').trim().toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function resolveChatAIChannelId(id) {
|
||||
const raw = String(id || '').trim();
|
||||
if (!raw) return '';
|
||||
if (chatAIChannels[raw]) return raw;
|
||||
const normalized = normalizeChatAIChannelId(raw);
|
||||
return normalized && chatAIChannelIdByNormalizedId[normalized] ? chatAIChannelIdByNormalizedId[normalized] : '';
|
||||
}
|
||||
|
||||
function populateChatAIChannelSelect(ai) {
|
||||
const select = document.getElementById('chat-ai-channel-select');
|
||||
if (!select) return;
|
||||
const cfg = ai && typeof ai === 'object' ? ai : {};
|
||||
chatAIChannels = cfg.channels && typeof cfg.channels === 'object' ? cfg.channels : {};
|
||||
chatAIChannelIdByNormalizedId = {};
|
||||
Object.keys(chatAIChannels).forEach(function (id) {
|
||||
const normalized = normalizeChatAIChannelId(id);
|
||||
if (normalized && !chatAIChannelIdByNormalizedId[normalized]) {
|
||||
chatAIChannelIdByNormalizedId[normalized] = id;
|
||||
}
|
||||
});
|
||||
chatDefaultAIChannel = resolveChatAIChannelId(cfg.default_channel || '');
|
||||
select.innerHTML = '';
|
||||
const fallbackOpt = document.createElement('option');
|
||||
fallbackOpt.value = '';
|
||||
fallbackOpt.textContent = typeof window.t === 'function' ? window.t('chat.aiChannelDefault') : '跟随默认通道';
|
||||
select.appendChild(fallbackOpt);
|
||||
Object.keys(chatAIChannels).sort().forEach(function (id) {
|
||||
const ch = chatAIChannels[id] || {};
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id;
|
||||
opt.textContent = (ch.name || id) + (ch.model ? ' · ' + ch.model : '');
|
||||
select.appendChild(opt);
|
||||
});
|
||||
let stored = '';
|
||||
try { stored = localStorage.getItem(AI_CHANNEL_STORAGE_KEY) || ''; } catch (e) {}
|
||||
stored = resolveChatAIChannelId(stored);
|
||||
select.value = stored || '';
|
||||
refreshSessionSettingsSelects();
|
||||
updateChatReasoningSummary();
|
||||
}
|
||||
|
||||
function selectedChatAIChannelId() {
|
||||
const select = document.getElementById('chat-ai-channel-select');
|
||||
return resolveChatAIChannelId(select ? select.value : '');
|
||||
}
|
||||
|
||||
function currentChatAIChannelLabel() {
|
||||
const id = selectedChatAIChannelId() || chatDefaultAIChannel;
|
||||
const ch = id ? chatAIChannels[id] : null;
|
||||
if (!ch) {
|
||||
return typeof window.t === 'function' ? window.t('chat.aiChannelDefaultShort') : '默认通道';
|
||||
}
|
||||
return ch.name || id;
|
||||
}
|
||||
|
||||
function truncateChatAIChannelSummaryLabel(label) {
|
||||
const chars = Array.from(String(label || ''));
|
||||
if (chars.length <= CHAT_AI_CHANNEL_SUMMARY_NAME_MAX) return chars.join('');
|
||||
return chars.slice(0, CHAT_AI_CHANNEL_SUMMARY_NAME_MAX).join('') + '...';
|
||||
}
|
||||
|
||||
function persistChatAIChannelPref() {
|
||||
const id = selectedChatAIChannelId();
|
||||
try {
|
||||
if (id) localStorage.setItem(AI_CHANNEL_STORAGE_KEY, id);
|
||||
else localStorage.removeItem(AI_CHANNEL_STORAGE_KEY);
|
||||
} catch (e) {}
|
||||
updateChatReasoningSummary();
|
||||
}
|
||||
|
||||
function reasoningSummaryModeLabel(mode) {
|
||||
const m = (mode || 'default').trim();
|
||||
const t = (typeof window.t === 'function') ? window.t : function (k) { return k; };
|
||||
@@ -662,8 +954,18 @@ function updateChatReasoningSummary() {
|
||||
const effort = effEl && effEl.value ? String(effEl.value).trim() : '';
|
||||
const t = (typeof window.t === 'function') ? window.t : function (k) { return k; };
|
||||
const modePart = reasoningSummaryModeLabel(mode);
|
||||
const effPart = effort || t('chat.reasoningSummaryDash');
|
||||
el.textContent = modePart + ' / ' + effPart;
|
||||
const reasoningPart = effort || modePart || t('chat.reasoningSummaryDash');
|
||||
let hitlPart = '';
|
||||
try {
|
||||
const hitlCfg = readHitlConfigFromForm();
|
||||
hitlPart = getHitlModeLabel(hitlCfg.mode);
|
||||
} catch (e) {
|
||||
hitlPart = '';
|
||||
}
|
||||
const channelPart = currentChatAIChannelLabel();
|
||||
const parts = [truncateChatAIChannelSummaryLabel(channelPart), reasoningPart, hitlPart].filter(Boolean);
|
||||
el.textContent = parts.join(' / ');
|
||||
el.title = [channelPart, reasoningPart, hitlPart].filter(Boolean).join(' / ');
|
||||
}
|
||||
|
||||
function closeChatReasoningPanel() {
|
||||
@@ -677,6 +979,7 @@ function toggleConversationReasoningCard() {
|
||||
const wrap = document.getElementById('chat-reasoning-wrapper');
|
||||
const toggle = document.getElementById('conversation-reasoning-toggle');
|
||||
if (!wrap || !toggle) return;
|
||||
syncChatReasoningBarHeight();
|
||||
wrap.classList.toggle('conversation-reasoning-collapsed');
|
||||
const collapsed = wrap.classList.contains('conversation-reasoning-collapsed');
|
||||
toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
@@ -711,6 +1014,7 @@ function restoreChatReasoningControlsFromStorage() {
|
||||
e.value = v;
|
||||
}
|
||||
}
|
||||
refreshSessionSettingsSelects();
|
||||
updateChatReasoningSummary();
|
||||
} catch (err) { /* ignore */ }
|
||||
}
|
||||
@@ -721,6 +1025,7 @@ function persistChatReasoningPrefs() {
|
||||
const elEff = document.getElementById('chat-reasoning-effort');
|
||||
if (m) localStorage.setItem(REASONING_MODE_LS, m.value || 'default');
|
||||
if (elEff) localStorage.setItem(REASONING_EFFORT_LS, elEff.value || '');
|
||||
refreshSessionSettingsSelects();
|
||||
updateChatReasoningSummary();
|
||||
} catch (err) { /* ignore */ }
|
||||
}
|
||||
@@ -746,12 +1051,15 @@ function buildReasoningRequestPayload() {
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.persistChatAIChannelPref = persistChatAIChannelPref;
|
||||
window.populateChatAIChannelSelect = populateChatAIChannelSelect;
|
||||
window.persistChatReasoningPrefs = persistChatReasoningPrefs;
|
||||
window.buildReasoningRequestPayload = buildReasoningRequestPayload;
|
||||
window.closeChatReasoningPanel = closeChatReasoningPanel;
|
||||
window.toggleChatReasoningPanel = toggleChatReasoningPanel;
|
||||
window.toggleConversationReasoningCard = toggleConversationReasoningCard;
|
||||
window.updateChatReasoningSummary = updateChatReasoningSummary;
|
||||
window.refreshSessionSettingsSelects = refreshSessionSettingsSelects;
|
||||
}
|
||||
|
||||
function closeAgentModePanel() {
|
||||
@@ -826,6 +1134,7 @@ async function initChatAgentModeFromConfig() {
|
||||
if (!r.ok) return;
|
||||
const cfg = await r.json();
|
||||
multiAgentAPIEnabled = !!(cfg.multi_agent && cfg.multi_agent.enabled);
|
||||
populateChatAIChannelSelect(cfg.ai || {});
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__csaiMultiAgentPublic = cfg.multi_agent || null;
|
||||
const tw = cfg.hitl && cfg.hitl.tool_whitelist;
|
||||
@@ -1042,10 +1351,20 @@ async function sendMessage() {
|
||||
conversationId: currentConversationId,
|
||||
role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
|
||||
};
|
||||
let streamConversationId = body.conversationId ? String(body.conversationId) : null;
|
||||
const isStreamStillVisibleForRequest = function () {
|
||||
if (!document.getElementById(progressId)) return false;
|
||||
if (!streamConversationId) return currentConversationId === body.conversationId;
|
||||
return currentConversationId === streamConversationId;
|
||||
};
|
||||
if (!currentConversationId && typeof getActiveProjectId === 'function') {
|
||||
const pid = getActiveProjectId();
|
||||
if (pid) body.projectId = pid;
|
||||
}
|
||||
const aiChannelId = selectedChatAIChannelId();
|
||||
if (aiChannelId) {
|
||||
body.aiChannelId = aiChannelId;
|
||||
}
|
||||
const hitlCfg = readHitlConfigFromForm();
|
||||
if (normalizeHitlMode(hitlCfg.mode) !== HITL_MODE_OFF) {
|
||||
const sensitiveTools = hitlToolsSplitToArray(hitlCfg.sensitiveTools || '');
|
||||
@@ -1078,7 +1397,7 @@ async function sendMessage() {
|
||||
window.CyberStrikeChatScroll.onUserSendMessage();
|
||||
}
|
||||
const progressElement = document.getElementById(progressId);
|
||||
registerProgressTask(progressId, currentConversationId);
|
||||
registerProgressTask(progressId, streamConversationId);
|
||||
loadActiveTasks();
|
||||
let assistantMessageId = null;
|
||||
let mcpExecutionIds = [];
|
||||
@@ -1105,7 +1424,7 @@ async function sendMessage() {
|
||||
|
||||
window.__csAgentLiveStream = {
|
||||
active: true,
|
||||
conversationId: currentConversationId || null,
|
||||
conversationId: streamConversationId || null,
|
||||
progressId: progressId
|
||||
};
|
||||
try {
|
||||
@@ -1117,9 +1436,26 @@ async function sendMessage() {
|
||||
if (eventData && eventData.type === 'done') {
|
||||
streamSawDone = true;
|
||||
}
|
||||
const eventConvId = eventData && eventData.data && eventData.data.conversationId
|
||||
? String(eventData.data.conversationId)
|
||||
: '';
|
||||
let justBoundConversation = false;
|
||||
if (eventConvId) {
|
||||
if (streamConversationId && streamConversationId !== eventConvId) {
|
||||
return;
|
||||
}
|
||||
if (!streamConversationId && eventData.type === 'conversation') {
|
||||
streamConversationId = eventConvId;
|
||||
justBoundConversation = true;
|
||||
}
|
||||
}
|
||||
if (!justBoundConversation && !isStreamStillVisibleForRequest()) {
|
||||
return;
|
||||
}
|
||||
handleStreamEvent(eventData, progressElement, progressId,
|
||||
() => assistantMessageId, (id) => { assistantMessageId = id; },
|
||||
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; });
|
||||
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; },
|
||||
{ conversationId: streamConversationId });
|
||||
};
|
||||
const processSseLines = typeof processSseDataLinesYielding === 'function'
|
||||
? processSseDataLinesYielding
|
||||
@@ -1157,13 +1493,13 @@ async function sendMessage() {
|
||||
if (typeof loadActiveTasks === 'function') {
|
||||
loadActiveTasks();
|
||||
}
|
||||
const convId = currentConversationId || (body && body.conversationId) || null;
|
||||
const convId = streamConversationId || (body && body.conversationId) || null;
|
||||
let attached = false;
|
||||
if (convId && typeof window.attachRunningTaskEventStream === 'function') {
|
||||
window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null };
|
||||
attached = await window.attachRunningTaskEventStream(convId).catch(() => false);
|
||||
}
|
||||
if (!attached) {
|
||||
if (!attached && isStreamStillVisibleForRequest()) {
|
||||
const hint = typeof window.t === 'function'
|
||||
? window.t('chat.streamEndedWithoutDone')
|
||||
: '连接提前结束,未收到任务完成信号。任务可能仍在后端执行,请查看顶部运行中任务或刷新当前对话。';
|
||||
@@ -1186,6 +1522,12 @@ async function sendMessage() {
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (!isStreamStillVisibleForRequest()) {
|
||||
if (typeof loadActiveTasks === 'function') {
|
||||
loadActiveTasks();
|
||||
}
|
||||
return;
|
||||
}
|
||||
removeMessage(progressId);
|
||||
const msg = error && error.message != null ? String(error.message) : String(error);
|
||||
const isNetwork = /network|fetch|Failed to fetch|aborted|AbortError|load failed|NetworkError/i.test(msg);
|
||||
@@ -10201,6 +10543,8 @@ function applyCustomIcon() {
|
||||
|
||||
// 自定义图标输入框回车键处理
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initSessionSettingsSelects();
|
||||
initChatReasoningBarHeightSync();
|
||||
const customInput = document.getElementById('custom-icon-input');
|
||||
if (customInput) {
|
||||
customInput.addEventListener('keydown', function(e) {
|
||||
|
||||
@@ -29,6 +29,16 @@ var dashboardState = {
|
||||
lastProjectSummary: null, // 最近一次项目仪表盘摘要(供 Tab 切换时重绘)
|
||||
};
|
||||
|
||||
function dashboardProjectScopedUrl(url) {
|
||||
try {
|
||||
var pid = typeof getActiveProjectId === 'function' ? (getActiveProjectId() || '') : '';
|
||||
if (!pid) return url;
|
||||
return url + (url.indexOf('?') === -1 ? '?' : '&') + 'project_id=' + encodeURIComponent(pid);
|
||||
} catch (e) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDashboard() {
|
||||
const runningEl = document.getElementById('dashboard-running-tasks');
|
||||
const vulnTotalEl = document.getElementById('dashboard-vuln-total');
|
||||
@@ -143,11 +153,11 @@ async function refreshDashboard() {
|
||||
// External MCP 健康度
|
||||
fetchJson('/api/external-mcp/stats'),
|
||||
// WebShell 已建立的连接(pentest 落地后的 foothold,对运营场景非常关键)
|
||||
fetchJson('/api/webshell/connections'),
|
||||
fetchJson(dashboardProjectScopedUrl('/api/webshell/connections')),
|
||||
// C2 仪表盘条:监听器 / 会话 / 待处理任务(任务接口含 pending_queued_count)
|
||||
fetchJson('/api/c2/listeners'),
|
||||
fetchJson('/api/c2/sessions?limit=500'),
|
||||
fetchJson('/api/c2/tasks?page=1&page_size=1'),
|
||||
fetchJson(dashboardProjectScopedUrl('/api/c2/listeners')),
|
||||
fetchJson(dashboardProjectScopedUrl('/api/c2/sessions?limit=500')),
|
||||
fetchJson(dashboardProjectScopedUrl('/api/c2/tasks?page=1&page_size=1')),
|
||||
fetchJson('/api/projects/dashboard-summary?fact_limit=10'),
|
||||
selectedSeverityStatus ? fetchJson('/api/vulnerabilities/stats?status=' + encodeURIComponent(selectedSeverityStatus)) : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
@@ -615,6 +615,28 @@ function reconcileHitlUiState() {
|
||||
|
||||
let hitlFollowRunSeq = 0;
|
||||
|
||||
function hitlAutoResizeTextarea(textarea) {
|
||||
if (!textarea) return;
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.max(textarea.scrollHeight, textarea.offsetHeight || 0) + 'px';
|
||||
}
|
||||
|
||||
function bindHitlAutoResizeTextareas(root) {
|
||||
const scope = root || document;
|
||||
if (!scope || !scope.querySelectorAll) return;
|
||||
scope.querySelectorAll('.hitl-edit-args').forEach(function (textarea) {
|
||||
if (textarea.__hitlAutoResizeBound) {
|
||||
hitlAutoResizeTextarea(textarea);
|
||||
return;
|
||||
}
|
||||
textarea.__hitlAutoResizeBound = true;
|
||||
hitlAutoResizeTextarea(textarea);
|
||||
textarea.addEventListener('input', function () {
|
||||
hitlAutoResizeTextarea(textarea);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批提交后原 SSE 已断开:轮询任务列表,运行中则拉取过程详情;任务结束后再整页加载会话以对齐终态。
|
||||
*/
|
||||
@@ -855,6 +877,7 @@ async function refreshHitlPending() {
|
||||
} else {
|
||||
container.innerHTML = workflowHtml + (workflowHtml && toolHtml ? '<div class="hitl-pending-section-divider"></div>' : '') + (toolHtml || '');
|
||||
}
|
||||
bindHitlAutoResizeTextareas(container);
|
||||
renderHitlPendingPagination();
|
||||
} catch (e) {
|
||||
hitlPendingLoaded = false;
|
||||
|
||||
+329
-45
@@ -4,6 +4,7 @@ let userInterruptModalPending = null;
|
||||
let activeTaskInterval = null;
|
||||
const ACTIVE_TASK_REFRESH_INTERVAL = 10000; // 10秒检查一次
|
||||
const TASK_FINAL_STATUSES = new Set(['failed', 'timeout', 'cancelled', 'completed']);
|
||||
const hitlInterruptToolItemMap = new Map();
|
||||
|
||||
/**
|
||||
* 主对话 POST 流仍在读取时,禁止再挂 task-events 补流,否则同一事件会画两遍(与 HITL 是否开启无关)。
|
||||
@@ -19,6 +20,13 @@ function syncAgentLiveStreamConversationId(cid) {
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function setCurrentConversationIdFromStream(cid) {
|
||||
currentConversationId = cid;
|
||||
try {
|
||||
window.currentConversationId = cid;
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function shouldSkipTaskEventReplayAttach(conversationId) {
|
||||
try {
|
||||
const live = window.__csAgentLiveStream;
|
||||
@@ -1984,7 +1992,32 @@ function formatEinoRunRetryTitle(data) {
|
||||
|
||||
// 处理流式事件
|
||||
function handleStreamEvent(event, progressElement, progressId,
|
||||
getAssistantId, setAssistantId, getMcpIds, setMcpIds) {
|
||||
getAssistantId, setAssistantId, getMcpIds, setMcpIds, options) {
|
||||
const expectedConversationId = options && options.conversationId
|
||||
? String(options.conversationId)
|
||||
: '';
|
||||
const eventConversationId = event && event.data && event.data.conversationId
|
||||
? String(event.data.conversationId)
|
||||
: '';
|
||||
if (expectedConversationId) {
|
||||
if (eventConversationId && eventConversationId !== expectedConversationId) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof window.currentConversationId === 'string' &&
|
||||
window.currentConversationId &&
|
||||
window.currentConversationId !== expectedConversationId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const progressNode = progressId ? document.getElementById(progressId) : null;
|
||||
const progressConversationId = progressNode && progressNode.dataset
|
||||
? String(progressNode.dataset.conversationId || '')
|
||||
: '';
|
||||
if (progressConversationId && progressConversationId !== expectedConversationId) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const streamScrollWasPinned = typeof window.captureScrollPinState === 'function'
|
||||
? window.captureScrollPinState()
|
||||
: (typeof window.isChatMessagesPinnedToBottom === 'function' ? window.isChatMessagesPinnedToBottom() : true);
|
||||
@@ -2048,7 +2081,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
}
|
||||
|
||||
// 更新当前对话ID
|
||||
currentConversationId = event.data.conversationId;
|
||||
setCurrentConversationIdFromStream(event.data.conversationId);
|
||||
syncAgentLiveStreamConversationId(event.data.conversationId);
|
||||
updateActiveConversation();
|
||||
addAttackChainButton(currentConversationId);
|
||||
@@ -2408,29 +2441,38 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
break;
|
||||
|
||||
case 'hitl_interrupt':
|
||||
const hitlItemId = addTimelineItem(timeline, 'warning', {
|
||||
title: '🧑⚖️ HITL',
|
||||
message: event.message,
|
||||
data: event.data
|
||||
});
|
||||
renderInlineHitlApproval(hitlItemId, event.data || {});
|
||||
const hitlTargetItem = findToolCallItemForHitl(timeline, event.data || {});
|
||||
if (hitlTargetItem && hitlTargetItem.id) {
|
||||
renderInlineHitlApproval(hitlTargetItem.id, event.data || {});
|
||||
} else {
|
||||
const hitlItemId = addTimelineItem(timeline, 'hitl_interrupt', {
|
||||
title: '🧑⚖️ HITL',
|
||||
message: event.message,
|
||||
data: event.data
|
||||
});
|
||||
renderInlineHitlApproval(hitlItemId, event.data || {});
|
||||
}
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('hitl-interrupt', { detail: event.data || {} }));
|
||||
} catch (e) {}
|
||||
break;
|
||||
case 'hitl_resumed':
|
||||
addTimelineItem(timeline, 'progress', {
|
||||
title: '✅ HITL',
|
||||
message: event.message,
|
||||
data: event.data
|
||||
});
|
||||
if (!resolveInlineHitlDecision(timeline, event.data || {}, 'approve', event.message)) {
|
||||
addTimelineItem(timeline, 'progress', {
|
||||
title: '✅ HITL',
|
||||
message: event.message,
|
||||
data: event.data
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'hitl_rejected':
|
||||
addTimelineItem(timeline, 'error', {
|
||||
title: '⛔ HITL',
|
||||
message: event.message,
|
||||
data: event.data
|
||||
});
|
||||
if (!resolveInlineHitlDecision(timeline, event.data || {}, 'reject', event.message)) {
|
||||
addTimelineItem(timeline, 'error', {
|
||||
title: '⛔ HITL',
|
||||
message: event.message,
|
||||
data: event.data
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'user_interrupt_continue': {
|
||||
@@ -2801,7 +2843,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
updateProgressConversation(progressId, responseData.conversationId);
|
||||
break;
|
||||
}
|
||||
currentConversationId = responseData.conversationId;
|
||||
setCurrentConversationIdFromStream(responseData.conversationId);
|
||||
syncAgentLiveStreamConversationId(responseData.conversationId);
|
||||
updateActiveConversation();
|
||||
addAttackChainButton(currentConversationId);
|
||||
@@ -2904,7 +2946,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
break;
|
||||
}
|
||||
|
||||
currentConversationId = responseData.conversationId;
|
||||
setCurrentConversationIdFromStream(responseData.conversationId);
|
||||
syncAgentLiveStreamConversationId(responseData.conversationId);
|
||||
updateActiveConversation();
|
||||
addAttackChainButton(currentConversationId);
|
||||
@@ -3064,7 +3106,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
}
|
||||
// 更新对话ID
|
||||
if (event.data && event.data.conversationId) {
|
||||
currentConversationId = event.data.conversationId;
|
||||
setCurrentConversationIdFromStream(event.data.conversationId);
|
||||
syncAgentLiveStreamConversationId(event.data.conversationId);
|
||||
updateActiveConversation();
|
||||
addAttackChainButton(currentConversationId);
|
||||
@@ -3115,6 +3157,22 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
function renderInlineHitlApproval(itemId, data) {
|
||||
const item = document.getElementById(itemId);
|
||||
if (!item || !data || !data.interruptId) return;
|
||||
if (item.classList.contains('timeline-item-tool_call')) {
|
||||
const state = toolCallDetailStateByItemId.get(item.id) || {};
|
||||
state.hitlData = data;
|
||||
state.pending = true;
|
||||
setToolCallDetailState(item, state);
|
||||
if (data.interruptId) {
|
||||
hitlInterruptToolItemMap.set(String(data.interruptId), item.id);
|
||||
}
|
||||
const existingContent = item.querySelector('.timeline-item-content.tool-call-detail-content');
|
||||
if (existingContent) {
|
||||
existingContent.remove();
|
||||
item.classList.remove('tool-call-detail-expanded');
|
||||
}
|
||||
renderToolCallDetailContent(item);
|
||||
return;
|
||||
}
|
||||
let contentEl = item.querySelector('.timeline-item-content');
|
||||
if (!contentEl) {
|
||||
// warning 等类型默认没有内容区域;HITL 内联审批需要可交互容器
|
||||
@@ -3136,31 +3194,165 @@ function renderInlineHitlApproval(itemId, data) {
|
||||
const allowEdit = mode === 'review_edit';
|
||||
const argsObj = payload.argumentsObj && typeof payload.argumentsObj === 'object' ? payload.argumentsObj : {};
|
||||
const argsJSON = JSON.stringify(argsObj, null, 2);
|
||||
const modeLabel = mode === 'review_edit' ? '审查编辑' : '审批模式';
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'hitl-inline-approval';
|
||||
panel.innerHTML = `
|
||||
<div class="hitl-input-help"><strong>${escapeHtml(toolName)}</strong> 待人工审批。模式:${escapeHtml(mode || '-')}。</div>
|
||||
${allowEdit
|
||||
? `<div class="hitl-input-help">审查编辑参数(JSON,可选):留空表示沿用原参数。</div>
|
||||
<textarea class="hitl-edit-args hitl-inline-edit" placeholder='{"command":"ls -la"}'>${escapeHtml(argsJSON === '{}' ? '' : argsJSON)}</textarea>`
|
||||
: '<div class="hitl-input-help">当前模式不支持改参,仅可通过/拒绝。</div>'
|
||||
panel.innerHTML = buildInlineHitlApprovalHtml(data, {
|
||||
toolName: toolName,
|
||||
mode: mode,
|
||||
modeLabel: modeLabel,
|
||||
allowEdit: allowEdit,
|
||||
argsJSON: argsJSON
|
||||
});
|
||||
contentEl.appendChild(panel);
|
||||
bindInlineHitlApproval(panel, data, { allowEdit: allowEdit });
|
||||
}
|
||||
|
||||
function resolveInlineHitlDecision(timeline, data, decision, message) {
|
||||
if (!timeline || !data || !data.interruptId) return false;
|
||||
const interruptId = String(data.interruptId);
|
||||
let item = null;
|
||||
const mappedId = hitlInterruptToolItemMap.get(interruptId);
|
||||
if (mappedId) item = document.getElementById(mappedId);
|
||||
if (!item) item = findToolCallItemForHitl(timeline, data);
|
||||
if (!item) {
|
||||
item = timeline.querySelector('[data-hitl-interrupt-id="' + hitlEscapeAttrSelector(interruptId) + '"]');
|
||||
}
|
||||
if (!item) return false;
|
||||
|
||||
if (item.classList.contains('timeline-item-tool_call')) {
|
||||
const state = toolCallDetailStateByItemId.get(item.id) || {};
|
||||
state.hitlData = Object.assign({}, state.hitlData || data, {
|
||||
resolved: true,
|
||||
decision: decision,
|
||||
decisionMessage: message || '',
|
||||
comment: data.comment || (state.hitlData && state.hitlData.comment) || '',
|
||||
editedArgs: data.editedArgs || data.editedArguments || (state.hitlData && (state.hitlData.editedArgs || state.hitlData.editedArguments)) || null
|
||||
});
|
||||
if (decision === 'approve' && state.hitlData.editedArgs && typeof state.hitlData.editedArgs === 'object') {
|
||||
state.originalArgs = state.originalArgs || state.args || {};
|
||||
state.args = state.hitlData.editedArgs;
|
||||
state.argsEditedByHitl = true;
|
||||
}
|
||||
<div class="hitl-input-help">备注(可选):建议写审批依据。</div>
|
||||
<input class="hitl-config-input hitl-inline-comment" type="text" placeholder="例如:允许只读命令">
|
||||
<div class="hitl-pending-actions">
|
||||
state.pending = decision === 'approve';
|
||||
setToolCallDetailState(item, state);
|
||||
const content = item.querySelector('.timeline-item-content.tool-call-detail-content');
|
||||
if (content) {
|
||||
content.remove();
|
||||
item.classList.remove('tool-call-detail-expanded');
|
||||
}
|
||||
renderToolCallDetailContent(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
const panel = item.querySelector('.hitl-inline-approval');
|
||||
if (panel) {
|
||||
markInlineHitlDecision(panel, decision, message || '');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findToolCallItemForHitl(timeline, data) {
|
||||
if (!timeline || !data) return null;
|
||||
const payload = data.payload && typeof data.payload === 'object' ? data.payload : {};
|
||||
const toolCallId = String(data.toolCallId || payload.toolCallId || '').trim();
|
||||
if (toolCallId) {
|
||||
const byId = timeline.querySelector('[data-tool-call-id="' + hitlEscapeAttrSelector(toolCallId) + '"]');
|
||||
if (byId && byId.classList.contains('timeline-item-tool_call')) return byId;
|
||||
}
|
||||
const toolName = String(data.toolName || payload.toolName || '').trim().toLowerCase();
|
||||
if (!toolName) return null;
|
||||
const shortWant = toolName.indexOf('::') >= 0 ? toolName.split('::').pop() : toolName;
|
||||
const calls = timeline.querySelectorAll('.timeline-item-tool_call');
|
||||
for (let i = calls.length - 1; i >= 0; i--) {
|
||||
const tn = String(calls[i].dataset.toolName || '').trim().toLowerCase();
|
||||
const shortTn = tn.indexOf('::') >= 0 ? tn.split('::').pop() : tn;
|
||||
if (tn === toolName || tn.endsWith('::' + shortWant) || shortTn === shortWant) {
|
||||
return calls[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildInlineHitlApprovalHtml(data, opts) {
|
||||
const hasToolNameOverride = opts && Object.prototype.hasOwnProperty.call(opts, 'toolName');
|
||||
const toolName = hasToolNameOverride ? String(opts.toolName || '') : (data.toolName || '-');
|
||||
const mode = opts && opts.mode ? opts.mode : String(data.mode || 'approval').trim().toLowerCase();
|
||||
const modeLabel = opts && opts.modeLabel ? opts.modeLabel : (mode === 'review_edit' ? '审查编辑' : '审批模式');
|
||||
const allowEdit = opts && opts.allowEdit === true;
|
||||
const argsJSON = opts && opts.argsJSON ? opts.argsJSON : '';
|
||||
const toolBadge = toolName
|
||||
? '<span class="hitl-tool-badge">' + escapeHtml(toolName) + '</span>'
|
||||
: '';
|
||||
if (data && data.resolved) {
|
||||
const ok = data.decision === 'approve';
|
||||
const text = data.decisionMessage || (ok ? '已通过,继续执行' : '已拒绝');
|
||||
const comment = data.comment ? '<span class="hitl-inline-decision-comment">' + escapeHtml(data.comment) + '</span>' : '';
|
||||
return `
|
||||
<div class="hitl-inline-decision hitl-inline-decision--${ok ? 'approve' : 'reject'}">
|
||||
<span class="hitl-inline-decision-dot" aria-hidden="true"></span>
|
||||
<strong>${escapeHtml(ok ? '审批通过' : '审批拒绝')}</strong>
|
||||
<span>${escapeHtml(text)}</span>
|
||||
${comment}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return `
|
||||
<div class="hitl-inline-header">
|
||||
<div class="hitl-inline-title">
|
||||
<span class="hitl-inline-icon" aria-hidden="true">!</span>
|
||||
<span>待审批</span>
|
||||
</div>
|
||||
<div class="hitl-inline-badges">
|
||||
${toolBadge}
|
||||
<span class="hitl-mode-tag hitl-mode-tag--${escapeHtml(mode || 'approval')}">${escapeHtml(modeLabel)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hitl-inline-body">
|
||||
<div class="hitl-input-help hitl-inline-summary">确认上方参数后决定是否继续执行。</div>
|
||||
${allowEdit
|
||||
? `<label class="hitl-inline-field">
|
||||
<span class="hitl-context-label">参数覆盖(JSON,可选)</span>
|
||||
<textarea class="hitl-edit-args hitl-inline-edit" placeholder='{"command":"ls -la"}'>${escapeHtml(argsJSON === '{}' ? '' : argsJSON)}</textarea>
|
||||
</label>`
|
||||
: ''
|
||||
}
|
||||
<label class="hitl-inline-field">
|
||||
<span class="hitl-context-label">备注(可选)</span>
|
||||
<input class="hitl-config-input hitl-inline-comment" type="text" placeholder="例如:允许只读命令">
|
||||
</label>
|
||||
</div>
|
||||
<div class="hitl-pending-actions hitl-inline-actions">
|
||||
<div class="hitl-input-help hitl-inline-status" aria-live="polite"></div>
|
||||
<button class="btn-secondary hitl-inline-reject">拒绝</button>
|
||||
<button class="btn-primary hitl-inline-approve">通过</button>
|
||||
</div>
|
||||
<div class="hitl-input-help hitl-inline-status"></div>
|
||||
`;
|
||||
contentEl.appendChild(panel);
|
||||
}
|
||||
|
||||
function autoResizeHitlTextarea(textarea) {
|
||||
if (!textarea) return;
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.max(textarea.scrollHeight, textarea.offsetHeight || 0) + 'px';
|
||||
}
|
||||
|
||||
function bindInlineHitlApproval(panel, data, opts) {
|
||||
const approveBtn = panel.querySelector('.hitl-inline-approve');
|
||||
const rejectBtn = panel.querySelector('.hitl-inline-reject');
|
||||
const commentInput = panel.querySelector('.hitl-inline-comment');
|
||||
const editInput = panel.querySelector('.hitl-inline-edit');
|
||||
const statusEl = panel.querySelector('.hitl-inline-status');
|
||||
const allowEdit = opts && opts.allowEdit === true;
|
||||
if (!approveBtn || !rejectBtn || !commentInput || !statusEl) return;
|
||||
|
||||
if (editInput) {
|
||||
autoResizeHitlTextarea(editInput);
|
||||
editInput.addEventListener('input', function () {
|
||||
autoResizeHitlTextarea(editInput);
|
||||
});
|
||||
}
|
||||
|
||||
const setBusy = function (busy) {
|
||||
approveBtn.disabled = busy;
|
||||
@@ -3197,7 +3389,27 @@ function renderInlineHitlApproval(itemId, data) {
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
statusEl.textContent = decision === 'approve' ? '已通过,等待执行继续...' : '已拒绝,反馈已交给模型继续迭代...';
|
||||
const msg = decision === 'approve' ? '已通过,等待执行继续...' : '已拒绝,反馈已交给模型继续迭代...';
|
||||
const toolItem = panel.closest('.timeline-item-tool_call');
|
||||
if (toolItem && toolItem.id) {
|
||||
const state = toolCallDetailStateByItemId.get(toolItem.id) || {};
|
||||
state.hitlData = Object.assign({}, state.hitlData || data, {
|
||||
resolved: true,
|
||||
decision: decision,
|
||||
decisionMessage: msg,
|
||||
comment: comment,
|
||||
editedArgs: editedArgs
|
||||
});
|
||||
if (decision === 'approve' && editedArgs && typeof editedArgs === 'object') {
|
||||
state.originalArgs = state.originalArgs || state.args || {};
|
||||
state.args = editedArgs;
|
||||
state.argsEditedByHitl = true;
|
||||
}
|
||||
state.pending = decision === 'approve';
|
||||
setToolCallDetailState(toolItem, state);
|
||||
}
|
||||
statusEl.textContent = msg;
|
||||
markInlineHitlDecision(panel, decision, msg);
|
||||
panel.classList.add('hitl-inline-done');
|
||||
} catch (e) {
|
||||
statusEl.textContent = '提交失败:' + (e && e.message ? e.message : 'unknown error');
|
||||
@@ -3209,6 +3421,19 @@ function renderInlineHitlApproval(itemId, data) {
|
||||
rejectBtn.onclick = function () { submit('reject'); };
|
||||
}
|
||||
|
||||
function markInlineHitlDecision(panel, decision, message) {
|
||||
if (!panel) return;
|
||||
const ok = decision === 'approve';
|
||||
panel.classList.add('hitl-inline-done');
|
||||
panel.innerHTML = `
|
||||
<div class="hitl-inline-decision hitl-inline-decision--${ok ? 'approve' : 'reject'}">
|
||||
<span class="hitl-inline-decision-dot" aria-hidden="true"></span>
|
||||
<strong>${escapeHtml(ok ? '审批通过' : '审批拒绝')}</strong>
|
||||
<span>${escapeHtml(message || (ok ? '已通过,继续执行' : '已拒绝'))}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderInlineWorkflowHitlApproval(itemId, data) {
|
||||
const item = document.getElementById(itemId);
|
||||
if (!item || !data) return;
|
||||
@@ -3228,15 +3453,28 @@ function renderInlineWorkflowHitlApproval(itemId, data) {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'workflow-hitl-inline-approval hitl-inline-approval';
|
||||
panel.innerHTML = `
|
||||
<div class="hitl-input-help"><strong>${escapeHtml(label)}</strong> 等待人工审批。</div>
|
||||
${prompt ? `<div class="hitl-input-help">${escapeHtml(prompt)}</div>` : ''}
|
||||
<div class="hitl-input-help">备注(可选)</div>
|
||||
<input class="hitl-config-input workflow-hitl-inline-comment" type="text" placeholder="审批意见">
|
||||
<div class="hitl-pending-actions">
|
||||
<div class="hitl-inline-header">
|
||||
<div class="hitl-inline-title">
|
||||
<span class="hitl-inline-icon" aria-hidden="true">!</span>
|
||||
<span>工作流审批</span>
|
||||
</div>
|
||||
<div class="hitl-inline-badges">
|
||||
<span class="hitl-tool-badge">${escapeHtml(label)}</span>
|
||||
<span class="hitl-mode-tag hitl-mode-tag--approval">审批模式</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hitl-inline-body">
|
||||
${prompt ? `<div class="hitl-inline-note">${escapeHtml(prompt)}</div>` : '<div class="hitl-inline-note">工作流暂停,等待你确认是否继续。</div>'}
|
||||
<label class="hitl-inline-field">
|
||||
<span class="hitl-context-label">备注(可选)</span>
|
||||
<input class="hitl-config-input workflow-hitl-inline-comment" type="text" placeholder="审批意见">
|
||||
</label>
|
||||
</div>
|
||||
<div class="hitl-pending-actions hitl-inline-actions">
|
||||
<div class="hitl-input-help workflow-hitl-inline-status" aria-live="polite"></div>
|
||||
<button class="btn-secondary workflow-hitl-inline-reject">拒绝</button>
|
||||
<button class="btn-primary workflow-hitl-inline-approve">通过</button>
|
||||
</div>
|
||||
<div class="hitl-input-help workflow-hitl-inline-status"></div>
|
||||
`;
|
||||
contentEl.appendChild(panel);
|
||||
|
||||
@@ -3520,8 +3758,8 @@ async function restoreHitlInlineForConversation(conversationId) {
|
||||
payload: payloadObj,
|
||||
conversationId: item.conversationId || conversationId
|
||||
};
|
||||
let hitlItemEl = detailsContainer.querySelector('[data-hitl-interrupt-id="' + hitlEscapeAttrSelector(String(item.id)) + '"]');
|
||||
if (!hitlItemEl && item.toolCallId) {
|
||||
let hitlItemEl = null;
|
||||
if (item.toolCallId) {
|
||||
hitlItemEl = detailsContainer.querySelector('[data-tool-call-id="' + hitlEscapeAttrSelector(String(item.toolCallId)) + '"]');
|
||||
}
|
||||
if (!hitlItemEl && item.toolName) {
|
||||
@@ -3538,6 +3776,9 @@ async function restoreHitlInlineForConversation(conversationId) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hitlItemEl) {
|
||||
hitlItemEl = detailsContainer.querySelector('[data-hitl-interrupt-id="' + hitlEscapeAttrSelector(String(item.id)) + '"]');
|
||||
}
|
||||
if (!hitlItemEl) continue;
|
||||
renderInlineHitlApproval(hitlItemEl.id, hitlData);
|
||||
}
|
||||
@@ -3683,7 +3924,16 @@ async function attachRunningTaskEventStream(conversationId) {
|
||||
if (eventData && eventData.type === 'done') {
|
||||
replaySawDone = true;
|
||||
}
|
||||
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); });
|
||||
if (typeof window.currentConversationId === 'string' && window.currentConversationId !== conversationId) {
|
||||
return;
|
||||
}
|
||||
const eventConvId = eventData && eventData.data && eventData.data.conversationId
|
||||
? String(eventData.data.conversationId)
|
||||
: '';
|
||||
if (eventConvId && eventConvId !== conversationId) {
|
||||
return;
|
||||
}
|
||||
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); }, { conversationId: conversationId });
|
||||
};
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
@@ -3842,7 +4092,7 @@ function buildToolResultSectionHtml(data, opts) {
|
||||
};
|
||||
const execResultLabel = _t('timeline.executionResult');
|
||||
const execIdLabel = _t('timeline.executionId');
|
||||
const waitingLabel = _t('timeline.running');
|
||||
const waitingLabel = opts.pendingText || _t('timeline.running');
|
||||
if (opts.pending) {
|
||||
return (
|
||||
'<div class="tool-result-section pending">' +
|
||||
@@ -3982,21 +4232,55 @@ async function renderToolCallDetailContent(item) {
|
||||
buildToolResultSectionHtml(state.resultData, { rawText: state.rawText }) +
|
||||
'</div>';
|
||||
} else if (state.pending !== false) {
|
||||
let pendingOpts = { pending: true };
|
||||
if (state.hitlData && state.hitlData.interruptId) {
|
||||
pendingOpts = {
|
||||
pending: true,
|
||||
pendingText: state.hitlData.resolved ? '已通过,等待执行结果' : '等待审批,通过后执行'
|
||||
};
|
||||
}
|
||||
resultBlock = '<div class="tool-details tool-result-slot">' +
|
||||
buildToolResultSectionHtml({}, { pending: true }) +
|
||||
buildToolResultSectionHtml({}, pendingOpts) +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
const paramsLabel = typeof window.t === 'function' ? window.t('timeline.params') : '参数:';
|
||||
const hitlEditedArgsLabel = state.argsEditedByHitl
|
||||
? '<span class="tool-args-hitl-edited">已按 HITL 改参执行</span>'
|
||||
: '';
|
||||
const argsBlock = state.hideArgs ? '' :
|
||||
'<div class="tool-arg-section">' +
|
||||
'<strong data-i18n="timeline.params">' + escapeHtml(paramsLabel) + '</strong>' +
|
||||
hitlEditedArgsLabel +
|
||||
'<pre class="tool-args">' + escapeHtml(JSON.stringify(args, null, 2)) + '</pre>' +
|
||||
'</div>';
|
||||
content.innerHTML = '<div class="tool-details">' + argsBlock + resultBlock + '</div>';
|
||||
let hitlBlock = '';
|
||||
if (state.hitlData && state.hitlData.interruptId) {
|
||||
const hitlPayload = state.hitlData.payload && typeof state.hitlData.payload === 'object' ? state.hitlData.payload : {};
|
||||
let hitlMode = String(state.hitlData.mode || '').trim().toLowerCase();
|
||||
if (hitlMode === 'feedback' || hitlMode === 'followup') hitlMode = 'approval';
|
||||
const hitlAllowEdit = hitlMode === 'review_edit';
|
||||
const hitlArgsObj = hitlPayload.argumentsObj && typeof hitlPayload.argumentsObj === 'object' ? hitlPayload.argumentsObj : args;
|
||||
hitlBlock = '<div class="hitl-inline-approval hitl-inline-approval--merged">' +
|
||||
buildInlineHitlApprovalHtml(state.hitlData, {
|
||||
toolName: '',
|
||||
mode: hitlMode,
|
||||
modeLabel: hitlMode === 'review_edit' ? '审查编辑' : '审批模式',
|
||||
allowEdit: hitlAllowEdit,
|
||||
argsJSON: JSON.stringify(hitlArgsObj || {}, null, 2)
|
||||
}) +
|
||||
'</div>';
|
||||
}
|
||||
content.innerHTML = '<div class="tool-details">' + argsBlock + resultBlock + hitlBlock + '</div>';
|
||||
item.appendChild(content);
|
||||
item.classList.add('tool-call-detail-expanded');
|
||||
updateToolDetailToggleLabel(item);
|
||||
const hitlPanel = content.querySelector('.hitl-inline-approval');
|
||||
if (hitlPanel && state.hitlData) {
|
||||
let bindMode = String(state.hitlData.mode || '').trim().toLowerCase();
|
||||
if (bindMode === 'feedback' || bindMode === 'followup') bindMode = 'approval';
|
||||
bindInlineHitlApproval(hitlPanel, state.hitlData, { allowEdit: bindMode === 'review_edit' });
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined' && !document.__cyberStrikeToolCallDetailToggleBound) {
|
||||
|
||||
@@ -596,8 +596,12 @@ function prefetchProjectsForChat() {
|
||||
ensureProjectsLoaded().catch(() => {});
|
||||
}
|
||||
|
||||
/** 新对话时默认不绑定项目;用户需主动选择后才写入共享黑板 */
|
||||
/** 新对话沿用用户最近选择的项目;没有选择时才保持未绑定。 */
|
||||
async function ensureDefaultActiveProjectForNewChat() {
|
||||
const id = getActiveProjectId();
|
||||
if (!id) return '';
|
||||
const project = await fetchProjectSummary(id).catch(() => null);
|
||||
if (project && project.id && project.status !== 'archived') return project.id;
|
||||
setActiveProjectId('');
|
||||
return '';
|
||||
}
|
||||
@@ -988,6 +992,7 @@ function updateProjectStats(stats) {
|
||||
|
||||
async function selectProject(id) {
|
||||
currentProjectId = id;
|
||||
if (id) setActiveProjectId(id);
|
||||
projectAssetsPagination.page = 1;
|
||||
const searchEl = document.getElementById('project-facts-search');
|
||||
const catEl = document.getElementById('project-facts-filter-category');
|
||||
|
||||
+546
-50
@@ -1,5 +1,9 @@
|
||||
// 设置相关功能
|
||||
let currentConfig = null;
|
||||
let selectedAIChannelId = '';
|
||||
const AI_CHANNEL_PROBE_CONCURRENCY = 2;
|
||||
const selectedAIChannelBulkIds = new Set();
|
||||
const aiChannelProbeResults = {};
|
||||
let allTools = [];
|
||||
let alwaysVisibleToolNames = new Set();
|
||||
let alwaysVisibleBuiltinToolNames = new Set();
|
||||
@@ -668,39 +672,10 @@ async function loadConfig(loadTools = true, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
// 填充OpenAI配置
|
||||
const providerEl = document.getElementById('openai-provider');
|
||||
if (providerEl) {
|
||||
providerEl.value = currentConfig.openai.provider || 'openai';
|
||||
}
|
||||
document.getElementById('openai-api-key').value = currentConfig.openai.api_key || '';
|
||||
document.getElementById('openai-base-url').value = currentConfig.openai.base_url || '';
|
||||
document.getElementById('openai-model').value = currentConfig.openai.model || '';
|
||||
const maxTokensEl = document.getElementById('openai-max-total-tokens');
|
||||
if (maxTokensEl) {
|
||||
maxTokensEl.value = currentConfig.openai.max_total_tokens || 120000;
|
||||
}
|
||||
const orm = currentConfig.openai && currentConfig.openai.reasoning ? currentConfig.openai.reasoning : {};
|
||||
const orModeEl = document.getElementById('openai-reasoning-mode');
|
||||
if (orModeEl) {
|
||||
const mv = (orm.mode || 'auto').toString().trim().toLowerCase();
|
||||
orModeEl.value = ['auto', 'on', 'off'].includes(mv) ? mv : 'auto';
|
||||
}
|
||||
const orEffEl = document.getElementById('openai-reasoning-effort');
|
||||
if (orEffEl) {
|
||||
const ev = (orm.effort || '').toString().trim().toLowerCase();
|
||||
orEffEl.value = ['', 'low', 'medium', 'high', 'max', 'xhigh'].includes(ev) ? ev : '';
|
||||
}
|
||||
const orProfEl = document.getElementById('openai-reasoning-profile');
|
||||
if (orProfEl) {
|
||||
const pv = (orm.profile || 'auto').toString().trim().toLowerCase();
|
||||
const ok = ['auto', 'deepseek_compat', 'openai_compat', 'output_config_effort'];
|
||||
orProfEl.value = ok.includes(pv) ? pv : 'auto';
|
||||
}
|
||||
const orAllowEl = document.getElementById('openai-reasoning-allow-client');
|
||||
if (orAllowEl) {
|
||||
orAllowEl.checked = orm.allow_client_reasoning !== false;
|
||||
}
|
||||
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||
selectedAIChannelId = currentConfig.ai.default_channel;
|
||||
renderAIChannelSelect();
|
||||
writeAIChannelToMainForm(selectedAIChannelId);
|
||||
|
||||
fillVisionConfigFromCurrent(currentConfig.vision || {});
|
||||
initModelListControls();
|
||||
@@ -1897,7 +1872,14 @@ async function applySettings() {
|
||||
};
|
||||
|
||||
const wecomAgentIdVal = document.getElementById('robot-wecom-agent-id')?.value.trim();
|
||||
const prevOpenai = (currentConfig && currentConfig.openai) ? currentConfig.openai : {};
|
||||
if (!currentConfig) currentConfig = {};
|
||||
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||
const activeChannelId = normalizeAIChannelId(selectedAIChannelId || currentConfig.ai.default_channel || 'default');
|
||||
currentConfig.ai.channels[activeChannelId] = readAIChannelFromMainForm(activeChannelId);
|
||||
currentConfig.ai.default_channel = activeChannelId;
|
||||
renderAIChannelSelect();
|
||||
const activeChannel = currentConfig.ai.channels[activeChannelId] || {};
|
||||
const prevOpenai = activeChannel;
|
||||
const prevRobots = (currentConfig && currentConfig.robots) ? currentConfig.robots : {};
|
||||
const prevHitl = (currentConfig && currentConfig.hitl) ? currentConfig.hitl : {};
|
||||
const hitlRetentionRaw = document.getElementById('hitl-retention-days')?.value;
|
||||
@@ -1909,21 +1891,7 @@ async function applySettings() {
|
||||
return String(s || '').split(/[\n,,]/).map(v => v.trim()).filter(Boolean);
|
||||
};
|
||||
const config = {
|
||||
openai: {
|
||||
...prevOpenai,
|
||||
provider: provider,
|
||||
api_key: apiKey,
|
||||
base_url: baseUrl,
|
||||
model: model,
|
||||
max_total_tokens: parseInt(document.getElementById('openai-max-total-tokens')?.value) || 120000,
|
||||
reasoning: {
|
||||
...(prevOpenai.reasoning || {}),
|
||||
mode: document.getElementById('openai-reasoning-mode')?.value || 'auto',
|
||||
effort: (document.getElementById('openai-reasoning-effort')?.value || '').trim(),
|
||||
profile: document.getElementById('openai-reasoning-profile')?.value || 'auto',
|
||||
allow_client_reasoning: document.getElementById('openai-reasoning-allow-client')?.checked !== false
|
||||
}
|
||||
},
|
||||
ai: currentConfig.ai,
|
||||
vision: visionPayload,
|
||||
fofa: {
|
||||
api_key: document.getElementById('fofa-api-key')?.value.trim() || '',
|
||||
@@ -2438,6 +2406,534 @@ function enhanceModelPickSelect(selectId) {
|
||||
syncModelPickDropdown(selectId);
|
||||
}
|
||||
|
||||
function normalizeAIChannelId(name) {
|
||||
const raw = String(name || '').trim().toLowerCase().replace(/_/g, '-');
|
||||
const id = raw.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
return id || 'default';
|
||||
}
|
||||
|
||||
function escapeAIChannelHtml(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function ensureAIConfigShape(cfg) {
|
||||
const ai = cfg && cfg.ai && typeof cfg.ai === 'object' ? cfg.ai : {};
|
||||
const channels = ai.channels && typeof ai.channels === 'object' ? { ...ai.channels } : {};
|
||||
let def = normalizeAIChannelId(ai.default_channel || '');
|
||||
if (!channels[def]) {
|
||||
const oa = (cfg && cfg.openai) ? cfg.openai : {};
|
||||
channels[def] = {
|
||||
name: def === 'default' ? 'Default' : def,
|
||||
provider: oa.provider || 'openai',
|
||||
api_key: oa.api_key || '',
|
||||
base_url: oa.base_url || '',
|
||||
model: oa.model || '',
|
||||
max_total_tokens: oa.max_total_tokens || 120000,
|
||||
max_completion_tokens: oa.max_completion_tokens || 0,
|
||||
reasoning: oa.reasoning || {}
|
||||
};
|
||||
}
|
||||
return { default_channel: def, channels };
|
||||
}
|
||||
|
||||
function readAIChannelFromMainForm(id) {
|
||||
const prev = currentConfig?.ai?.channels?.[id] || {};
|
||||
const maxCompletionTokens = parseInt(document.getElementById('openai-max-completion-tokens')?.value, 10) || 16384;
|
||||
return {
|
||||
...prev,
|
||||
name: (document.getElementById('ai-channel-name')?.value || '').trim() || prev.name || id,
|
||||
provider: document.getElementById('openai-provider')?.value || 'openai',
|
||||
api_key: document.getElementById('openai-api-key')?.value.trim() || '',
|
||||
base_url: document.getElementById('openai-base-url')?.value.trim() || '',
|
||||
model: document.getElementById('openai-model')?.value.trim() || '',
|
||||
max_total_tokens: parseInt(document.getElementById('openai-max-total-tokens')?.value, 10) || 120000,
|
||||
max_completion_tokens: maxCompletionTokens,
|
||||
reasoning: {
|
||||
...(prev.reasoning || {}),
|
||||
mode: document.getElementById('openai-reasoning-mode')?.value || 'auto',
|
||||
effort: (document.getElementById('openai-reasoning-effort')?.value || '').trim(),
|
||||
profile: document.getElementById('openai-reasoning-profile')?.value || 'auto',
|
||||
allow_client_reasoning: document.getElementById('openai-reasoning-allow-client')?.checked !== false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function writeAIChannelToMainForm(id) {
|
||||
const ai = ensureAIConfigShape(currentConfig || {});
|
||||
const ch = ai.channels[id] || ai.channels[ai.default_channel] || {};
|
||||
selectedAIChannelId = id || ai.default_channel;
|
||||
const nameEl = document.getElementById('ai-channel-name');
|
||||
if (nameEl) nameEl.value = ch.name || selectedAIChannelId;
|
||||
const providerEl = document.getElementById('openai-provider');
|
||||
if (providerEl) {
|
||||
const provider = (ch.provider === 'openai' || !ch.provider) ? 'openai_compatible' : ch.provider;
|
||||
providerEl.value = provider;
|
||||
}
|
||||
const keyEl = document.getElementById('openai-api-key');
|
||||
if (keyEl) keyEl.value = ch.api_key || '';
|
||||
const baseEl = document.getElementById('openai-base-url');
|
||||
if (baseEl) baseEl.value = ch.base_url || '';
|
||||
const modelEl = document.getElementById('openai-model');
|
||||
if (modelEl) modelEl.value = ch.model || '';
|
||||
const maxTokensEl = document.getElementById('openai-max-total-tokens');
|
||||
if (maxTokensEl) maxTokensEl.value = ch.max_total_tokens || 120000;
|
||||
const maxCompletionTokensEl = document.getElementById('openai-max-completion-tokens');
|
||||
if (maxCompletionTokensEl) maxCompletionTokensEl.value = ch.max_completion_tokens || 16384;
|
||||
const r = ch.reasoning || {};
|
||||
const modeEl = document.getElementById('openai-reasoning-mode');
|
||||
if (modeEl) modeEl.value = ['auto', 'on', 'off'].includes(String(r.mode || '').toLowerCase()) ? String(r.mode).toLowerCase() : 'auto';
|
||||
const effEl = document.getElementById('openai-reasoning-effort');
|
||||
if (effEl) effEl.value = ['', 'low', 'medium', 'high', 'max', 'xhigh'].includes(String(r.effort || '').toLowerCase()) ? String(r.effort || '').toLowerCase() : '';
|
||||
const profileEl = document.getElementById('openai-reasoning-profile');
|
||||
if (profileEl) profileEl.value = ['auto', 'deepseek_compat', 'openai_compat', 'output_config_effort'].includes(String(r.profile || '').toLowerCase()) ? String(r.profile || '').toLowerCase() : 'auto';
|
||||
const allowEl = document.getElementById('openai-reasoning-allow-client');
|
||||
if (allowEl) allowEl.checked = r.allow_client_reasoning !== false;
|
||||
syncModelListFetchButtons();
|
||||
}
|
||||
|
||||
function renderAIChannelSelect() {
|
||||
if (!currentConfig) return;
|
||||
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||
const select = document.getElementById('ai-channel-select');
|
||||
if (!select) return;
|
||||
select.innerHTML = '';
|
||||
const ids = Object.keys(currentConfig.ai.channels || {}).sort();
|
||||
ids.forEach((id) => {
|
||||
const ch = currentConfig.ai.channels[id] || {};
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id;
|
||||
const marker = id === currentConfig.ai.default_channel ? ' *' : '';
|
||||
opt.textContent = `${ch.name || id}${marker} · ${ch.model || '-'}`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
selectedAIChannelId = selectedAIChannelId && currentConfig.ai.channels[selectedAIChannelId]
|
||||
? selectedAIChannelId
|
||||
: currentConfig.ai.default_channel;
|
||||
select.value = selectedAIChannelId;
|
||||
renderAIChannelList(ids);
|
||||
updateAIChannelEditorChrome(selectedAIChannelId);
|
||||
const countLabel = typeof window.t === 'function'
|
||||
? window.t('settingsBasic.aiChannelCount').replace('{count}', String(ids.length))
|
||||
: `已保存 ${ids.length} 个通道`;
|
||||
showAIChannelSaveHint(countLabel, true);
|
||||
}
|
||||
|
||||
function channelHostLabel(baseUrl) {
|
||||
const raw = String(baseUrl || '').trim();
|
||||
if (!raw) return '-';
|
||||
try {
|
||||
return new URL(raw).host || raw;
|
||||
} catch (e) {
|
||||
return raw.replace(/^https?:\/\//, '').split('/')[0] || raw;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAIChannelList(ids) {
|
||||
const list = document.getElementById('ai-channel-list');
|
||||
if (!list || !currentConfig?.ai?.channels) return;
|
||||
list.innerHTML = '';
|
||||
(ids || Object.keys(currentConfig.ai.channels).sort()).forEach((id) => {
|
||||
const ch = currentConfig.ai.channels[id] || {};
|
||||
const isDefault = id === currentConfig.ai.default_channel;
|
||||
const isComplete = !validateSelectedAIChannelPayload(ch);
|
||||
const probe = aiChannelProbeResults[id] || null;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ai-channel-list-item' + (id === selectedAIChannelId ? ' active' : '') + (selectedAIChannelBulkIds.has(id) ? ' checked' : '');
|
||||
item.setAttribute('role', 'button');
|
||||
item.setAttribute('tabindex', '0');
|
||||
item.setAttribute('aria-current', id === selectedAIChannelId ? 'true' : 'false');
|
||||
item.onclick = () => selectAIChannelForEditing(id);
|
||||
item.onkeydown = (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
selectAIChannelForEditing(id);
|
||||
}
|
||||
};
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.className = 'ai-channel-bulk-check';
|
||||
checkbox.checked = selectedAIChannelBulkIds.has(id);
|
||||
checkbox.setAttribute('aria-label', `选择 ${ch.name || id}`);
|
||||
checkbox.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
if (checkbox.checked) {
|
||||
selectedAIChannelBulkIds.add(id);
|
||||
} else {
|
||||
selectedAIChannelBulkIds.delete(id);
|
||||
}
|
||||
item.classList.toggle('checked', checkbox.checked);
|
||||
};
|
||||
const defaultBadge = isDefault ? `<span class="ai-channel-badge">${escapeAIChannelHtml(settingsT('settingsBasic.aiChannelDefaultBadge', '默认'))}</span>` : '';
|
||||
let statusText = isComplete
|
||||
? settingsT('settingsBasic.aiChannelReady', '可用')
|
||||
: settingsT('settingsBasic.aiChannelDraft', '待完善');
|
||||
let statusClass = isComplete ? 'ready' : 'draft';
|
||||
if (probe) {
|
||||
statusText = probe.message || statusText;
|
||||
statusClass = probe.status || statusClass;
|
||||
}
|
||||
const body = document.createElement('div');
|
||||
body.className = 'ai-channel-card-body';
|
||||
body.innerHTML = `
|
||||
<div class="ai-channel-list-main">
|
||||
<span class="ai-channel-status-dot ${statusClass}" aria-hidden="true"></span>
|
||||
<strong title="${escapeAIChannelHtml(ch.name || id)}">${escapeAIChannelHtml(ch.name || id)}</strong>
|
||||
${defaultBadge}
|
||||
</div>
|
||||
<div class="ai-channel-list-meta" title="${escapeAIChannelHtml(ch.model || '-')} · ${escapeAIChannelHtml(channelHostLabel(ch.base_url))}">${escapeAIChannelHtml(ch.model || '-')} · ${escapeAIChannelHtml(channelHostLabel(ch.base_url))}</div>
|
||||
<div class="ai-channel-list-foot">
|
||||
<span class="ai-channel-status-label ${statusClass}" title="${escapeAIChannelHtml(statusText)}">${escapeAIChannelHtml(statusText)}</span>
|
||||
<span title="${escapeAIChannelHtml(id)}">${escapeAIChannelHtml(id)}</span>
|
||||
</div>
|
||||
`;
|
||||
item.appendChild(checkbox);
|
||||
item.appendChild(body);
|
||||
list.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function showAIChannelSaveHint(message, ok) {
|
||||
const el = document.getElementById('ai-channel-save-hint');
|
||||
if (!el) return;
|
||||
el.textContent = message;
|
||||
el.classList.toggle('is-error', ok === false);
|
||||
el.classList.toggle('is-success', ok !== false);
|
||||
}
|
||||
|
||||
function updateAIChannelEditorChrome(id) {
|
||||
const ai = ensureAIConfigShape(currentConfig || {});
|
||||
const channelId = normalizeAIChannelId(id || ai.default_channel || 'default');
|
||||
const ch = ai.channels[channelId] || {};
|
||||
const title = document.getElementById('ai-channel-editor-title');
|
||||
const meta = document.getElementById('ai-channel-editor-meta');
|
||||
if (title) title.textContent = ch.name || channelId;
|
||||
if (meta) {
|
||||
const parts = [
|
||||
channelId === ai.default_channel
|
||||
? settingsT('settingsBasic.aiChannelDefaultMeta', '默认通道')
|
||||
: settingsT('settingsBasic.aiChannelCustomMeta', '自定义通道'),
|
||||
ch.provider === 'claude' ? 'Claude' : settingsT('settingsBasic.aiChannelOpenAICompat', 'OpenAI 兼容'),
|
||||
ch.model || settingsT('settingsBasic.aiChannelModelMissing', '未填写模型'),
|
||||
channelHostLabel(ch.base_url)
|
||||
].filter(Boolean);
|
||||
meta.textContent = parts.join(' / ');
|
||||
}
|
||||
}
|
||||
|
||||
function validateSelectedAIChannelPayload(ch) {
|
||||
const missing = [];
|
||||
if (!String(ch.base_url || '').trim()) missing.push('Base URL');
|
||||
if (!String(ch.api_key || '').trim()) missing.push('API Key');
|
||||
if (!String(ch.model || '').trim()) missing.push('模型');
|
||||
if (missing.length) {
|
||||
return missing.join(', ');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function persistAIChannelsToServer(successMessage, options = {}) {
|
||||
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return false;
|
||||
if (!currentConfig) currentConfig = {};
|
||||
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||
const id = normalizeAIChannelId(selectedAIChannelId || currentConfig.ai.default_channel || 'default');
|
||||
const channelPayload = readAIChannelFromMainForm(id);
|
||||
currentConfig.ai.channels[id] = channelPayload;
|
||||
selectedAIChannelId = id;
|
||||
const missing = validateSelectedAIChannelPayload(channelPayload);
|
||||
if (missing) {
|
||||
showAIChannelSaveHint(`请填写:${missing}`, false);
|
||||
alert(`请填写:${missing}`);
|
||||
return false;
|
||||
}
|
||||
renderAIChannelSelect();
|
||||
showAIChannelSaveHint('正在保存通道...', true);
|
||||
try {
|
||||
const shouldMergeLatest = options.mergeLatest !== false;
|
||||
const latestResponse = shouldMergeLatest ? await apiFetch('/api/config') : null;
|
||||
if (latestResponse && latestResponse.ok) {
|
||||
const latest = await latestResponse.json();
|
||||
const latestAI = ensureAIConfigShape(latest || {});
|
||||
currentConfig.ai.channels = {
|
||||
...(latestAI.channels || {}),
|
||||
...(currentConfig.ai.channels || {}),
|
||||
[id]: channelPayload
|
||||
};
|
||||
if (!currentConfig.ai.default_channel) {
|
||||
currentConfig.ai.default_channel = latestAI.default_channel || id;
|
||||
}
|
||||
}
|
||||
const updateResponse = await apiFetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ai: currentConfig.ai })
|
||||
});
|
||||
if (!updateResponse.ok) {
|
||||
const error = await updateResponse.json().catch(() => ({}));
|
||||
throw new Error(error.error || '保存通道失败');
|
||||
}
|
||||
const applyResponse = await apiFetch('/api/config/apply', { method: 'POST' });
|
||||
if (!applyResponse.ok) {
|
||||
const error = await applyResponse.json().catch(() => ({}));
|
||||
throw new Error(error.error || '应用通道失败');
|
||||
}
|
||||
const response = await apiFetch('/api/config');
|
||||
if (response.ok) {
|
||||
currentConfig = await response.json();
|
||||
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||
selectedAIChannelId = currentConfig.ai.channels[id] ? id : currentConfig.ai.default_channel;
|
||||
renderAIChannelSelect();
|
||||
writeAIChannelToMainForm(selectedAIChannelId);
|
||||
if (typeof populateChatAIChannelSelect === 'function') {
|
||||
populateChatAIChannelSelect(currentConfig.ai);
|
||||
}
|
||||
}
|
||||
showAIChannelSaveHint(successMessage || '通道已保存', true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
showAIChannelSaveHint(error.message || '保存通道失败', false);
|
||||
alert(error.message || '保存通道失败');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function persistAIConfigOnlyToServer(successMessage) {
|
||||
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return false;
|
||||
if (!currentConfig) return false;
|
||||
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||
showAIChannelSaveHint('正在保存通道...', true);
|
||||
try {
|
||||
const updateResponse = await apiFetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ai: currentConfig.ai })
|
||||
});
|
||||
if (!updateResponse.ok) {
|
||||
const error = await updateResponse.json().catch(() => ({}));
|
||||
throw new Error(error.error || '保存通道失败');
|
||||
}
|
||||
const applyResponse = await apiFetch('/api/config/apply', { method: 'POST' });
|
||||
if (!applyResponse.ok) {
|
||||
const error = await applyResponse.json().catch(() => ({}));
|
||||
throw new Error(error.error || '应用通道失败');
|
||||
}
|
||||
if (typeof populateChatAIChannelSelect === 'function') {
|
||||
populateChatAIChannelSelect(currentConfig.ai);
|
||||
}
|
||||
showAIChannelSaveHint(successMessage || '通道已保存', true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
showAIChannelSaveHint(error.message || '保存通道失败', false);
|
||||
alert(error.message || '保存通道失败');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSelectedAIChannel() {
|
||||
await persistAIChannelsToServer(typeof window.t === 'function' ? window.t('settingsBasic.aiChannelSaved') : '通道已保存');
|
||||
}
|
||||
|
||||
async function setSelectedAIChannelDefault() {
|
||||
if (!currentConfig) return;
|
||||
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||
const id = normalizeAIChannelId(selectedAIChannelId || currentConfig.ai.default_channel || 'default');
|
||||
currentConfig.ai.channels[id] = readAIChannelFromMainForm(id);
|
||||
currentConfig.ai.default_channel = id;
|
||||
await persistAIChannelsToServer(typeof window.t === 'function' ? window.t('settingsBasic.aiChannelDefaultSaved') : '已设为默认通道');
|
||||
}
|
||||
|
||||
function selectAIChannelForEditing(id) {
|
||||
if (!currentConfig) return;
|
||||
if (selectedAIChannelId && currentConfig.ai?.channels?.[selectedAIChannelId]) {
|
||||
currentConfig.ai.channels[selectedAIChannelId] = readAIChannelFromMainForm(selectedAIChannelId);
|
||||
}
|
||||
const next = normalizeAIChannelId(id || currentConfig.ai?.default_channel || 'default');
|
||||
selectedAIChannelId = next;
|
||||
writeAIChannelToMainForm(next);
|
||||
renderAIChannelSelect();
|
||||
}
|
||||
|
||||
function uniqueAIChannelId(base) {
|
||||
const ai = ensureAIConfigShape(currentConfig || {});
|
||||
let id = normalizeAIChannelId(base);
|
||||
if (!ai.channels[id]) return id;
|
||||
let i = 2;
|
||||
while (ai.channels[`${id}-${i}`]) i++;
|
||||
return `${id}-${i}`;
|
||||
}
|
||||
|
||||
function createAIChannelFromForm() {
|
||||
if (!currentConfig) currentConfig = {};
|
||||
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||
if (selectedAIChannelId && currentConfig.ai.channels[selectedAIChannelId]) {
|
||||
currentConfig.ai.channels[selectedAIChannelId] = readAIChannelFromMainForm(selectedAIChannelId);
|
||||
}
|
||||
const baseName = (typeof window.t === 'function' ? window.t('settingsBasic.aiChannelUntitled') : 'New Channel');
|
||||
const id = uniqueAIChannelId(baseName);
|
||||
currentConfig.ai.channels[id] = {
|
||||
name: baseName,
|
||||
provider: 'openai_compatible',
|
||||
api_key: '',
|
||||
base_url: '',
|
||||
model: '',
|
||||
max_total_tokens: 120000,
|
||||
max_completion_tokens: 16384,
|
||||
reasoning: { mode: 'auto', effort: '', profile: 'auto', allow_client_reasoning: true }
|
||||
};
|
||||
selectedAIChannelId = id;
|
||||
renderAIChannelSelect();
|
||||
writeAIChannelToMainForm(id);
|
||||
showAIChannelSaveHint('新通道尚未保存,填写后点击「保存更改」。', true);
|
||||
}
|
||||
|
||||
function copyAIChannelFromForm() {
|
||||
if (!currentConfig) return;
|
||||
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||
const source = readAIChannelFromMainForm(selectedAIChannelId || currentConfig.ai.default_channel);
|
||||
const id = uniqueAIChannelId((source.name || selectedAIChannelId || 'channel') + '-copy');
|
||||
currentConfig.ai.channels[id] = { ...source, name: (source.name || id) + ' Copy' };
|
||||
selectedAIChannelId = id;
|
||||
renderAIChannelSelect();
|
||||
writeAIChannelToMainForm(id);
|
||||
showAIChannelSaveHint('复制的通道尚未保存,确认后点击「保存更改」。', true);
|
||||
}
|
||||
|
||||
function deleteSelectedAIChannel() {
|
||||
if (!currentConfig) return;
|
||||
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||
const ids = Object.keys(currentConfig.ai.channels || {});
|
||||
if (ids.length <= 1) {
|
||||
alert('至少保留一个 AI 通道');
|
||||
return;
|
||||
}
|
||||
const id = selectedAIChannelId || currentConfig.ai.default_channel;
|
||||
const ch = currentConfig.ai.channels[id] || {};
|
||||
const name = ch.name || id;
|
||||
const msg = typeof window.t === 'function'
|
||||
? window.t('settingsBasic.aiChannelDeleteConfirm').replace('{name}', name)
|
||||
: `确定删除 AI 通道「${name}」吗?`;
|
||||
if (!confirm(msg)) {
|
||||
return;
|
||||
}
|
||||
delete currentConfig.ai.channels[id];
|
||||
currentConfig.ai.default_channel = Object.keys(currentConfig.ai.channels).sort()[0];
|
||||
renderAIChannelSelect();
|
||||
writeAIChannelToMainForm(currentConfig.ai.default_channel);
|
||||
persistAIChannelsToServer('通道已删除', { mergeLatest: false });
|
||||
}
|
||||
|
||||
function selectedOrAllAIChannelIdsForProbe() {
|
||||
if (!currentConfig) return [];
|
||||
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||
if (selectedAIChannelId && currentConfig.ai.channels[selectedAIChannelId]) {
|
||||
currentConfig.ai.channels[selectedAIChannelId] = readAIChannelFromMainForm(selectedAIChannelId);
|
||||
}
|
||||
const checked = Array.from(selectedAIChannelBulkIds).filter((id) => currentConfig.ai.channels[id]);
|
||||
const ids = checked.length ? checked : Object.keys(currentConfig.ai.channels || {}).sort();
|
||||
return ids.filter((id) => !validateSelectedAIChannelPayload(currentConfig.ai.channels[id] || {}));
|
||||
}
|
||||
|
||||
async function probeSelectedAIChannels() {
|
||||
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return;
|
||||
const ids = selectedOrAllAIChannelIdsForProbe();
|
||||
if (!ids.length) {
|
||||
alert('没有可探活的完整通道,请先填写 Base URL、API Key 和模型');
|
||||
return;
|
||||
}
|
||||
showAIChannelSaveHint(`正在探活 ${ids.length} 个通道...`, true);
|
||||
ids.forEach((id) => {
|
||||
aiChannelProbeResults[id] = { status: 'testing', message: '测试中...' };
|
||||
});
|
||||
renderAIChannelList();
|
||||
let okCount = 0;
|
||||
let nextIndex = 0;
|
||||
async function probeNextAIChannel() {
|
||||
const id = ids[nextIndex++];
|
||||
if (!id) return;
|
||||
const ch = currentConfig.ai.channels[id] || {};
|
||||
try {
|
||||
const response = await apiFetch('/api/config/test-openai', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider: ch.provider || 'openai_compatible',
|
||||
base_url: ch.base_url || '',
|
||||
api_key: ch.api_key || '',
|
||||
model: ch.model || ''
|
||||
})
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (response.ok && result.success) {
|
||||
okCount += 1;
|
||||
const latency = result.latency_ms ? ` ${result.latency_ms}ms` : '';
|
||||
aiChannelProbeResults[id] = { status: 'ready', message: `可用${latency}` };
|
||||
} else {
|
||||
aiChannelProbeResults[id] = { status: 'failed', message: (result.error || '连接失败') };
|
||||
}
|
||||
} catch (error) {
|
||||
aiChannelProbeResults[id] = { status: 'failed', message: error.message || '测试出错' };
|
||||
}
|
||||
renderAIChannelList();
|
||||
}
|
||||
const workers = Array.from({ length: Math.min(AI_CHANNEL_PROBE_CONCURRENCY, ids.length) }, async function () {
|
||||
while (nextIndex < ids.length) {
|
||||
await probeNextAIChannel();
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
showAIChannelSaveHint(`探活完成:${okCount}/${ids.length} 可用`, okCount === ids.length);
|
||||
}
|
||||
|
||||
async function deleteCheckedAIChannels() {
|
||||
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return;
|
||||
if (!currentConfig) return;
|
||||
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||
const ids = Array.from(selectedAIChannelBulkIds).filter((id) => currentConfig.ai.channels[id]);
|
||||
if (!ids.length) {
|
||||
alert('请先勾选要删除的通道');
|
||||
return;
|
||||
}
|
||||
const deletable = ids.filter((id) => id !== currentConfig.ai.default_channel);
|
||||
if (!deletable.length) {
|
||||
alert('默认通道不能批量删除,请先切换默认通道');
|
||||
return;
|
||||
}
|
||||
if (Object.keys(currentConfig.ai.channels || {}).length - deletable.length < 1) {
|
||||
alert('至少保留一个 AI 通道');
|
||||
return;
|
||||
}
|
||||
const names = deletable.map((id) => currentConfig.ai.channels[id]?.name || id).join('、');
|
||||
if (!confirm(`确定删除 ${deletable.length} 个 AI 通道吗?\n${names}`)) {
|
||||
return;
|
||||
}
|
||||
deletable.forEach((id) => {
|
||||
delete currentConfig.ai.channels[id];
|
||||
selectedAIChannelBulkIds.delete(id);
|
||||
delete aiChannelProbeResults[id];
|
||||
});
|
||||
if (!currentConfig.ai.channels[selectedAIChannelId]) {
|
||||
selectedAIChannelId = currentConfig.ai.default_channel;
|
||||
}
|
||||
renderAIChannelSelect();
|
||||
writeAIChannelToMainForm(selectedAIChannelId);
|
||||
await persistAIConfigOnlyToServer(`已删除 ${deletable.length} 个通道`);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.selectAIChannelForEditing = selectAIChannelForEditing;
|
||||
window.saveSelectedAIChannel = saveSelectedAIChannel;
|
||||
window.setSelectedAIChannelDefault = setSelectedAIChannelDefault;
|
||||
window.createAIChannelFromForm = createAIChannelFromForm;
|
||||
window.copyAIChannelFromForm = copyAIChannelFromForm;
|
||||
window.deleteSelectedAIChannel = deleteSelectedAIChannel;
|
||||
window.probeSelectedAIChannels = probeSelectedAIChannels;
|
||||
window.deleteCheckedAIChannels = deleteCheckedAIChannels;
|
||||
}
|
||||
|
||||
function initModelListControls() {
|
||||
const providerEl = document.getElementById('openai-provider');
|
||||
if (providerEl && !providerEl.dataset.modelListBound) {
|
||||
@@ -2941,7 +3437,7 @@ async function saveToolsConfig() {
|
||||
|
||||
// 构建只包含工具配置的配置对象
|
||||
const config = {
|
||||
openai: currentConfig.openai || {},
|
||||
ai: ensureAIConfigShape(currentConfig || {}),
|
||||
agent: currentConfig.agent || {},
|
||||
multi_agent: {
|
||||
enabled: currentConfig?.multi_agent?.enabled === true,
|
||||
|
||||
+210
-3
@@ -622,7 +622,8 @@ function getWebshellConnections() {
|
||||
if (typeof apiFetch === 'undefined') {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return apiFetch('/api/webshell/connections', { method: 'GET' })
|
||||
var url = '/api/webshell/connections';
|
||||
return apiFetch(url, { method: 'GET' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (list) { return Array.isArray(list) ? list : []; })
|
||||
.catch(function (e) {
|
||||
@@ -631,11 +632,205 @@ function getWebshellConnections() {
|
||||
});
|
||||
}
|
||||
|
||||
function webshellConnectionProjectId(conn) {
|
||||
return (conn && (conn.project_id || conn.projectId) || '').trim();
|
||||
}
|
||||
|
||||
function webshellProjectOptionsHtml(selectedId) {
|
||||
var selected = String(selectedId || '').trim();
|
||||
var html = '<option value="">' + escapeHtml(wsT('assets.unboundProject') || '暂不绑定') + '</option>';
|
||||
var entries = [];
|
||||
try {
|
||||
if (typeof projectNameById !== 'undefined') entries = Object.entries(projectNameById);
|
||||
} catch (e) {}
|
||||
entries.sort(function (a, b) {
|
||||
return String(a[1] || '').localeCompare(String(b[1] || ''), undefined, { sensitivity: 'base' });
|
||||
});
|
||||
entries.forEach(function (entry) {
|
||||
var id = entry[0];
|
||||
var name = entry[1] || id;
|
||||
if (!id) return;
|
||||
html += '<option value="' + escapeHtml(id) + '"' + (id === selected ? ' selected' : '') + '>' + escapeHtml(name) + '</option>';
|
||||
});
|
||||
if (selected && !entries.some(function (entry) { return entry[0] === selected; })) {
|
||||
html += '<option value="' + escapeHtml(selected) + '" selected>' + escapeHtml(selected) + '</option>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
var webshellFormSelectMap = {};
|
||||
var webshellFormSelectDocBound = false;
|
||||
var WEBSHELL_FORM_SELECT_CARET = '<span class="webshell-form-select-caret" aria-hidden="true"></span>';
|
||||
|
||||
function closeAllWebshellFormSelects() {
|
||||
Object.keys(webshellFormSelectMap).forEach(function (id) {
|
||||
var reg = webshellFormSelectMap[id];
|
||||
if (!reg || !reg.wrapper) return;
|
||||
reg.wrapper.classList.remove('open');
|
||||
if (reg.trigger) reg.trigger.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function syncWebshellFormSelect(select) {
|
||||
if (!select || !select.id) return;
|
||||
var reg = webshellFormSelectMap[select.id];
|
||||
if (!reg) return;
|
||||
var dropdown = reg.dropdown;
|
||||
var trigger = reg.trigger;
|
||||
var valueSpan = trigger.querySelector('.webshell-form-select-value');
|
||||
dropdown.innerHTML = '';
|
||||
Array.prototype.forEach.call(select.options, function (opt) {
|
||||
var item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'webshell-form-select-option';
|
||||
item.setAttribute('role', 'option');
|
||||
item.setAttribute('data-value', opt.value);
|
||||
item.setAttribute('aria-selected', opt.value === select.value ? 'true' : 'false');
|
||||
if (opt.value === select.value) item.classList.add('is-selected');
|
||||
|
||||
var check = document.createElement('span');
|
||||
check.className = 'webshell-form-select-check';
|
||||
check.textContent = '✓';
|
||||
check.setAttribute('aria-hidden', 'true');
|
||||
|
||||
var label = document.createElement('span');
|
||||
label.className = 'webshell-form-select-label';
|
||||
label.textContent = opt.textContent;
|
||||
|
||||
item.appendChild(check);
|
||||
item.appendChild(label);
|
||||
dropdown.appendChild(item);
|
||||
});
|
||||
var selectedOpt = select.options[select.selectedIndex];
|
||||
if (valueSpan) valueSpan.textContent = selectedOpt ? selectedOpt.textContent : '';
|
||||
trigger.disabled = !!select.disabled;
|
||||
reg.wrapper.classList.toggle('is-disabled', !!select.disabled);
|
||||
}
|
||||
|
||||
function enhanceWebshellFormSelect(select) {
|
||||
if (!select || !select.id) return;
|
||||
var existing = webshellFormSelectMap[select.id];
|
||||
if (existing && existing.select !== select) delete webshellFormSelectMap[select.id];
|
||||
if (select.dataset.webshellFormCustom === '1') {
|
||||
syncWebshellFormSelect(select);
|
||||
return;
|
||||
}
|
||||
|
||||
select.dataset.webshellFormCustom = '1';
|
||||
select.classList.add('webshell-form-native-select');
|
||||
select.tabIndex = -1;
|
||||
select.setAttribute('aria-hidden', 'true');
|
||||
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.className = 'webshell-form-select-ui';
|
||||
|
||||
var trigger = document.createElement('button');
|
||||
trigger.type = 'button';
|
||||
trigger.className = 'webshell-form-select-trigger';
|
||||
trigger.setAttribute('aria-haspopup', 'listbox');
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
|
||||
var valueSpan = document.createElement('span');
|
||||
valueSpan.className = 'webshell-form-select-value';
|
||||
trigger.appendChild(valueSpan);
|
||||
trigger.insertAdjacentHTML('beforeend', WEBSHELL_FORM_SELECT_CARET);
|
||||
|
||||
var dropdown = document.createElement('div');
|
||||
dropdown.className = 'webshell-form-select-dropdown';
|
||||
dropdown.setAttribute('role', 'listbox');
|
||||
|
||||
var parent = select.parentNode;
|
||||
parent.insertBefore(wrapper, select);
|
||||
wrapper.appendChild(trigger);
|
||||
wrapper.appendChild(dropdown);
|
||||
wrapper.appendChild(select);
|
||||
|
||||
webshellFormSelectMap[select.id] = { wrapper: wrapper, trigger: trigger, dropdown: dropdown, select: select };
|
||||
|
||||
trigger.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (select.disabled) return;
|
||||
var open = wrapper.classList.contains('open');
|
||||
closeAllWebshellFormSelects();
|
||||
if (!open) {
|
||||
wrapper.classList.add('open');
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
dropdown.addEventListener('click', function (e) {
|
||||
var item = e.target.closest('.webshell-form-select-option');
|
||||
if (!item) return;
|
||||
e.stopPropagation();
|
||||
var value = item.getAttribute('data-value');
|
||||
if (value === null) return;
|
||||
if (select.value !== value) {
|
||||
select.value = value;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
wrapper.classList.remove('open');
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
syncWebshellFormSelect(select);
|
||||
});
|
||||
|
||||
select.addEventListener('change', function () {
|
||||
syncWebshellFormSelect(select);
|
||||
});
|
||||
|
||||
syncWebshellFormSelect(select);
|
||||
}
|
||||
|
||||
function refreshWebshellFormSelects(root) {
|
||||
var container = root || document.getElementById('webshell-modal');
|
||||
if (!container) return;
|
||||
Object.keys(webshellFormSelectMap).forEach(function (id) {
|
||||
if (!document.getElementById(id)) delete webshellFormSelectMap[id];
|
||||
});
|
||||
container.querySelectorAll('select').forEach(enhanceWebshellFormSelect);
|
||||
if (!webshellFormSelectDocBound) {
|
||||
webshellFormSelectDocBound = true;
|
||||
document.addEventListener('click', closeAllWebshellFormSelects);
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') closeAllWebshellFormSelects();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function populateWebshellProjectSelect(selectedId) {
|
||||
var sel = document.getElementById('webshell-project-id');
|
||||
if (!sel) return Promise.resolve();
|
||||
var selected = String(selectedId || '').trim();
|
||||
sel.innerHTML = webshellProjectOptionsHtml(selected);
|
||||
sel.value = selected;
|
||||
syncWebshellFormSelect(sel);
|
||||
var loadPromise = Promise.resolve([]);
|
||||
if (typeof ensureProjectsLoaded === 'function') {
|
||||
loadPromise = ensureProjectsLoaded();
|
||||
} else if (typeof fetchAllProjects === 'function') {
|
||||
loadPromise = fetchAllProjects(false).then(function (list) {
|
||||
if (typeof rebuildProjectNameMap === 'function') rebuildProjectNameMap(list || []);
|
||||
return list || [];
|
||||
});
|
||||
}
|
||||
return loadPromise.then(function () {
|
||||
sel.innerHTML = webshellProjectOptionsHtml(selected);
|
||||
sel.value = selected;
|
||||
syncWebshellFormSelect(sel);
|
||||
}).catch(function (e) {
|
||||
console.warn('加载 WebShell 项目选项失败', e);
|
||||
});
|
||||
}
|
||||
|
||||
// 从服务端刷新连接列表并重绘侧栏
|
||||
function refreshWebshellConnectionsFromServer() {
|
||||
return getWebshellConnections().then(function (list) {
|
||||
webshellConnections = list;
|
||||
renderWebshellList();
|
||||
if (typeof ensureProjectsLoaded === 'function') {
|
||||
ensureProjectsLoaded().then(function () {
|
||||
renderWebshellList();
|
||||
}).catch(function () {});
|
||||
}
|
||||
return list;
|
||||
});
|
||||
}
|
||||
@@ -4636,11 +4831,19 @@ function showAddWebshellModal() {
|
||||
if (osSelEl) osSelEl.value = 'auto';
|
||||
var encSelEl = document.getElementById('webshell-encoding');
|
||||
if (encSelEl) encSelEl.value = 'auto';
|
||||
var defaultProjectId = '';
|
||||
try {
|
||||
defaultProjectId = typeof getActiveProjectId === 'function' ? (getActiveProjectId() || '') : '';
|
||||
} catch (e) {}
|
||||
populateWebshellProjectSelect(defaultProjectId);
|
||||
document.getElementById('webshell-remark').value = '';
|
||||
var titleEl = document.getElementById('webshell-modal-title');
|
||||
if (titleEl) titleEl.textContent = wsT('webshell.addConnection');
|
||||
var modal = document.getElementById('webshell-modal');
|
||||
if (modal) openAppModal(modal);
|
||||
if (modal) {
|
||||
openAppModal(modal);
|
||||
refreshWebshellFormSelects(modal);
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑连接弹窗(预填当前连接信息)
|
||||
@@ -4662,7 +4865,9 @@ function showEditWebshellModal(connId) {
|
||||
if (osEditEl) osEditEl.value = normalizeWebshellOS(conn.os);
|
||||
var encEditEl = document.getElementById('webshell-encoding');
|
||||
if (encEditEl) encEditEl.value = normalizeWebshellEncoding(conn.encoding);
|
||||
populateWebshellProjectSelect(webshellConnectionProjectId(conn));
|
||||
document.getElementById('webshell-remark').value = conn.remark || '';
|
||||
refreshWebshellFormSelects(document.getElementById('webshell-modal'));
|
||||
document.getElementById('webshell-url')?.focus();
|
||||
});
|
||||
}
|
||||
@@ -4961,7 +5166,9 @@ function saveWebshellConnection() {
|
||||
|
||||
var editIdEl = document.getElementById('webshell-edit-id');
|
||||
var editId = editIdEl ? editIdEl.value.trim() : '';
|
||||
var body = { url: url, password: password, type: type, method: method === 'get' ? 'get' : 'post', cmd_param: cmdParam, encoding: encoding, os: osTag, remark: remark || url };
|
||||
var projectId = (document.getElementById('webshell-project-id') || {}).value || '';
|
||||
if (projectId && typeof projectId.trim === 'function') projectId = projectId.trim(); else projectId = '';
|
||||
var body = { url: url, password: password, type: type, method: method === 'get' ? 'get' : 'post', cmd_param: cmdParam, encoding: encoding, os: osTag, remark: remark || url, project_id: projectId };
|
||||
if (typeof apiFetch === 'undefined') return;
|
||||
|
||||
var reqUrl = editId ? ('/api/webshell/connections/' + encodeURIComponent(editId)) : '/api/webshell/connections';
|
||||
|
||||
+202
-103
@@ -990,7 +990,7 @@
|
||||
</div>
|
||||
<div id="conversations-pagination" class="sidebar-list-pagination conversation-sidebar-pagination"></div>
|
||||
<div id="chat-reasoning-wrapper" class="chat-reasoning-wrapper conversation-reasoning-card conversation-reasoning-collapsed" style="display: none;">
|
||||
<button type="button" id="conversation-reasoning-toggle" class="conversation-reasoning-card-header" onclick="toggleConversationReasoningCard()" aria-expanded="false" aria-controls="conversation-reasoning-body" data-i18n="chat.reasoningCompactAria" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="模型推理选项" title="模型推理选项">
|
||||
<button type="button" id="conversation-reasoning-toggle" class="conversation-reasoning-card-header" onclick="toggleConversationReasoningCard()" aria-expanded="false" aria-controls="conversation-reasoning-body" data-i18n="chat.sessionSettingsAria" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="会话设置" title="会话设置">
|
||||
<div class="conversation-reasoning-heading">
|
||||
<span class="conversation-reasoning-icon" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -999,7 +999,7 @@
|
||||
</svg>
|
||||
</span>
|
||||
<div class="conversation-reasoning-heading-text">
|
||||
<span class="conversation-reasoning-title" data-i18n="chat.reasoningPanelTitle">模型推理</span>
|
||||
<span class="conversation-reasoning-title" data-i18n="chat.sessionSettingsTitle">会话设置</span>
|
||||
<span id="chat-reasoning-summary" class="conversation-reasoning-summary"></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1010,89 +1010,75 @@
|
||||
</span>
|
||||
</button>
|
||||
<div id="conversation-reasoning-body" class="conversation-reasoning-body" role="region">
|
||||
<p class="chat-reasoning-panel-hint" data-i18n="chat.reasoningPanelHint">仅 Eino 请求生效,与系统设置中的默认值合并。</p>
|
||||
<p class="chat-reasoning-panel-hint" data-i18n="chat.sessionSettingsHint">AI 通道、推理设置与人机协同只影响后续消息。</p>
|
||||
<div class="chat-reasoning-fields">
|
||||
<div class="chat-reasoning-field">
|
||||
<label class="chat-reasoning-field-label" for="chat-reasoning-mode"><span data-i18n="chat.reasoningModeLabel">模式</span></label>
|
||||
<select id="chat-reasoning-mode" class="chat-reasoning-select" onchange="persistChatReasoningPrefs()">
|
||||
<option value="default" data-i18n="chat.reasoningModeDefault">跟随系统</option>
|
||||
<option value="off" data-i18n="chat.reasoningModeOff">关闭</option>
|
||||
<option value="on" data-i18n="chat.reasoningModeOn">开启</option>
|
||||
<option value="auto" data-i18n="chat.reasoningModeAuto">自动</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="chat-reasoning-field">
|
||||
<label class="chat-reasoning-field-label" for="chat-reasoning-effort"><span data-i18n="chat.reasoningEffortLabel">推理强度</span></label>
|
||||
<select id="chat-reasoning-effort" class="chat-reasoning-select" onchange="persistChatReasoningPrefs()">
|
||||
<option value="" data-i18n="chat.reasoningEffortUnset">不指定</option>
|
||||
<option value="low">low</option>
|
||||
<option value="medium">medium</option>
|
||||
<option value="high">high</option>
|
||||
<option value="xhigh">xhigh</option>
|
||||
<option value="max">max</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hitl-sidebar-card hitl-sidebar-collapsed" id="hitl-sidebar-card">
|
||||
<div class="hitl-sidebar-card-header" id="hitl-sidebar-toggle" role="button" tabindex="0" aria-expanded="false" aria-controls="hitl-sidebar-body" onclick="toggleHitlSidebarCard()" onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggleHitlSidebarCard(); }">
|
||||
<div class="hitl-sidebar-heading">
|
||||
<span class="hitl-sidebar-icon" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2L4 5v6.09c0 5.05 3.41 9.76 8 10.91 4.59-1.15 8-5.86 8-10.91V5l-8-3z" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"/>
|
||||
<path d="M9.5 12.5l2 2 3-4" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="hitl-sidebar-heading-text">
|
||||
<span class="hitl-sidebar-title" data-i18n="chat.hitlTitle">人机协同</span>
|
||||
<span class="hitl-sidebar-subtitle" data-i18n="chat.hitlCardSubtitle">审批与白名单</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="sidebar-card-chevron hitl-sidebar-chevron" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="hitl-sidebar-body" id="hitl-sidebar-body">
|
||||
<div id="hitl-apply-feedback" class="hitl-apply-feedback" role="status" aria-live="polite"></div>
|
||||
<div class="hitl-sidebar-config">
|
||||
<div class="hitl-config-field">
|
||||
<label class="hitl-config-label" for="hitl-mode-select" data-i18n="chat.hitlModeLabel">模式</label>
|
||||
<select id="hitl-mode-select" class="hitl-config-select">
|
||||
<option value="off" data-i18n="chat.hitlModeOff">关闭</option>
|
||||
<option value="approval" data-i18n="chat.hitlModeApproval">审批模式</option>
|
||||
<option value="review_edit" data-i18n="chat.hitlModeReviewEdit">审查编辑</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="hitl-config-field" id="hitl-reviewer-field">
|
||||
<label class="hitl-config-label" data-i18n="chat.hitlReviewerLabel">审批方</label>
|
||||
<div class="hitl-reviewer-toggle" role="group" aria-label="Reviewer">
|
||||
<button type="button" class="hitl-reviewer-toggle-btn is-active" data-reviewer="human" aria-pressed="true">
|
||||
<span data-i18n="chat.hitlReviewerHuman">人工审批</span>
|
||||
</button>
|
||||
<button type="button" class="hitl-reviewer-toggle-btn" data-reviewer="audit_agent" aria-pressed="false">
|
||||
<span data-i18n="chat.hitlReviewerAgent">审计 Agent</span>
|
||||
</button>
|
||||
<div class="session-settings-group session-settings-group-ai">
|
||||
<div class="session-settings-group-title">AI</div>
|
||||
<div class="chat-reasoning-field">
|
||||
<label class="chat-reasoning-field-label" for="chat-ai-channel-select"><span data-i18n="chat.aiChannelLabel">AI 通道</span></label>
|
||||
<select id="chat-ai-channel-select" class="chat-reasoning-select" onchange="persistChatAIChannelPref()">
|
||||
<option value="" data-i18n="chat.aiChannelDefault">跟随默认通道</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="session-settings-inline">
|
||||
<div class="chat-reasoning-field">
|
||||
<label class="chat-reasoning-field-label" for="chat-reasoning-mode"><span data-i18n="chat.reasoningModeLabel">模式</span></label>
|
||||
<select id="chat-reasoning-mode" class="chat-reasoning-select" onchange="persistChatReasoningPrefs()">
|
||||
<option value="default" data-i18n="chat.reasoningModeDefault">跟随系统</option>
|
||||
<option value="off" data-i18n="chat.reasoningModeOff">关闭</option>
|
||||
<option value="on" data-i18n="chat.reasoningModeOn">开启</option>
|
||||
<option value="auto" data-i18n="chat.reasoningModeAuto">自动</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="chat-reasoning-field">
|
||||
<label class="chat-reasoning-field-label" for="chat-reasoning-effort"><span data-i18n="chat.reasoningEffortLabel">推理强度</span></label>
|
||||
<select id="chat-reasoning-effort" class="chat-reasoning-select" onchange="persistChatReasoningPrefs()">
|
||||
<option value="" data-i18n="chat.reasoningEffortUnset">不指定</option>
|
||||
<option value="low">low</option>
|
||||
<option value="medium">medium</option>
|
||||
<option value="high">high</option>
|
||||
<option value="xhigh">xhigh</option>
|
||||
<option value="max">max</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" id="hitl-reviewer-select" value="human" />
|
||||
<p class="hitl-config-hint" data-i18n="chat.hitlReviewerHint">可在人工与审计 Agent 之间随时切换;规则与白名单不变。人机协同为「关闭」时也可预先选择。</p>
|
||||
</div>
|
||||
<div class="hitl-config-field hitl-config-field--tools">
|
||||
<label class="hitl-config-label" for="hitl-sensitive-tools" data-i18n="chat.hitlWhitelistTools">白名单工具(免审批,逗号分隔)</label>
|
||||
<textarea id="hitl-sensitive-tools" class="hitl-config-textarea" rows="3" spellcheck="false" autocomplete="off" data-i18n="chat.hitlWhitelistPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
|
||||
<p class="hitl-config-hint" data-i18n="chat.hitlWhitelistHint">每行一个或逗号分隔;与 config 中全局白名单合并展示。</p>
|
||||
</div>
|
||||
<div class="hitl-config-actions">
|
||||
<button type="button" class="hitl-apply-btn" data-require-permission="hitl:write" id="hitl-apply-btn" onclick="window.applyHitlSidebarConfig && window.applyHitlSidebarConfig()">
|
||||
<span data-i18n="chat.hitlApply">应用</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="chat-reasoning-field session-hitl-field session-settings-group">
|
||||
<div class="session-settings-group-title" data-i18n="chat.hitlTitle">人机协同</div>
|
||||
<div id="hitl-apply-feedback" class="hitl-apply-feedback" role="status" aria-live="polite"></div>
|
||||
<div class="hitl-sidebar-config session-hitl-config">
|
||||
<div class="hitl-config-field">
|
||||
<label class="hitl-config-label" for="hitl-mode-select" data-i18n="chat.reasoningModeLabel">模式</label>
|
||||
<select id="hitl-mode-select" class="hitl-config-select">
|
||||
<option value="off" data-i18n="chat.hitlModeOff">关闭</option>
|
||||
<option value="approval" data-i18n="chat.hitlModeApproval">审批模式</option>
|
||||
<option value="review_edit" data-i18n="chat.hitlModeReviewEdit">审查编辑</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="hitl-config-field" id="hitl-reviewer-field">
|
||||
<label class="hitl-config-label" data-i18n="chat.hitlReviewerLabel">审批方</label>
|
||||
<div class="hitl-reviewer-toggle" role="group" aria-label="Reviewer">
|
||||
<button type="button" class="hitl-reviewer-toggle-btn is-active" data-reviewer="human" aria-pressed="true">
|
||||
<span data-i18n="chat.hitlReviewerHuman">人工审批</span>
|
||||
</button>
|
||||
<button type="button" class="hitl-reviewer-toggle-btn" data-reviewer="audit_agent" aria-pressed="false">
|
||||
<span data-i18n="chat.hitlReviewerAgent">审计 Agent</span>
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="hitl-reviewer-select" value="human" />
|
||||
<p class="hitl-config-hint" data-i18n="chat.hitlReviewerHint">可在人工与审计 Agent 之间随时切换;规则与白名单不变。人机协同为「关闭」时也可预先选择。</p>
|
||||
</div>
|
||||
<div class="hitl-config-field hitl-config-field--tools">
|
||||
<label class="hitl-config-label" for="hitl-sensitive-tools" data-i18n="chat.hitlWhitelistTools">白名单工具(免审批,逗号分隔)</label>
|
||||
<textarea id="hitl-sensitive-tools" class="hitl-config-textarea" rows="3" spellcheck="false" autocomplete="off" data-i18n="chat.hitlWhitelistPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
|
||||
<p class="hitl-config-hint" data-i18n="chat.hitlWhitelistHint">每行一个或逗号分隔;与 config 中全局白名单合并展示。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 分组详情页面 -->
|
||||
<div id="group-detail-page" class="group-detail-page" style="display: none;">
|
||||
@@ -2827,8 +2813,8 @@
|
||||
<div id="page-c2-sessions" class="page">
|
||||
<div class="page-header">
|
||||
<h2 data-i18n="c2.sessions.title">会话管理</h2>
|
||||
<div class="page-header-actions">
|
||||
<button type="button" class="btn-danger" id="c2-sessions-batch-delete" data-require-permission="c2:delete" disabled onclick="C2.deleteSelectedSessions()"><span data-i18n="c2.sessions.batchDelete">批量删除</span></button>
|
||||
<div class="page-header-actions c2-sessions-page-actions">
|
||||
<button type="button" class="btn-secondary" id="c2-sessions-batch-delete" data-require-permission="c2:delete" disabled onclick="C2.deleteSelectedSessions()"><span data-i18n="c2.sessions.batchDelete">批量删除</span></button>
|
||||
<button type="button" class="btn-secondary" id="c2-sessions-delete-filtered" data-require-permission="c2:delete" disabled onclick="C2.deleteFilteredSessions()"><span data-i18n="c2.sessions.deleteFiltered">删除筛选结果</span></button>
|
||||
<button class="btn-secondary" onclick="C2.loadSessions()"><span data-i18n="common.refresh">刷新</span></button>
|
||||
</div>
|
||||
@@ -2854,14 +2840,76 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-content c2-tasks-page-wrap">
|
||||
<div class="c2-tasks-toolbar">
|
||||
<label class="c2-tasks-select-all-label">
|
||||
<input type="checkbox" id="c2-tasks-select-all" onchange="C2.onTasksSelectAll(this.checked)">
|
||||
<span data-i18n="c2.tasks.selectAll">全选本页</span>
|
||||
</label>
|
||||
<div class="c2-tasks-summary" id="c2-tasks-summary" hidden>
|
||||
<span class="c2-tasks-stat">
|
||||
<span class="c2-tasks-stat-label" data-i18n="c2.tasks.statTotal">当前筛选</span>
|
||||
<strong id="c2-tasks-stat-total">0</strong>
|
||||
</span>
|
||||
<span class="c2-tasks-stat c2-tasks-stat--success">
|
||||
<span class="c2-tasks-stat-label" data-i18n="c2.tasks.success">成功</span>
|
||||
<strong id="c2-tasks-stat-success">0</strong>
|
||||
</span>
|
||||
<span class="c2-tasks-stat c2-tasks-stat--failed">
|
||||
<span class="c2-tasks-stat-label" data-i18n="c2.tasks.failed">失败</span>
|
||||
<strong id="c2-tasks-stat-failed">0</strong>
|
||||
</span>
|
||||
<span class="c2-tasks-stat c2-tasks-stat--pending">
|
||||
<span class="c2-tasks-stat-label" data-i18n="c2.tasks.pending">待处理</span>
|
||||
<strong id="c2-tasks-stat-pending">0</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="c2-tasks-filter-card">
|
||||
<div class="c2-tasks-time-presets" id="c2-tasks-time-presets">
|
||||
<span class="c2-tasks-time-presets-label" data-i18n="c2.tasks.timePresets">快捷</span>
|
||||
<button type="button" class="c2-tasks-time-preset-btn" data-preset="15m" data-i18n="c2.tasks.preset15m">最近15分钟</button>
|
||||
<button type="button" class="c2-tasks-time-preset-btn" data-preset="1h" data-i18n="c2.tasks.preset1h">最近1小时</button>
|
||||
<button type="button" class="c2-tasks-time-preset-btn" data-preset="24h" data-i18n="c2.tasks.preset24h">最近24小时</button>
|
||||
<button type="button" class="c2-tasks-time-preset-btn" data-preset="7d" data-i18n="c2.tasks.preset7d">最近7天</button>
|
||||
<button type="button" class="c2-tasks-time-preset-btn" data-preset="all" data-i18n="c2.tasks.presetAll">全部</button>
|
||||
</div>
|
||||
<div class="c2-tasks-filter-fields">
|
||||
<label class="c2-tasks-field">
|
||||
<span data-i18n="c2.tasks.colStatus">状态</span>
|
||||
<select id="c2-tasks-filter-status" class="form-control">
|
||||
<option value="" data-i18n="c2.tasks.filterAllStatuses">全部状态</option>
|
||||
<option value="queued" data-i18n="c2.tasks.queued">队列中</option>
|
||||
<option value="sent" data-i18n="c2.tasks.sent">已发送</option>
|
||||
<option value="running" data-i18n="c2.tasks.running">执行中</option>
|
||||
<option value="success" data-i18n="c2.tasks.success">成功</option>
|
||||
<option value="failed" data-i18n="c2.tasks.failed">失败</option>
|
||||
<option value="cancelled" data-i18n="c2.tasks.cancelled">已取消</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="c2-tasks-field">
|
||||
<span data-i18n="c2.tasks.colType">类型</span>
|
||||
<select id="c2-tasks-filter-type" class="form-control">
|
||||
<option value="" data-i18n="c2.tasks.filterAllTypes">全部类型</option>
|
||||
<option value="shell">shell</option>
|
||||
<option value="exec">exec</option>
|
||||
<option value="ls">ls</option>
|
||||
<option value="pwd">pwd</option>
|
||||
<option value="cd">cd</option>
|
||||
<option value="download">download</option>
|
||||
<option value="upload">upload</option>
|
||||
<option value="ps">ps</option>
|
||||
<option value="sleep">sleep</option>
|
||||
<option value="screenshot">screenshot</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="c2-tasks-field c2-tasks-field--session">
|
||||
<span data-i18n="c2.tasks.filterSession">会话 ID</span>
|
||||
<input type="text" id="c2-tasks-filter-session" class="form-control" data-i18n="c2.tasks.filterSessionPlaceholder" data-i18n-attr="placeholder" placeholder="s_xxxxxxxx" autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
<div class="c2-tasks-filter-actions">
|
||||
<button type="button" class="btn-primary btn-small" onclick="C2.applyTasksFilters()" data-i18n="c2.tasks.applyFilters">应用筛选</button>
|
||||
<button type="button" class="btn-secondary btn-small" onclick="C2.resetTasksFilters()" data-i18n="c2.tasks.resetFilters">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="c2-tasks-panel">
|
||||
<div id="c2-task-list" class="c2-tasks-list-container"></div>
|
||||
<div id="c2-tasks-pagination" class="pagination-container c2-tasks-pagination"></div>
|
||||
</div>
|
||||
<div id="c2-task-list" class="c2-task-list-container"></div>
|
||||
<div id="c2-tasks-pagination" class="pagination-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3391,16 +3439,53 @@
|
||||
<h3 data-i18n="settingsBasic.basicTitle">基本设置</h3>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI配置 -->
|
||||
<!-- AI 通道配置 -->
|
||||
<div class="settings-subsection">
|
||||
<h4 data-i18n="settingsBasic.openaiConfig">OpenAI 配置</h4>
|
||||
<div class="settings-form">
|
||||
<div class="ai-channel-manager">
|
||||
<div class="ai-channel-manager-header">
|
||||
<div>
|
||||
<h4 data-i18n="settingsBasic.openaiConfig">AI 通道配置</h4>
|
||||
<p id="ai-channel-save-hint" class="ai-channel-manager-hint" data-i18n="settingsBasic.aiChannelHint">已保存的通道会显示在左侧;保存后写入 ai.channels,默认通道用于新对话和未指定通道的任务。</p>
|
||||
</div>
|
||||
<button type="button" class="btn-primary ai-channel-save-btn" onclick="saveSelectedAIChannel()" data-i18n="settingsBasic.aiChannelSave">保存更改</button>
|
||||
</div>
|
||||
<div class="ai-channel-manager-body">
|
||||
<aside class="ai-channel-sidebar" aria-label="AI 通道列表" data-i18n="settingsBasic.aiChannelListAria" data-i18n-attr="aria-label">
|
||||
<div class="ai-channel-sidebar-head">
|
||||
<span data-i18n="settingsBasic.aiChannelSavedList">已保存通道</span>
|
||||
<div class="ai-channel-bulk-actions">
|
||||
<button type="button" class="ai-channel-bulk-btn" onclick="probeSelectedAIChannels()" title="检测所选或全部完整通道是否可用">批量探活</button>
|
||||
<button type="button" class="ai-channel-bulk-btn danger" onclick="deleteCheckedAIChannels()" title="删除已勾选的非默认通道">删除所选</button>
|
||||
<button type="button" class="ai-channel-icon-btn" onclick="createAIChannelFromForm()" title="新增通道" aria-label="新增通道">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<select id="ai-channel-select" class="ai-channel-native-select" onchange="selectAIChannelForEditing(this.value)" aria-hidden="true" tabindex="-1"></select>
|
||||
<div id="ai-channel-list" class="ai-channel-list" aria-live="polite"></div>
|
||||
</aside>
|
||||
<div class="ai-channel-editor">
|
||||
<div class="ai-channel-editor-head">
|
||||
<div>
|
||||
<span class="ai-channel-editor-kicker" data-i18n="settingsBasic.aiChannelEditing">正在编辑</span>
|
||||
<h5 id="ai-channel-editor-title">-</h5>
|
||||
<p id="ai-channel-editor-meta">-</p>
|
||||
</div>
|
||||
<div class="ai-channel-editor-actions">
|
||||
<button type="button" class="btn-secondary" onclick="setSelectedAIChannelDefault()" data-i18n="settingsBasic.aiChannelSetDefault">设为默认</button>
|
||||
<button type="button" class="btn-secondary" onclick="copyAIChannelFromForm()" data-i18n="settingsBasic.aiChannelCopy">复制</button>
|
||||
<button type="button" class="btn-secondary danger" onclick="deleteSelectedAIChannel()" data-i18n="settingsBasic.aiChannelDelete">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-form ai-channel-editor-form">
|
||||
<div class="form-group">
|
||||
<label for="ai-channel-name" data-i18n="settingsBasic.aiChannelName">通道名称</label>
|
||||
<input type="text" id="ai-channel-name" placeholder="Qwen Max" maxlength="24" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="openai-provider" data-i18n="settingsBasic.apiProvider">API 提供商</label>
|
||||
<select id="openai-provider" style="width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border-color, #e2e8f0); border-radius: 6px; background: var(--card-bg, #fff); color: var(--text-color, #2d3748); font-size: 0.875rem;">
|
||||
<option value="openai" data-i18n="settingsBasic.providerOpenAI">OpenAI / 兼容 OpenAI 协议</option>
|
||||
<option value="claude" data-i18n="settingsBasic.providerClaude">Claude (Anthropic Messages API)</option>
|
||||
</select>
|
||||
<select id="openai-provider" style="width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border-color, #e2e8f0); border-radius: 6px; background: var(--card-bg, #fff); color: var(--text-color, #2d3748); font-size: 0.875rem;">
|
||||
<option value="openai_compatible" data-i18n="settingsBasic.providerOpenAI">OpenAI / 兼容 OpenAI 协议</option>
|
||||
<option value="claude" data-i18n="settingsBasic.providerClaude">Claude (Anthropic Messages API)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="openai-base-url">Base URL <span style="color: red;">*</span></label>
|
||||
@@ -3428,8 +3513,13 @@
|
||||
<small style="color: var(--text-muted, #718096); font-size: 0.75rem;" data-i18n="settingsBasic.maxTotalTokensHint">内存压缩和攻击链构建共用此配置,默认 120000</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settingsBasic.openaiReasoningTitle">模型推理</label>
|
||||
<small class="form-hint" data-i18n="settingsBasic.openaiReasoningHint">与对话页「模型推理」下拉配合使用。</small>
|
||||
<label for="openai-max-completion-tokens"><span data-i18n="settingsBasic.maxCompletionTokens">最大输出 Token 数</span></label>
|
||||
<input type="number" id="openai-max-completion-tokens" data-i18n="settingsBasic.maxCompletionTokensPlaceholder" data-i18n-attr="placeholder" placeholder="16384" min="1" step="256" />
|
||||
<small style="color: var(--text-muted, #718096); font-size: 0.75rem;" data-i18n="settingsBasic.maxCompletionTokensHint">单次模型回复的输出上限,默认 16384</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settingsBasic.openaiReasoningTitle">推理设置</label>
|
||||
<small class="form-hint" data-i18n="settingsBasic.openaiReasoningHint">作为该 AI 通道的默认推理设置;对话页「会话设置」可覆盖。</small>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; align-items: center;">
|
||||
<label for="openai-reasoning-mode" style="font-size: 0.8125rem;" data-i18n="chat.reasoningModeLabel">模式</label>
|
||||
<select id="openai-reasoning-mode" style="min-width: 140px; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border-color, #e2e8f0);">
|
||||
@@ -3463,6 +3553,9 @@
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 2px;">
|
||||
<a href="javascript:void(0)" id="test-openai-btn" onclick="testOpenAIConnection()" style="font-size: 0.8125rem; color: var(--accent-color, #3182ce); text-decoration: none; cursor: pointer; user-select: none;" data-i18n="settingsBasic.testConnection">测试连接</a>
|
||||
<span id="test-openai-result" style="font-size: 0.8125rem;"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3483,7 +3576,7 @@
|
||||
<div class="form-group">
|
||||
<label for="vision-provider" data-i18n="settingsBasic.provider">提供商</label>
|
||||
<select id="vision-provider" style="width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border-color, #e2e8f0); border-radius: 6px; background: var(--card-bg, #fff); color: var(--text-color, #2d3748); font-size: 0.875rem;">
|
||||
<option value="" data-i18n="settingsBasic.visionProviderReuseOpenAI">OpenAI 配置(留空复用)</option>
|
||||
<option value="" data-i18n="settingsBasic.visionProviderReuseOpenAI">默认 AI 通道(留空复用)</option>
|
||||
<option value="openai" data-i18n="settingsBasic.providerOpenAI">OpenAI / 兼容 OpenAI 协议</option>
|
||||
<option value="claude" data-i18n="settingsBasic.providerClaude">Claude (Anthropic Messages API)</option>
|
||||
</select>
|
||||
@@ -6116,6 +6209,12 @@
|
||||
</select>
|
||||
<small class="form-hint" data-i18n="webshell.encodingHint">中文 Windows 目标若出现乱码,请切换为 GBK 或 GB18030</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="webshell-project-id" data-i18n="assets.project">所属项目</label>
|
||||
<select id="webshell-project-id">
|
||||
<option value="" data-i18n="assets.unboundProject">暂不绑定</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="webshell-remark" data-i18n="webshell.remark">备注</label>
|
||||
<input type="text" id="webshell-remark" data-i18n="webshell.remarkPlaceholder" data-i18n-attr="placeholder" placeholder="便于识别的备注名" />
|
||||
@@ -6544,8 +6643,8 @@
|
||||
<script src="/static/js/agents.js"></script>
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
<script src="/static/js/chat-scroll.js"></script>
|
||||
<script src="/static/js/monitor.js?v=20260717-1"></script>
|
||||
<script src="/static/js/chat.js?v=20260717-1"></script>
|
||||
<script src="/static/js/monitor.js?v=20260723-1"></script>
|
||||
<script src="/static/js/chat.js?v=20260723-1"></script>
|
||||
<script src="/static/js/hitl.js"></script>
|
||||
<script src="/static/js/settings.js?v=20260717-1"></script>
|
||||
<script src="/static/js/audit-datetime-picker.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user