mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-19 04:20:19 +02:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24390db100 | |||
| c000fe5195 | |||
| 0b4a11d01a | |||
| d433e44a7d | |||
| 7de51fe0ea | |||
| a354cf97e5 | |||
| c180f07c7e | |||
| 15730d3ef4 | |||
| b7fa18b6d4 | |||
| 8d622f63ff | |||
| 20b05146fb | |||
| d8768eae76 | |||
| 9232cee38d | |||
| 6c975e63d2 | |||
| e175523b82 | |||
| ae23427d9e | |||
| 93a2504ce3 | |||
| 09b0479fb3 | |||
| 2bdc9d4fe0 | |||
| 01b3d8056c |
+2
-2
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.6.39"
|
version: "v1.6.40"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||||
@@ -58,7 +58,7 @@ openai:
|
|||||||
api_key: sk-xxxxxxx # API 密钥(必填)
|
api_key: sk-xxxxxxx # API 密钥(必填)
|
||||||
model: qwen3-max # 模型名称(必填)
|
model: qwen3-max # 模型名称(必填)
|
||||||
max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置)
|
max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置)
|
||||||
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort 等;provider 为 claude 时合并为 Anthropic 顶层 thinking(extended thinking),mode: off 关闭
|
# 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:
|
reasoning:
|
||||||
mode: on # auto | on | off;off 时不附加任何推理扩展字段
|
mode: on # auto | on | off;off 时不附加任何推理扩展字段
|
||||||
effort: high # low | medium | high | max | xhigh(最高档:OpenAI 常用 xhigh,部分网关用 max,原样下发);空表示不指定
|
effort: high # low | medium | high | max | xhigh(最高档:OpenAI 常用 xhigh,部分网关用 max,原样下发);空表示不指定
|
||||||
|
|||||||
@@ -829,6 +829,7 @@ func setupRoutes(
|
|||||||
protected.PUT("/batch-tasks/:queueId/schedule-enabled", agentHandler.SetBatchQueueScheduleEnabled)
|
protected.PUT("/batch-tasks/:queueId/schedule-enabled", agentHandler.SetBatchQueueScheduleEnabled)
|
||||||
protected.DELETE("/batch-tasks/:queueId", agentHandler.DeleteBatchQueue)
|
protected.DELETE("/batch-tasks/:queueId", agentHandler.DeleteBatchQueue)
|
||||||
protected.PUT("/batch-tasks/:queueId/tasks/:taskId", agentHandler.UpdateBatchTask)
|
protected.PUT("/batch-tasks/:queueId/tasks/:taskId", agentHandler.UpdateBatchTask)
|
||||||
|
protected.POST("/batch-tasks/:queueId/tasks/:taskId/run", agentHandler.RunSingleBatchTask)
|
||||||
protected.POST("/batch-tasks/:queueId/tasks", agentHandler.AddBatchTask)
|
protected.POST("/batch-tasks/:queueId/tasks", agentHandler.AddBatchTask)
|
||||||
protected.DELETE("/batch-tasks/:queueId/tasks/:taskId", agentHandler.DeleteBatchTask)
|
protected.DELETE("/batch-tasks/:queueId/tasks/:taskId", agentHandler.DeleteBatchTask)
|
||||||
|
|
||||||
@@ -876,6 +877,7 @@ func setupRoutes(
|
|||||||
protected.POST("/config/apply", configHandler.ApplyConfig)
|
protected.POST("/config/apply", configHandler.ApplyConfig)
|
||||||
protected.POST("/config/test-openai", configHandler.TestOpenAI)
|
protected.POST("/config/test-openai", configHandler.TestOpenAI)
|
||||||
protected.POST("/config/test-vision", configHandler.TestVision)
|
protected.POST("/config/test-vision", configHandler.TestVision)
|
||||||
|
protected.POST("/config/list-models", configHandler.ListModels)
|
||||||
|
|
||||||
// 系统设置 - 终端(执行命令,提高运维效率)
|
// 系统设置 - 终端(执行命令,提高运维效率)
|
||||||
protected.POST("/terminal/run", terminalHandler.RunCommand)
|
protected.POST("/terminal/run", terminalHandler.RunCommand)
|
||||||
@@ -1107,6 +1109,7 @@ func setupRoutes(
|
|||||||
c2Routes.POST("/listeners/:id/start", c2Handler.StartListener)
|
c2Routes.POST("/listeners/:id/start", c2Handler.StartListener)
|
||||||
c2Routes.POST("/listeners/:id/stop", c2Handler.StopListener)
|
c2Routes.POST("/listeners/:id/stop", c2Handler.StopListener)
|
||||||
c2Routes.GET("/sessions", c2Handler.ListSessions)
|
c2Routes.GET("/sessions", c2Handler.ListSessions)
|
||||||
|
c2Routes.DELETE("/sessions", c2Handler.DeleteSessions)
|
||||||
c2Routes.GET("/sessions/:id", c2Handler.GetSession)
|
c2Routes.GET("/sessions/:id", c2Handler.GetSession)
|
||||||
c2Routes.DELETE("/sessions/:id", c2Handler.DeleteSession)
|
c2Routes.DELETE("/sessions/:id", c2Handler.DeleteSession)
|
||||||
c2Routes.PUT("/sessions/:id/sleep", c2Handler.SetSessionSleep)
|
c2Routes.PUT("/sessions/:id/sleep", c2Handler.SetSessionSleep)
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ func registerC2ListenerTool(s *mcp.Server, m *c2.Manager, l *zap.Logger, webList
|
|||||||
- stop: 停止监听器(需 listener_id)
|
- stop: 停止监听器(需 listener_id)
|
||||||
- delete: 删除监听器(需 listener_id)
|
- delete: 删除监听器(需 listener_id)
|
||||||
监听器类型: tcp_reverse, http_beacon, https_beacon, websocket
|
监听器类型: tcp_reverse, http_beacon, https_beacon, websocket
|
||||||
|
tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + ImplantToken)才登记会话;经典 bash/nc 反弹需在 config.allow_legacy_shell=true(公网不推荐)。
|
||||||
端口约束:create/update 的 bind_port 禁止与本平台 Web/API 所用端口相同。当前本服务该端口为 %d(配置项 server.port,随进程启动从配置文件加载)。若 bind_port 与此相同会导致本服务或监听器 bind 失败、Beacon/oneliner 误连到 Web 而非 C2。请为监听器另选空闲端口。`, webListenPort),
|
端口约束:create/update 的 bind_port 禁止与本平台 Web/API 所用端口相同。当前本服务该端口为 %d(配置项 server.port,随进程启动从配置文件加载)。若 bind_port 与此相同会导致本服务或监听器 bind 失败、Beacon/oneliner 误连到 Web 而非 C2。请为监听器另选空闲端口。`, webListenPort),
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -74,7 +75,7 @@ func registerC2ListenerTool(s *mcp.Server, m *c2.Manager, l *zap.Logger, webList
|
|||||||
"bind_port": map[string]interface{}{"type": "integer", "description": fmt.Sprintf("绑定端口(create 必填)。须 ≠ %d(当前本服务 Web/API 端口,配置 server.port)", webListenPort), "minimum": 1, "maximum": 65535},
|
"bind_port": map[string]interface{}{"type": "integer", "description": fmt.Sprintf("绑定端口(create 必填)。须 ≠ %d(当前本服务 Web/API 端口,配置 server.port)", webListenPort), "minimum": 1, "maximum": 65535},
|
||||||
"profile_id": map[string]interface{}{"type": "string", "description": "Malleable Profile ID"},
|
"profile_id": map[string]interface{}{"type": "string", "description": "Malleable Profile ID"},
|
||||||
"remark": map[string]interface{}{"type": "string", "description": "备注"},
|
"remark": map[string]interface{}{"type": "string", "description": "备注"},
|
||||||
"config": map[string]interface{}{"type": "object", "description": "高级配置(beacon 路径/TLS/OPSEC 等),create/update 可用"},
|
"config": map[string]interface{}{"type": "object", "description": "高级配置(beacon 路径/TLS/OPSEC 等),create/update 可用。tcp_reverse 可选 allow_legacy_shell:true 允许未加密经典 shell(默认 false)"},
|
||||||
},
|
},
|
||||||
"required": []string{"action"},
|
"required": []string{"action"},
|
||||||
},
|
},
|
||||||
@@ -222,20 +223,23 @@ func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
|
|||||||
s.RegisterTool(mcp.Tool{
|
s.RegisterTool(mcp.Tool{
|
||||||
Name: builtin.ToolC2Session,
|
Name: builtin.ToolC2Session,
|
||||||
Description: `C2 会话管理。通过 action 参数选择操作:
|
Description: `C2 会话管理。通过 action 参数选择操作:
|
||||||
- list: 列出会话(可按 listener_id/status/os/search 过滤)
|
- list: 列出会话(可按 listener_id/status/os/search/suspicious 过滤)
|
||||||
- get: 获取会话详情及最近任务历史(需 session_id)
|
- get: 获取会话详情及最近任务历史(需 session_id)
|
||||||
- set_sleep: 设置心跳间隔(需 session_id)
|
- set_sleep: 设置心跳间隔(需 session_id)
|
||||||
- kill: 下发 exit 任务让 implant 退出(需 session_id)
|
- kill: 下发 exit 任务让 implant 退出(需 session_id)
|
||||||
- delete: 删除会话记录(需 session_id)`,
|
- delete: 删除单个会话记录(需 session_id)
|
||||||
|
- delete_batch: 批量删除会话(需 session_ids 数组)`,
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"action": map[string]interface{}{"type": "string", "description": "操作: list/get/set_sleep/kill/delete", "enum": []string{"list", "get", "set_sleep", "kill", "delete"}},
|
"action": map[string]interface{}{"type": "string", "description": "操作: list/get/set_sleep/kill/delete/delete_batch", "enum": []string{"list", "get", "set_sleep", "kill", "delete", "delete_batch"}},
|
||||||
"session_id": map[string]interface{}{"type": "string", "description": "会话 ID(get/set_sleep/kill/delete 需要)"},
|
"session_id": map[string]interface{}{"type": "string", "description": "会话 ID(get/set_sleep/kill/delete 需要)"},
|
||||||
|
"session_ids": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "会话 ID 列表(delete_batch)"},
|
||||||
"listener_id": map[string]interface{}{"type": "string", "description": "按监听器过滤(list)"},
|
"listener_id": map[string]interface{}{"type": "string", "description": "按监听器过滤(list)"},
|
||||||
"status": map[string]interface{}{"type": "string", "description": "按状态过滤: active/sleeping/dead/killed(list)"},
|
"status": map[string]interface{}{"type": "string", "description": "按状态过滤: active/sleeping/dead/killed(list)"},
|
||||||
"os": map[string]interface{}{"type": "string", "description": "按 OS 过滤: linux/windows/darwin(list)"},
|
"os": map[string]interface{}{"type": "string", "description": "按 OS 过滤: linux/windows/darwin(list)"},
|
||||||
"search": map[string]interface{}{"type": "string", "description": "模糊搜索 hostname/username/IP(list)"},
|
"search": map[string]interface{}{"type": "string", "description": "模糊搜索 hostname/username/IP(list)"},
|
||||||
|
"suspicious": map[string]interface{}{"type": "boolean", "description": "仅疑似误报:离线且 tcp_* / unknown / PID 0(list)"},
|
||||||
"limit": map[string]interface{}{"type": "integer", "description": "返回数量上限(list)"},
|
"limit": map[string]interface{}{"type": "integer", "description": "返回数量上限(list)"},
|
||||||
"sleep_seconds": map[string]interface{}{"type": "integer", "description": "心跳间隔秒数(set_sleep)"},
|
"sleep_seconds": map[string]interface{}{"type": "integer", "description": "心跳间隔秒数(set_sleep)"},
|
||||||
"jitter_percent": map[string]interface{}{"type": "integer", "description": "抖动百分比 0-100(set_sleep)"},
|
"jitter_percent": map[string]interface{}{"type": "integer", "description": "抖动百分比 0-100(set_sleep)"},
|
||||||
@@ -257,6 +261,9 @@ func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
|
|||||||
if limit := int(getFloat64(params, "limit")); limit > 0 {
|
if limit := int(getFloat64(params, "limit")); limit > 0 {
|
||||||
filter.Limit = limit
|
filter.Limit = limit
|
||||||
}
|
}
|
||||||
|
if v, ok := params["suspicious"].(bool); ok && v {
|
||||||
|
filter.Suspicious = true
|
||||||
|
}
|
||||||
sessions, err := m.DB().ListC2Sessions(filter)
|
sessions, err := m.DB().ListC2Sessions(filter)
|
||||||
return makeC2Result(map[string]interface{}{"sessions": sessions, "count": len(sessions)}, err)
|
return makeC2Result(map[string]interface{}{"sessions": sessions, "count": len(sessions)}, err)
|
||||||
|
|
||||||
@@ -274,8 +281,16 @@ func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
|
|||||||
case "set_sleep":
|
case "set_sleep":
|
||||||
sleep := int(getFloat64(params, "sleep_seconds"))
|
sleep := int(getFloat64(params, "sleep_seconds"))
|
||||||
jitter := int(getFloat64(params, "jitter_percent"))
|
jitter := int(getFloat64(params, "jitter_percent"))
|
||||||
err := m.DB().SetC2SessionSleep(id, sleep, jitter)
|
task, err := m.SetSessionSleep(id, sleep, jitter)
|
||||||
return makeC2Result(map[string]interface{}{"updated": err == nil, "sleep_seconds": sleep, "jitter_percent": jitter}, err)
|
out := map[string]interface{}{
|
||||||
|
"updated": err == nil,
|
||||||
|
"sleep_seconds": sleep,
|
||||||
|
"jitter_percent": jitter,
|
||||||
|
}
|
||||||
|
if task != nil {
|
||||||
|
out["task_id"] = task.ID
|
||||||
|
}
|
||||||
|
return makeC2Result(out, err)
|
||||||
|
|
||||||
case "kill":
|
case "kill":
|
||||||
task, err := m.EnqueueTask(c2.EnqueueTaskInput{
|
task, err := m.EnqueueTask(c2.EnqueueTaskInput{
|
||||||
@@ -292,6 +307,17 @@ func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
|
|||||||
err := m.DB().DeleteC2Session(id)
|
err := m.DB().DeleteC2Session(id)
|
||||||
return makeC2Result(map[string]interface{}{"deleted": err == nil}, err)
|
return makeC2Result(map[string]interface{}{"deleted": err == nil}, err)
|
||||||
|
|
||||||
|
case "delete_batch":
|
||||||
|
rawIDs, _ := params["session_ids"].([]interface{})
|
||||||
|
ids := make([]string, 0, len(rawIDs))
|
||||||
|
for _, v := range rawIDs {
|
||||||
|
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||||
|
ids = append(ids, strings.TrimSpace(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n, err := m.DB().DeleteC2SessionsByIDs(ids)
|
||||||
|
return makeC2Result(map[string]interface{}{"deleted": n}, err)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return makeC2Result(nil, fmt.Errorf("unknown action: %s", action))
|
return makeC2Result(nil, fmt.Errorf("unknown action: %s", action))
|
||||||
}
|
}
|
||||||
@@ -491,11 +517,11 @@ func registerC2PayloadTool(s *mcp.Server, m *c2.Manager, l *zap.Logger, webListe
|
|||||||
Name: builtin.ToolC2Payload,
|
Name: builtin.ToolC2Payload,
|
||||||
Description: fmt.Sprintf(`C2 Payload 生成。通过 action 参数选择操作:
|
Description: fmt.Sprintf(`C2 Payload 生成。通过 action 参数选择操作:
|
||||||
- oneliner: 生成单行 payload。kind 必须与监听器协议一致,否则会失败:
|
- oneliner: 生成单行 payload。kind 必须与监听器协议一致,否则会失败:
|
||||||
• tcp_reverse:裸 TCP 反弹,可用 kind: bash, nc, nc_mkfifo, python, perl, powershell(bash 指 /dev/tcp 类,不是 HTTP)。
|
• tcp_reverse:默认仅支持 build 加密 Beacon;若监听器 config.allow_legacy_shell=true,才可用 kind: bash, nc, nc_mkfifo, python, perl, powershell。
|
||||||
• http_beacon / https_beacon / websocket:仅 HTTP(S) Beacon 轮询,oneliner 只能用 kind: curl_beacon(脚本内用 bash+curl,与「tcp 的 bash」不同)。curl_beacon 返回串末尾含「 &」用于把整个 bash -c 放后台;若用 exec/execute 同步执行,必须整段原样复制(含末尾 &)。若删掉 &,内部 while 死循环占满前台,调用会一直阻塞到超时/杀进程。
|
• http_beacon / https_beacon / websocket:仅 HTTP(S) Beacon 轮询,oneliner 只能用 kind: curl_beacon(脚本内用 bash+curl,与「tcp 的 bash」不同)。curl_beacon 返回串末尾含「 &」用于把整个 bash -c 放后台;若用 exec/execute 同步执行,必须整段原样复制(含末尾 &)。若删掉 &,内部 while 死循环占满前台,调用会一直阻塞到超时/杀进程。
|
||||||
• 需要经典 bash 反弹 shell 时:先 c2_listener create type=tcp_reverse,再对该监听器用 kind=bash。
|
• 公网部署 tcp_reverse 请用 build 生成加密 Beacon,勿开启 allow_legacy_shell。
|
||||||
• 省略 kind 时,会按监听器类型自动选第一个兼容类型(HTTP 系默认为 curl_beacon)。
|
• 省略 kind 时,会按监听器类型自动选第一个兼容类型(HTTP 系默认为 curl_beacon)。
|
||||||
- build: 交叉编译 beacon 二进制。支持 http_beacon / https_beacon / websocket / tcp_reverse(tcp_reverse 下植入端回连后先发魔数 CSB1,再走与 HTTP 相同的 AES-GCM JSON 语义;未发魔数的连接仍按经典交互 shell 处理)。
|
- build: 交叉编译 beacon 二进制。支持 http_beacon / https_beacon / websocket / tcp_reverse(tcp_reverse 植入端回连后先发魔数 CSB1,再经 AES-GCM 解密且校验 ImplantToken 后才登记会话)。
|
||||||
依赖的监听器 bind_port 须避开本服务 Web 端口 %d(配置 server.port,与 c2_listener 描述一致),否则 Beacon 无法正确回连。`, webListenPort),
|
依赖的监听器 bind_port 须避开本服务 Web 端口 %d(配置 server.port,与 c2_listener 描述一致),否则 Beacon 无法正确回连。`, webListenPort),
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -540,6 +566,9 @@ func registerC2PayloadTool(s *mcp.Server, m *c2.Manager, l *zap.Logger, webListe
|
|||||||
}
|
}
|
||||||
return makeC2Result(nil, fmt.Errorf("监听器类型 %s 不支持 %s,兼容类型: %v", listener.Type, kind, names))
|
return makeC2Result(nil, fmt.Errorf("监听器类型 %s 不支持 %s,兼容类型: %v", listener.Type, kind, names))
|
||||||
}
|
}
|
||||||
|
if err := c2.ValidateOnelinerForListener(listener, kind); err != nil {
|
||||||
|
return makeC2Result(nil, err)
|
||||||
|
}
|
||||||
input := c2.OnelinerInput{
|
input := c2.OnelinerInput{
|
||||||
Kind: kind,
|
Kind: kind,
|
||||||
Host: host,
|
Host: host,
|
||||||
|
|||||||
@@ -20,10 +20,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// TCPReverseListener 监听 TCP 端口,等待目标机反弹连接。
|
// TCPReverseListener 监听 TCP 端口,等待目标机反弹连接。
|
||||||
// 经典模式:纯交互式 raw shell,与 nc / bash -i >& /dev/tcp 兼容。
|
// 默认仅接受加密 TCP Beacon:连接后先发送魔数 CSB1,再经 AES-GCM 解密且校验 ImplantToken 后才登记会话。
|
||||||
// 二进制 Beacon:连接后先发送魔数 CSB1,随后使用与 HTTP Beacon 相同的 AES-GCM JSON 语义(成帧见 tcp_beacon_server.go)。
|
// 可选经典模式(config.allow_legacy_shell=true):纯交互式 raw shell,与 nc / bash -i >& /dev/tcp 兼容,无鉴权,仅建议内网实验。
|
||||||
// 每个新连接自动生成一个 implant_uuid(基于远端地址 + 启动时间 hash),登记为 c2_session;
|
// 任务派发(经典模式):同步 exec —— 收到 task 时直接 send 命令字节并读取输出(带结束标记)。
|
||||||
// 任务派发:使用同步 exec 模式 —— 收到 task 时直接 send 命令字节并读取输出(带结束标记)。
|
|
||||||
type TCPReverseListener struct {
|
type TCPReverseListener struct {
|
||||||
rec *database.C2Listener
|
rec *database.C2Listener
|
||||||
cfg *ListenerConfig
|
cfg *ListenerConfig
|
||||||
@@ -122,12 +121,14 @@ func (l *TCPReverseListener) acceptLoop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleConn 一个连接=一个会话:先识别二进制 TCP Beacon(魔数 CSB1),否则走经典交互式 shell。
|
// handleConn 先识别加密 TCP Beacon(魔数 CSB1 + AES-GCM + Token);未通过则按配置拒绝或走经典 shell。
|
||||||
func (l *TCPReverseListener) handleConn(conn net.Conn) {
|
func (l *TCPReverseListener) handleConn(conn net.Conn) {
|
||||||
br := bufio.NewReader(conn)
|
br := bufio.NewReader(conn)
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(20 * time.Second))
|
remote := conn.RemoteAddr().String()
|
||||||
prefix, err := br.Peek(4)
|
|
||||||
if err == nil && len(prefix) == 4 && string(prefix) == tcpBeaconMagic {
|
_ = conn.SetReadDeadline(time.Now().Add(tcpBeaconPeekTimeout))
|
||||||
|
prefix, peekErr := br.Peek(4)
|
||||||
|
if peekErr == nil && len(prefix) == 4 && string(prefix) == tcpBeaconMagic {
|
||||||
if _, err := br.Discard(4); err != nil {
|
if _, err := br.Discard(4); err != nil {
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
return
|
return
|
||||||
@@ -136,14 +137,22 @@ func (l *TCPReverseListener) handleConn(conn net.Conn) {
|
|||||||
l.handleTCPBeaconSession(conn, br)
|
l.handleTCPBeaconSession(conn, br)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !l.cfg.AllowLegacyShell {
|
||||||
|
l.logger.Debug("tcp_reverse 拒绝未加密连接", zap.String("remote", remote))
|
||||||
|
_ = conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
_ = conn.SetReadDeadline(time.Time{})
|
_ = conn.SetReadDeadline(time.Time{})
|
||||||
l.handleShellConn(conn, br)
|
l.handleShellConn(conn, br)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleShellConn 经典裸 TCP 反弹 shell(与 nc/bash /dev/tcp 兼容)。
|
// handleShellConn 经典裸 TCP 反弹 shell(与 nc/bash /dev/tcp 兼容);需监听器显式开启 allow_legacy_shell。
|
||||||
func (l *TCPReverseListener) handleShellConn(conn net.Conn, br *bufio.Reader) {
|
func (l *TCPReverseListener) handleShellConn(conn net.Conn, br *bufio.Reader) {
|
||||||
remote := conn.RemoteAddr().String()
|
remote := conn.RemoteAddr().String()
|
||||||
host, _, _ := net.SplitHostPort(remote)
|
host, _, _ := net.SplitHostPort(remote)
|
||||||
|
|
||||||
// 用 listener+remote_ip 生成稳定 implant_uuid,使同一来源的重连复用同一会话
|
// 用 listener+remote_ip 生成稳定 implant_uuid,使同一来源的重连复用同一会话
|
||||||
uuidSeed := fmt.Sprintf("%s|%s", l.rec.ID, host)
|
uuidSeed := fmt.Sprintf("%s|%s", l.rec.ID, host)
|
||||||
hash := sha256.Sum256([]byte(uuidSeed))
|
hash := sha256.Sum256([]byte(uuidSeed))
|
||||||
|
|||||||
+41
-1
@@ -381,8 +381,10 @@ func (m *Manager) IngestCheckIn(listenerID string, req ImplantCheckInRequest) (*
|
|||||||
Metadata: req.Metadata,
|
Metadata: req.Metadata,
|
||||||
}
|
}
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
// 保留原 ID/FirstSeenAt/Note,避免被覆盖
|
// 保留原 ID/FirstSeenAt/Note 与操作员设置的 sleep/jitter,避免被 beacon 心跳上报覆盖
|
||||||
session.FirstSeenAt = existing.FirstSeenAt
|
session.FirstSeenAt = existing.FirstSeenAt
|
||||||
|
session.SleepSeconds = existing.SleepSeconds
|
||||||
|
session.JitterPercent = existing.JitterPercent
|
||||||
if session.Note == "" {
|
if session.Note == "" {
|
||||||
session.Note = existing.Note
|
session.Note = existing.Note
|
||||||
}
|
}
|
||||||
@@ -413,6 +415,44 @@ func (m *Manager) IngestCheckIn(listenerID string, req ImplantCheckInRequest) (*
|
|||||||
return session, nil
|
return session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSessionSleep 更新会话期望的心跳间隔,并向植入体下发 sleep 任务以尽快生效。
|
||||||
|
func (m *Manager) SetSessionSleep(sessionID string, sleepSeconds, jitterPercent int) (*database.C2Task, error) {
|
||||||
|
if strings.TrimSpace(sessionID) == "" {
|
||||||
|
return nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
if sleepSeconds < 1 {
|
||||||
|
sleepSeconds = 1
|
||||||
|
}
|
||||||
|
if jitterPercent < 0 {
|
||||||
|
jitterPercent = 0
|
||||||
|
}
|
||||||
|
if jitterPercent > 100 {
|
||||||
|
jitterPercent = 100
|
||||||
|
}
|
||||||
|
if err := m.db.SetC2SessionSleep(sessionID, sleepSeconds, jitterPercent); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
task, err := m.EnqueueTask(EnqueueTaskInput{
|
||||||
|
SessionID: sessionID,
|
||||||
|
TaskType: TaskTypeSleep,
|
||||||
|
Payload: map[string]interface{}{
|
||||||
|
"seconds": sleepSeconds,
|
||||||
|
"jitter": jitterPercent,
|
||||||
|
},
|
||||||
|
Source: "manual",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Warn("sleep 任务入队失败", zap.Error(err), zap.String("session_id", sessionID))
|
||||||
|
}
|
||||||
|
m.publishEvent("info", "session", sessionID, "",
|
||||||
|
fmt.Sprintf("Sleep 已更新: %ds (抖动 %d%%)", sleepSeconds, jitterPercent),
|
||||||
|
map[string]interface{}{
|
||||||
|
"sleep_seconds": sleepSeconds,
|
||||||
|
"jitter_percent": jitterPercent,
|
||||||
|
})
|
||||||
|
return task, nil
|
||||||
|
}
|
||||||
|
|
||||||
// MarkSessionDead 心跳超时检测器调用:标记会话为 dead
|
// MarkSessionDead 心跳超时检测器调用:标记会话为 dead
|
||||||
func (m *Manager) MarkSessionDead(sessionID string) error {
|
func (m *Manager) MarkSessionDead(sessionID string) error {
|
||||||
if err := m.db.SetC2SessionStatus(sessionID, string(SessionDead)); err != nil {
|
if err := m.db.SetC2SessionStatus(sessionID, string(SessionDead)); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package c2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIngestCheckIn_PreservesOperatorSleepOnHeartbeat(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
db, err := database.NewDB(filepath.Join(tmp, "c2.sqlite"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
mgr := NewManager(db, zap.NewNop(), tmp)
|
||||||
|
ln, err := mgr.CreateListener(CreateListenerInput{
|
||||||
|
Name: "t",
|
||||||
|
Type: string(ListenerTypeHTTPBeacon),
|
||||||
|
BindHost: "127.0.0.1",
|
||||||
|
BindPort: 18080,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
first, err := mgr.IngestCheckIn(ln.ID, ImplantCheckInRequest{
|
||||||
|
ImplantUUID: "implant-uuid-1",
|
||||||
|
Hostname: "host1",
|
||||||
|
Username: "user",
|
||||||
|
OS: "darwin",
|
||||||
|
Arch: "amd64",
|
||||||
|
SleepSeconds: 5,
|
||||||
|
JitterPercent: 0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.SetC2SessionSleep(first.ID, 30, 20); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := mgr.IngestCheckIn(ln.ID, ImplantCheckInRequest{
|
||||||
|
ImplantUUID: "implant-uuid-1",
|
||||||
|
Hostname: "host1",
|
||||||
|
Username: "user",
|
||||||
|
OS: "darwin",
|
||||||
|
Arch: "amd64",
|
||||||
|
SleepSeconds: 5,
|
||||||
|
JitterPercent: 0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if second.SleepSeconds != 30 || second.JitterPercent != 20 {
|
||||||
|
t.Fatalf("expected sleep=30 jitter=20, got sleep=%d jitter=%d", second.SleepSeconds, second.JitterPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := db.GetC2Session(first.ID)
|
||||||
|
if err != nil || stored == nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stored.SleepSeconds != 30 || stored.JitterPercent != 20 {
|
||||||
|
t.Fatalf("db: expected sleep=30 jitter=20, got sleep=%d jitter=%d", stored.SleepSeconds, stored.JitterPercent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetSessionSleep_UpdatesDBAndEnqueuesTask(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
db, err := database.NewDB(filepath.Join(tmp, "c2.sqlite"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
mgr := NewManager(db, zap.NewNop(), tmp)
|
||||||
|
ln, err := mgr.CreateListener(CreateListenerInput{
|
||||||
|
Name: "t2",
|
||||||
|
Type: string(ListenerTypeHTTPBeacon),
|
||||||
|
BindHost: "127.0.0.1",
|
||||||
|
BindPort: 18081,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sess, err := mgr.IngestCheckIn(ln.ID, ImplantCheckInRequest{
|
||||||
|
ImplantUUID: "implant-uuid-2",
|
||||||
|
Hostname: "host2",
|
||||||
|
Username: "user",
|
||||||
|
OS: "linux",
|
||||||
|
Arch: "amd64",
|
||||||
|
SleepSeconds: 5,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := mgr.SetSessionSleep(sess.ID, 15, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if task == nil || task.TaskType != string(TaskTypeSleep) {
|
||||||
|
t.Fatalf("expected sleep task, got %#v", task)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := db.GetC2Session(sess.ID)
|
||||||
|
if err != nil || stored == nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stored.SleepSeconds != 15 || stored.JitterPercent != 10 {
|
||||||
|
t.Fatalf("expected sleep=15 jitter=10, got sleep=%d jitter=%d", stored.SleepSeconds, stored.JitterPercent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
package c2
|
package c2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OnelinerKind 单行 payload 的语言/形式
|
// OnelinerKind 单行 payload 的语言/形式
|
||||||
@@ -79,6 +82,23 @@ type OnelinerInput struct {
|
|||||||
ImplantToken string // HTTP Beacon 鉴权 token
|
ImplantToken string // HTTP Beacon 鉴权 token
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateOnelinerForListener 校验 oneliner 与监听器配置是否匹配(如 tcp_reverse 默认要求加密 Beacon)。
|
||||||
|
func ValidateOnelinerForListener(listener *database.C2Listener, kind OnelinerKind) error {
|
||||||
|
if listener == nil {
|
||||||
|
return fmt.Errorf("listener is nil")
|
||||||
|
}
|
||||||
|
if ListenerType(listener.Type) == ListenerTypeTCPReverse && tcpOnelinerKinds[kind] {
|
||||||
|
cfg := &ListenerConfig{}
|
||||||
|
if strings.TrimSpace(listener.ConfigJSON) != "" {
|
||||||
|
_ = json.Unmarshal([]byte(listener.ConfigJSON), cfg)
|
||||||
|
}
|
||||||
|
if !cfg.AllowLegacyShell {
|
||||||
|
return fmt.Errorf("监听器未开启 allow_legacy_shell:tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + Token);请用 build 生成 beacon,或显式开启 allow_legacy_shell(公网不推荐)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GenerateOneliner 生成单行 payload。
|
// GenerateOneliner 生成单行 payload。
|
||||||
// 设计要点:
|
// 设计要点:
|
||||||
// - 不依赖目标机预装的可执行(除该 oneliner 关键的 bash/python/perl 等);
|
// - 不依赖目标机预装的可执行(除该 oneliner 关键的 bash/python/perl 等);
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import (
|
|||||||
// tcpBeaconMagic 二进制 Beacon 在反向 TCP 连接建立后首先发送的 4 字节,用于与经典 shell 反弹区分。
|
// tcpBeaconMagic 二进制 Beacon 在反向 TCP 连接建立后首先发送的 4 字节,用于与经典 shell 反弹区分。
|
||||||
const tcpBeaconMagic = "CSB1"
|
const tcpBeaconMagic = "CSB1"
|
||||||
|
|
||||||
|
// tcpBeaconPeekTimeout 等待 CSB1 魔数的探测窗口;合法 Beacon 连接后立即发送魔数。
|
||||||
|
const tcpBeaconPeekTimeout = 2 * time.Second
|
||||||
|
|
||||||
// tcpBeaconMaxFrame 单帧密文(base64 字符串)最大字节数,防止 OOM。
|
// tcpBeaconMaxFrame 单帧密文(base64 字符串)最大字节数,防止 OOM。
|
||||||
const tcpBeaconMaxFrame = 64 << 20
|
const tcpBeaconMaxFrame = 64 << 20
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ type ListenerConfig struct {
|
|||||||
MaxConcurrentTasks int `json:"max_concurrent_tasks,omitempty"`
|
MaxConcurrentTasks int `json:"max_concurrent_tasks,omitempty"`
|
||||||
// CallbackHost 植入端/Payload 使用的回连主机名(可选);与 bind_host 分离,便于 NAT/ECS 等场景
|
// CallbackHost 植入端/Payload 使用的回连主机名(可选);与 bind_host 分离,便于 NAT/ECS 等场景
|
||||||
CallbackHost string `json:"callback_host,omitempty"`
|
CallbackHost string `json:"callback_host,omitempty"`
|
||||||
|
// AllowLegacyShell 为 true 时 tcp_reverse 允许未加密的经典 bash/nc 反弹 shell 登记会话(默认 false,公网部署强烈不建议开启)
|
||||||
|
AllowLegacyShell bool `json:"allow_legacy_shell,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyDefaults 对未填字段填默认值;调用方负责持久化时序列化新值
|
// ApplyDefaults 对未填字段填默认值;调用方负责持久化时序列化新值
|
||||||
|
|||||||
@@ -507,6 +507,42 @@ func (db *DB) CancelPendingBatchTasks(queueID string, completedAt time.Time) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrepareBatchSingleTaskRun 准备单条执行:可选重置子任务,并更新队列索引与状态
|
||||||
|
func (db *DB) PrepareBatchSingleTaskRun(queueID, taskID string, taskIndex int, resetTask, resumeQueue bool) error {
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("开始事务失败: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if resetTask {
|
||||||
|
_, err = tx.Exec(
|
||||||
|
"UPDATE batch_tasks SET status = ?, conversation_id = NULL, started_at = NULL, completed_at = NULL, error = NULL, result = NULL WHERE queue_id = ? AND id = ?",
|
||||||
|
"pending", queueID, taskID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("重置批量任务状态失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if resumeQueue {
|
||||||
|
_, err = tx.Exec(
|
||||||
|
"UPDATE batch_task_queues SET status = ?, current_index = ?, completed_at = NULL, last_run_error = NULL WHERE id = ?",
|
||||||
|
"paused", taskIndex, queueID,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
_, err = tx.Exec(
|
||||||
|
"UPDATE batch_task_queues SET current_index = ?, last_run_error = NULL WHERE id = ?",
|
||||||
|
taskIndex, queueID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("更新批量任务队列状态失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteBatchTask 删除批量任务
|
// DeleteBatchTask 删除批量任务
|
||||||
func (db *DB) DeleteBatchTask(queueID, taskID string) error {
|
func (db *DB) DeleteBatchTask(queueID, taskID string) error {
|
||||||
_, err := db.Exec(
|
_, err := db.Exec(
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ var ErrNoValidC2EventIDs = errors.New("no valid event ids")
|
|||||||
// ErrNoValidC2TaskIDs 批量删除任务时未提供任何合法 ID
|
// ErrNoValidC2TaskIDs 批量删除任务时未提供任何合法 ID
|
||||||
var ErrNoValidC2TaskIDs = errors.New("no valid task ids")
|
var ErrNoValidC2TaskIDs = errors.New("no valid task ids")
|
||||||
|
|
||||||
|
// ErrNoValidC2SessionIDs 批量删除会话时未提供任何合法 ID
|
||||||
|
var ErrNoValidC2SessionIDs = errors.New("no valid session ids")
|
||||||
|
|
||||||
// validC2TextIDForDelete 校验 C2 文本主键(e_/t_/s_/… 等)用于批量删除入参
|
// validC2TextIDForDelete 校验 C2 文本主键(e_/t_/s_/… 等)用于批量删除入参
|
||||||
func validC2TextIDForDelete(id string) bool {
|
func validC2TextIDForDelete(id string) bool {
|
||||||
if len(id) < 2 || len(id) > 80 {
|
if len(id) < 2 || len(id) > 80 {
|
||||||
@@ -473,6 +476,7 @@ type ListC2SessionsFilter struct {
|
|||||||
Status string // active|sleeping|dead|killed;空表示全部
|
Status string // active|sleeping|dead|killed;空表示全部
|
||||||
OS string
|
OS string
|
||||||
Search string // 模糊匹配 hostname/username/internal_ip
|
Search string // 模糊匹配 hostname/username/internal_ip
|
||||||
|
Suspicious bool // 疑似误报:离线且 hostname 为 tcp_* / 用户名为 unknown / PID 为 0
|
||||||
Limit int // 0 表示无限制
|
Limit int // 0 表示无限制
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,6 +501,11 @@ func (db *DB) ListC2Sessions(filter ListC2SessionsFilter) ([]*C2Session, error)
|
|||||||
kw := "%" + filter.Search + "%"
|
kw := "%" + filter.Search + "%"
|
||||||
args = append(args, kw, kw, kw)
|
args = append(args, kw, kw, kw)
|
||||||
}
|
}
|
||||||
|
if filter.Suspicious {
|
||||||
|
conditions = append(conditions, `status = 'dead' AND (
|
||||||
|
hostname LIKE 'tcp_%' OR LOWER(COALESCE(username,'')) = 'unknown' OR COALESCE(pid, 0) = 0
|
||||||
|
)`)
|
||||||
|
}
|
||||||
query := `
|
query := `
|
||||||
SELECT id, listener_id, implant_uuid, COALESCE(hostname,''), COALESCE(username,''),
|
SELECT id, listener_id, implant_uuid, COALESCE(hostname,''), COALESCE(username,''),
|
||||||
COALESCE(os,''), COALESCE(arch,''), COALESCE(pid, 0), COALESCE(process_name,''),
|
COALESCE(os,''), COALESCE(arch,''), COALESCE(pid, 0), COALESCE(process_name,''),
|
||||||
@@ -554,6 +563,44 @@ func (db *DB) DeleteC2Session(id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteC2SessionsByIDs 按主键批量删除会话
|
||||||
|
func (db *DB) DeleteC2SessionsByIDs(ids []string) (int64, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
const maxBatch = 500
|
||||||
|
if len(ids) > maxBatch {
|
||||||
|
ids = ids[:maxBatch]
|
||||||
|
}
|
||||||
|
clean := make([]string, 0, len(ids))
|
||||||
|
seen := make(map[string]struct{}, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if !validC2TextIDForDelete(id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
clean = append(clean, id)
|
||||||
|
}
|
||||||
|
if len(clean) == 0 {
|
||||||
|
return 0, ErrNoValidC2SessionIDs
|
||||||
|
}
|
||||||
|
placeholders := strings.Repeat("?,", len(clean)-1) + "?"
|
||||||
|
args := make([]interface{}, len(clean))
|
||||||
|
for i := range clean {
|
||||||
|
args[i] = clean[i]
|
||||||
|
}
|
||||||
|
query := `DELETE FROM c2_sessions WHERE id IN (` + placeholders + `)`
|
||||||
|
res, err := db.Exec(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// CRUD:C2 任务
|
// CRUD:C2 任务
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -382,26 +382,40 @@ func (db *DB) CountConversations(search string) (int, error) {
|
|||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func conversationOrderClause(sortBy, tableAlias string) string {
|
||||||
|
col := "updated_at"
|
||||||
|
if strings.TrimSpace(strings.ToLower(sortBy)) == "created_at" {
|
||||||
|
col = "created_at"
|
||||||
|
}
|
||||||
|
prefix := tableAlias
|
||||||
|
if prefix != "" {
|
||||||
|
prefix += "."
|
||||||
|
}
|
||||||
|
return "ORDER BY " + prefix + col + " DESC"
|
||||||
|
}
|
||||||
|
|
||||||
// ListConversations 列出所有对话
|
// ListConversations 列出所有对话
|
||||||
func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversation, error) {
|
func (db *DB) ListConversations(limit, offset int, search, sortBy string) ([]*Conversation, error) {
|
||||||
var rows *sql.Rows
|
var rows *sql.Rows
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
if search != "" {
|
if search != "" {
|
||||||
// 使用 EXISTS 子查询代替 LEFT JOIN + DISTINCT,避免大表笛卡尔积
|
// 使用 EXISTS 子查询代替 LEFT JOIN + DISTINCT,避免大表笛卡尔积
|
||||||
searchPattern := "%" + search + "%"
|
searchPattern := "%" + search + "%"
|
||||||
|
orderClause := conversationOrderClause(sortBy, "c")
|
||||||
rows, err = db.Query(
|
rows, err = db.Query(
|
||||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id
|
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id
|
||||||
FROM conversations c
|
FROM conversations c
|
||||||
WHERE c.title LIKE ?
|
WHERE c.title LIKE ?
|
||||||
OR EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.content LIKE ?)
|
OR EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.content LIKE ?)
|
||||||
ORDER BY c.updated_at DESC
|
`+orderClause+`
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
searchPattern, searchPattern, limit, offset,
|
searchPattern, searchPattern, limit, offset,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
orderClause := conversationOrderClause(sortBy, "")
|
||||||
rows, err = db.Query(
|
rows, err = db.Query(
|
||||||
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id FROM conversations ORDER BY updated_at DESC LIMIT ? OFFSET ?",
|
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id FROM conversations "+orderClause+" LIMIT ? OFFSET ?",
|
||||||
limit, offset,
|
limit, offset,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -467,11 +481,12 @@ func (db *DB) CountUngroupedConversations() (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListUngroupedConversations 列出不在任何分组中的对话(最近对话侧栏)。
|
// ListUngroupedConversations 列出不在任何分组中的对话(最近对话侧栏)。
|
||||||
func (db *DB) ListUngroupedConversations(limit, offset int) ([]*Conversation, error) {
|
func (db *DB) ListUngroupedConversations(limit, offset int, sortBy string) ([]*Conversation, error) {
|
||||||
|
orderClause := conversationOrderClause(sortBy, "c")
|
||||||
rows, err := db.Query(
|
rows, err := db.Query(
|
||||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id `+
|
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id `+
|
||||||
ungroupedConversationsSQL+`
|
ungroupedConversationsSQL+`
|
||||||
ORDER BY c.updated_at DESC
|
`+orderClause+`
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
limit, offset,
|
limit, offset,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1678,6 +1678,7 @@ func (h *AgentHandler) ListBatchQueues(c *gin.Context) {
|
|||||||
// StartBatchQueue 开始执行批量任务队列
|
// StartBatchQueue 开始执行批量任务队列
|
||||||
func (h *AgentHandler) StartBatchQueue(c *gin.Context) {
|
func (h *AgentHandler) StartBatchQueue(c *gin.Context) {
|
||||||
queueID := c.Param("queueId")
|
queueID := c.Param("queueId")
|
||||||
|
h.batchTaskManager.ClearSingleRunTask(queueID)
|
||||||
ok, err := h.startBatchQueueExecution(queueID, false)
|
ok, err := h.startBatchQueueExecution(queueID, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -1709,6 +1710,7 @@ func (h *AgentHandler) RerunBatchQueue(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "重置队列失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "重置队列失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.batchTaskManager.ClearSingleRunTask(queueID)
|
||||||
ok, err := h.startBatchQueueExecution(queueID, false)
|
ok, err := h.startBatchQueueExecution(queueID, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -1908,6 +1910,53 @@ func (h *AgentHandler) AddBatchTask(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "任务已添加", "task": task, "queue": queue})
|
c.JSON(http.StatusOK, gin.H{"message": "任务已添加", "task": task, "queue": queue})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunSingleBatchTask 单条执行指定子任务(可覆盖已成功项),完成后暂停队列
|
||||||
|
func (h *AgentHandler) RunSingleBatchTask(c *gin.Context) {
|
||||||
|
queueID := c.Param("queueId")
|
||||||
|
taskID := c.Param("taskId")
|
||||||
|
|
||||||
|
if err := h.batchTaskManager.PrepareSingleTaskRun(queueID, taskID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.batchTaskManager.SetSingleRunTask(queueID, taskID)
|
||||||
|
|
||||||
|
// 暂停态单条执行:旧批量协程可能仍占用执行槽,先回收以便重新启动
|
||||||
|
if queue, ok := h.batchTaskManager.GetBatchQueue(queueID); ok && queue.Status == BatchQueueStatusPaused {
|
||||||
|
h.forceUnmarkBatchQueueRunning(queueID)
|
||||||
|
}
|
||||||
|
|
||||||
|
autoStarted := true
|
||||||
|
autoStartMsg := "已开始单条执行"
|
||||||
|
ok, startErr := h.startBatchQueueExecution(queueID, false)
|
||||||
|
if startErr != nil {
|
||||||
|
h.batchTaskManager.ClearSingleRunTask(queueID)
|
||||||
|
autoStarted = false
|
||||||
|
autoStartMsg = "任务已准备就绪,但自动启动失败: " + startErr.Error()
|
||||||
|
} else if !ok {
|
||||||
|
h.batchTaskManager.ClearSingleRunTask(queueID)
|
||||||
|
autoStarted = false
|
||||||
|
autoStartMsg = "任务已准备就绪,但队列不存在"
|
||||||
|
}
|
||||||
|
|
||||||
|
queue, exists := h.batchTaskManager.GetBatchQueue(queueID)
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "task", "run_single_batch_task", "单条执行批量子任务", "batch_task", taskID, map[string]interface{}{
|
||||||
|
"batch_queue_id": queueID,
|
||||||
|
"auto_started": autoStarted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": autoStartMsg,
|
||||||
|
"queue": queue,
|
||||||
|
"autoStarted": autoStarted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteBatchTask 删除批量任务
|
// DeleteBatchTask 删除批量任务
|
||||||
func (h *AgentHandler) DeleteBatchTask(c *gin.Context) {
|
func (h *AgentHandler) DeleteBatchTask(c *gin.Context) {
|
||||||
queueID := c.Param("queueId")
|
queueID := c.Param("queueId")
|
||||||
@@ -1949,6 +1998,10 @@ func (h *AgentHandler) unmarkBatchQueueRunning(queueID string) {
|
|||||||
delete(h.batchRunning, queueID)
|
delete(h.batchRunning, queueID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) forceUnmarkBatchQueueRunning(queueID string) {
|
||||||
|
h.unmarkBatchQueueRunning(queueID)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) nextBatchQueueRunAt(cronExpr string, from time.Time) (*time.Time, error) {
|
func (h *AgentHandler) nextBatchQueueRunAt(cronExpr string, from time.Time) (*time.Time, error) {
|
||||||
expr := strings.TrimSpace(cronExpr)
|
expr := strings.TrimSpace(cronExpr)
|
||||||
if expr == "" {
|
if expr == "" {
|
||||||
@@ -2096,6 +2149,10 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
|||||||
h.logger.Error("创建对话失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
|
h.logger.Error("创建对话失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
|
||||||
h.batchTaskManager.UpdateTaskStatus(queueID, task.ID, "failed", "", "创建对话失败: "+err.Error())
|
h.batchTaskManager.UpdateTaskStatus(queueID, task.ID, "failed", "", "创建对话失败: "+err.Error())
|
||||||
h.batchTaskManager.MoveToNextTask(queueID)
|
h.batchTaskManager.MoveToNextTask(queueID)
|
||||||
|
if h.batchTaskManager.TakeSingleRunTaskIfMatch(queueID, task.ID) {
|
||||||
|
h.batchTaskManager.UpdateQueueStatus(queueID, "paused")
|
||||||
|
break
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
conversationID = conv.ID
|
conversationID = conv.ID
|
||||||
@@ -2352,6 +2409,12 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
|||||||
// 移动到下一个任务
|
// 移动到下一个任务
|
||||||
h.batchTaskManager.MoveToNextTask(queueID)
|
h.batchTaskManager.MoveToNextTask(queueID)
|
||||||
|
|
||||||
|
if h.batchTaskManager.TakeSingleRunTaskIfMatch(queueID, task.ID) {
|
||||||
|
h.batchTaskManager.UpdateQueueStatus(queueID, "paused")
|
||||||
|
h.logger.Info("单条执行完成,队列已暂停", zap.String("queueId", queueID), zap.String("taskId", task.ID))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否被取消或暂停
|
// 检查是否被取消或暂停
|
||||||
queue, _ = h.batchTaskManager.GetBatchQueue(queueID)
|
queue, _ = h.batchTaskManager.GetBatchQueue(queueID)
|
||||||
if queue.Status == "cancelled" || queue.Status == "paused" {
|
if queue.Status == "cancelled" || queue.Status == "paused" {
|
||||||
|
|||||||
@@ -77,11 +77,12 @@ type BatchTaskQueue struct {
|
|||||||
|
|
||||||
// BatchTaskManager 批量任务管理器
|
// BatchTaskManager 批量任务管理器
|
||||||
type BatchTaskManager struct {
|
type BatchTaskManager struct {
|
||||||
db *database.DB
|
db *database.DB
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
queues map[string]*BatchTaskQueue
|
queues map[string]*BatchTaskQueue
|
||||||
taskCancels map[string]context.CancelFunc // 存储每个队列当前任务的取消函数
|
taskCancels map[string]context.CancelFunc // 存储每个队列当前任务的取消函数
|
||||||
mu sync.RWMutex
|
singleRunTasks map[string]string // queueID -> taskID,单条执行完成后暂停队列
|
||||||
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBatchTaskManager 创建批量任务管理器
|
// NewBatchTaskManager 创建批量任务管理器
|
||||||
@@ -90,9 +91,10 @@ func NewBatchTaskManager(logger *zap.Logger) *BatchTaskManager {
|
|||||||
logger = zap.NewNop()
|
logger = zap.NewNop()
|
||||||
}
|
}
|
||||||
return &BatchTaskManager{
|
return &BatchTaskManager{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
queues: make(map[string]*BatchTaskQueue),
|
queues: make(map[string]*BatchTaskQueue),
|
||||||
taskCancels: make(map[string]context.CancelFunc),
|
taskCancels: make(map[string]context.CancelFunc),
|
||||||
|
singleRunTasks: make(map[string]string),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -864,6 +866,138 @@ func (m *BatchTaskManager) AddTaskToQueue(queueID, message string) (*BatchTask,
|
|||||||
return task, nil
|
return task, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrepareSingleTaskRun 准备单条执行:重置目标任务(若已有结果)并定位队列索引
|
||||||
|
func (m *BatchTaskManager) PrepareSingleTaskRun(queueID, taskID string) error {
|
||||||
|
var cancelFunc context.CancelFunc
|
||||||
|
var siblingRunningIDs []string
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
queue, exists := m.queues[queueID]
|
||||||
|
if !exists {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return fmt.Errorf("队列不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
var task *BatchTask
|
||||||
|
taskIndex := -1
|
||||||
|
for i, t := range queue.Tasks {
|
||||||
|
if t.ID == taskID {
|
||||||
|
taskIndex = i
|
||||||
|
task = t
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if task == nil {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return fmt.Errorf("任务不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !queueAllowsSingleTaskRunLocked(queue, task) {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return fmt.Errorf("队列正在执行或未就绪,无法单条执行")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暂停态:中止在途子任务并收口仍标记 running 的其它子任务,以便单条执行非冲突项
|
||||||
|
if queue.Status == BatchQueueStatusPaused {
|
||||||
|
if c, ok := m.taskCancels[queueID]; ok {
|
||||||
|
cancelFunc = c
|
||||||
|
delete(m.taskCancels, queueID)
|
||||||
|
}
|
||||||
|
for _, t := range queue.Tasks {
|
||||||
|
if t != nil && t.ID != taskID && t.Status == BatchTaskStatusRunning {
|
||||||
|
siblingRunningIDs = append(siblingRunningIDs, t.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
needsReset := task.Status != BatchTaskStatusPending
|
||||||
|
resumeQueue := queue.Status == BatchQueueStatusCompleted || queue.Status == BatchQueueStatusCancelled
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if cancelFunc != nil {
|
||||||
|
cancelFunc()
|
||||||
|
}
|
||||||
|
const staleRunMsg = "为单条执行其它任务,已中止"
|
||||||
|
for _, sid := range siblingRunningIDs {
|
||||||
|
m.UpdateTaskStatus(queueID, sid, BatchTaskStatusCancelled, "", staleRunMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
queue, exists = m.queues[queueID]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("队列不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
task = nil
|
||||||
|
taskIndex = -1
|
||||||
|
for i, t := range queue.Tasks {
|
||||||
|
if t.ID == taskID {
|
||||||
|
taskIndex = i
|
||||||
|
task = t
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if task == nil {
|
||||||
|
return fmt.Errorf("任务不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.db != nil {
|
||||||
|
if err := m.db.PrepareBatchSingleTaskRun(queueID, taskID, taskIndex, needsReset, resumeQueue); err != nil {
|
||||||
|
return fmt.Errorf("准备单条执行失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsReset {
|
||||||
|
task.Status = BatchTaskStatusPending
|
||||||
|
task.ConversationID = ""
|
||||||
|
task.StartedAt = nil
|
||||||
|
task.CompletedAt = nil
|
||||||
|
task.Error = ""
|
||||||
|
task.Result = ""
|
||||||
|
}
|
||||||
|
queue.CurrentIndex = taskIndex
|
||||||
|
queue.LastRunError = ""
|
||||||
|
if resumeQueue {
|
||||||
|
queue.Status = BatchQueueStatusPaused
|
||||||
|
queue.CompletedAt = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSingleRunTask 标记队列仅执行指定子任务,完成后自动暂停
|
||||||
|
func (m *BatchTaskManager) SetSingleRunTask(queueID, taskID string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.singleRunTasks == nil {
|
||||||
|
m.singleRunTasks = make(map[string]string)
|
||||||
|
}
|
||||||
|
m.singleRunTasks[queueID] = taskID
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearSingleRunTask 清除单条执行标记
|
||||||
|
func (m *BatchTaskManager) ClearSingleRunTask(queueID string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
delete(m.singleRunTasks, queueID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TakeSingleRunTaskIfMatch 若刚完成的子任务为单条执行目标,则清除标记并返回 true
|
||||||
|
func (m *BatchTaskManager) TakeSingleRunTaskIfMatch(queueID, taskID string) bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.singleRunTasks == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if m.singleRunTasks[queueID] != taskID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete(m.singleRunTasks, queueID)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteTask 删除任务(队列空闲时可删;执行中任务不可删)
|
// DeleteTask 删除任务(队列空闲时可删;执行中任务不可删)
|
||||||
func (m *BatchTaskManager) DeleteTask(queueID, taskID string) error {
|
func (m *BatchTaskManager) DeleteTask(queueID, taskID string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -936,6 +1070,25 @@ func queueAllowsTaskListMutationLocked(queue *BatchTaskQueue) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// queueAllowsSingleTaskRunLocked 是否允许对指定子任务发起单条执行(必须在持有 BatchTaskManager.mu 下调用)
|
||||||
|
func queueAllowsSingleTaskRunLocked(queue *BatchTaskQueue, task *BatchTask) bool {
|
||||||
|
if queue == nil || task == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if task.Status == BatchTaskStatusRunning {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if queue.Status == BatchQueueStatusRunning {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch queue.Status {
|
||||||
|
case BatchQueueStatusPending, BatchQueueStatusPaused, BatchQueueStatusCompleted, BatchQueueStatusCancelled:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetNextTask 获取下一个待执行的任务
|
// GetNextTask 获取下一个待执行的任务
|
||||||
func (m *BatchTaskManager) GetNextTask(queueID string) (*BatchTask, bool) {
|
func (m *BatchTaskManager) GetNextTask(queueID string) (*BatchTask, bool) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
|||||||
+58
-3
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -277,6 +278,9 @@ func (h *C2Handler) ListSessions(c *gin.Context) {
|
|||||||
filter.Limit = n
|
filter.Limit = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if c.Query("suspicious") == "1" || strings.EqualFold(c.Query("suspicious"), "true") {
|
||||||
|
filter.Suspicious = true
|
||||||
|
}
|
||||||
|
|
||||||
sessions, err := h.mgr().DB().ListC2Sessions(filter)
|
sessions, err := h.mgr().DB().ListC2Sessions(filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -324,7 +328,37 @@ func (h *C2Handler) DeleteSession(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSessionSleep 设置会话的 sleep/jitter
|
// DeleteSessions 批量删除会话(请求体 JSON: {"ids":["s_xxx",...]})
|
||||||
|
func (h *C2Handler) DeleteSessions(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.IDs) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ids is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := h.mgr().DB().DeleteC2SessionsByIDs(req.IDs)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, database.ErrNoValidC2SessionIDs) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "c2", "session_delete", "批量删除 C2 会话", "c2_session", "", map[string]interface{}{
|
||||||
|
"count": n, "ids": req.IDs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"deleted": n})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSessionSleep 设置会话的 sleep/jitter,并下发 sleep 任务到植入体
|
||||||
func (h *C2Handler) SetSessionSleep(c *gin.Context) {
|
func (h *C2Handler) SetSessionSleep(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
var req struct {
|
var req struct {
|
||||||
@@ -335,12 +369,33 @@ func (h *C2Handler) SetSessionSleep(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.SleepSeconds < 1 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "sleep_seconds must be >= 1"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.JitterPercent < 0 || req.JitterPercent > 100 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "jitter_percent must be 0-100"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.mgr().DB().SetC2SessionSleep(id, req.SleepSeconds, req.JitterPercent); err != nil {
|
task, err := h.mgr().SetSessionSleep(id, req.SleepSeconds, req.JitterPercent)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"updated": true})
|
out := gin.H{
|
||||||
|
"updated": true,
|
||||||
|
"sleep_seconds": req.SleepSeconds,
|
||||||
|
"jitter_percent": req.JitterPercent,
|
||||||
|
}
|
||||||
|
if task != nil {
|
||||||
|
out["task_id"] = task.ID
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -1068,6 +1068,80 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListModelsRequest 获取模型列表请求(OpenAI 兼容 GET /models)。
|
||||||
|
type ListModelsRequest struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModels 代理调用上游 GET /models,返回可用模型 id 列表。
|
||||||
|
func (h *ConfigHandler) ListModels(c *gin.Context) {
|
||||||
|
var req ListModelsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := strings.TrimSpace(req.Provider)
|
||||||
|
if provider == "" {
|
||||||
|
provider = "openai"
|
||||||
|
}
|
||||||
|
if strings.EqualFold(provider, "claude") {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"supported": false,
|
||||||
|
"error": "Claude (Anthropic Messages API) 不支持自动获取模型列表,请手动填写",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(req.APIKey) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "API Key 不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := strings.TrimSuffix(strings.TrimSpace(req.BaseURL), "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://api.openai.com/v1"
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpCfg := &config.OpenAIConfig{
|
||||||
|
Provider: provider,
|
||||||
|
BaseURL: baseURL,
|
||||||
|
APIKey: strings.TrimSpace(req.APIKey),
|
||||||
|
}
|
||||||
|
client := openai.NewClient(tmpCfg, nil, h.logger)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
models, err := client.ListModels(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if apiErr, ok := err.(*openai.APIError); ok {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"supported": true,
|
||||||
|
"error": fmt.Sprintf("API 返回错误 (HTTP %d): %s", apiErr.StatusCode, apiErr.Body),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"supported": true,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"supported": true,
|
||||||
|
"models": models,
|
||||||
|
"count": len(models),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// TestVisionRequest 测试 Vision 模型连接;vision.api_key/base_url 留空时可传 openai 段作回退。
|
// TestVisionRequest 测试 Vision 模型连接;vision.api_key/base_url 留空时可传 openai 段作回退。
|
||||||
type TestVisionRequest struct {
|
type TestVisionRequest struct {
|
||||||
Vision config.VisionConfig `json:"vision"`
|
Vision config.VisionConfig `json:"vision"`
|
||||||
|
|||||||
@@ -105,17 +105,18 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) {
|
|||||||
|
|
||||||
excludeGrouped := strings.TrimSpace(search) == "" &&
|
excludeGrouped := strings.TrimSpace(search) == "" &&
|
||||||
(c.Query("exclude_grouped") == "true" || c.Query("exclude_grouped") == "1")
|
(c.Query("exclude_grouped") == "true" || c.Query("exclude_grouped") == "1")
|
||||||
|
sortBy := strings.TrimSpace(c.Query("sort_by"))
|
||||||
|
|
||||||
var conversations []*database.Conversation
|
var conversations []*database.Conversation
|
||||||
var total int
|
var total int
|
||||||
var err error
|
var err error
|
||||||
if excludeGrouped {
|
if excludeGrouped {
|
||||||
conversations, err = h.db.ListUngroupedConversations(limit, offset)
|
conversations, err = h.db.ListUngroupedConversations(limit, offset, sortBy)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
total, err = h.db.CountUngroupedConversations()
|
total, err = h.db.CountUngroupedConversations()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
conversations, err = h.db.ListConversations(limit, offset, search)
|
conversations, err = h.db.ListConversations(limit, offset, search, sortBy)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
total, err = h.db.CountConversations(search)
|
total, err = h.db.CountConversations(search)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5030,6 +5030,51 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/config/list-models": map[string]interface{}{
|
||||||
|
"post": map[string]interface{}{
|
||||||
|
"tags": []string{"配置管理"},
|
||||||
|
"summary": "获取模型列表",
|
||||||
|
"description": "代理调用 OpenAI 兼容 GET /models,返回可用模型 id 列表。Claude 不支持。",
|
||||||
|
"operationId": "listModels",
|
||||||
|
"requestBody": map[string]interface{}{
|
||||||
|
"required": true,
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"required": []string{"api_key"},
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"provider": map[string]interface{}{"type": "string", "description": "LLM提供商(openai/claude)", "example": "openai"},
|
||||||
|
"base_url": map[string]interface{}{"type": "string", "description": "API基地址(可选)"},
|
||||||
|
"api_key": map[string]interface{}{"type": "string", "description": "API密钥"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"responses": map[string]interface{}{
|
||||||
|
"200": map[string]interface{}{
|
||||||
|
"description": "获取结果",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"success": map[string]interface{}{"type": "boolean"},
|
||||||
|
"supported": map[string]interface{}{"type": "boolean"},
|
||||||
|
"error": map[string]interface{}{"type": "string"},
|
||||||
|
"models": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
|
||||||
|
"count": map[string]interface{}{"type": "integer"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"400": map[string]interface{}{"description": "参数错误"},
|
||||||
|
"401": map[string]interface{}{"description": "未授权"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// ==================== 终端 ====================
|
// ==================== 终端 ====================
|
||||||
"/api/terminal/run": map[string]interface{}{
|
"/api/terminal/run": map[string]interface{}{
|
||||||
|
|||||||
@@ -447,7 +447,7 @@ func (h *RobotHandler) cmdUnbindProject(platform, userID string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *RobotHandler) cmdList() string {
|
func (h *RobotHandler) cmdList() string {
|
||||||
convs, err := h.db.ListConversations(50, 0, "")
|
convs, err := h.db.ListConversations(50, 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "获取对话列表失败: " + err.Error()
|
return "获取对话列表失败: " + err.Error()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ package openai
|
|||||||
// Auth: Bearer → x-api-key
|
// Auth: Bearer → x-api-key
|
||||||
// Tools: OpenAI tools[] → Claude tools[] (input_schema)
|
// Tools: OpenAI tools[] → Claude tools[] (input_schema)
|
||||||
//
|
//
|
||||||
// Extended thinking: 顶层 `thinking` 从 OpenAI 请求体透传;响应中 `thinking` block 映射为
|
// Extended thinking: 顶层 `thinking` / `output_config` 从 OpenAI 请求体透传;响应中 `thinking` block 映射为
|
||||||
// `reasoning_content`(可读前缀 + 内部 JSON 尾缀以保留 signature,供多轮工具续跑;UI 用 openai.DisplayReasoningContent 剥离)。
|
// `reasoning_content`(可读前缀 + 内部 JSON 尾缀以保留 signature,供多轮工具续跑;UI 用 openai.DisplayReasoningContent 剥离)。
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -40,8 +40,9 @@ type claudeRequest struct {
|
|||||||
System string `json:"system,omitempty"`
|
System string `json:"system,omitempty"`
|
||||||
Messages []claudeMessage `json:"messages"`
|
Messages []claudeMessage `json:"messages"`
|
||||||
Tools []claudeTool `json:"tools,omitempty"`
|
Tools []claudeTool `json:"tools,omitempty"`
|
||||||
Stream bool `json:"stream,omitempty"`
|
Stream bool `json:"stream,omitempty"`
|
||||||
Thinking json.RawMessage `json:"thinking,omitempty"`
|
Thinking json.RawMessage `json:"thinking,omitempty"`
|
||||||
|
OutputConfig json.RawMessage `json:"output_config,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type claudeMessage struct {
|
type claudeMessage struct {
|
||||||
@@ -304,12 +305,17 @@ func convertOpenAIToClaude(payload interface{}) (*claudeRequest, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extended thinking (Anthropic top-level); merged from Eino ExtraFields / admin extras.
|
// Extended thinking + effort (Anthropic top-level); merged from Eino ExtraFields / admin extras.
|
||||||
if th, ok := oai["thinking"]; ok && th != nil {
|
if th, ok := oai["thinking"]; ok && th != nil {
|
||||||
if raw, err := json.Marshal(th); err == nil && len(raw) > 0 && string(raw) != "null" {
|
if raw, err := json.Marshal(th); err == nil && len(raw) > 0 && string(raw) != "null" {
|
||||||
req.Thinking = json.RawMessage(raw)
|
req.Thinking = json.RawMessage(raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if oc, ok := oai["output_config"]; ok && oc != nil {
|
||||||
|
if raw, err := json.Marshal(oc); err == nil && len(raw) > 0 && string(raw) != "null" {
|
||||||
|
req.OutputConfig = json.RawMessage(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return req, nil
|
return req, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,39 @@ func TestConvertOpenAIToClaude_AssistantReasoningReplay(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConvertOpenAIToClaude_OutputConfigEffort(t *testing.T) {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"model": "claude-opus-4-8",
|
||||||
|
"messages": []interface{}{
|
||||||
|
map[string]interface{}{"role": "user", "content": "hi"},
|
||||||
|
},
|
||||||
|
"thinking": map[string]interface{}{
|
||||||
|
"type": "adaptive",
|
||||||
|
"display": "summarized",
|
||||||
|
},
|
||||||
|
"output_config": map[string]interface{}{
|
||||||
|
"effort": "high",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req, err := convertOpenAIToClaude(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(req.Thinking) == 0 {
|
||||||
|
t.Fatal("expected thinking")
|
||||||
|
}
|
||||||
|
if len(req.OutputConfig) == 0 {
|
||||||
|
t.Fatal("expected output_config")
|
||||||
|
}
|
||||||
|
var oc map[string]interface{}
|
||||||
|
if err := json.Unmarshal(req.OutputConfig, &oc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if oc["effort"] != "high" {
|
||||||
|
t.Fatalf("effort=%v", oc["effort"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestClaudeToOpenAIResponseJSON_Thinking(t *testing.T) {
|
func TestClaudeToOpenAIResponseJSON_Thinking(t *testing.T) {
|
||||||
claudeBody := []byte(`{
|
claudeBody := []byte(`{
|
||||||
"id":"msg_1","type":"message","role":"assistant","model":"x","stop_reason":"end_turn",
|
"id":"msg_1","type":"message","role":"assistant","model":"x","stop_reason":"end_turn",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -535,3 +536,81 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
|
|||||||
|
|
||||||
return full.String(), toolCalls, finishReason, nil
|
return full.String(), toolCalls, finishReason, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ModelsListResponse 表示 OpenAI 兼容 GET /models 响应。
|
||||||
|
type ModelsListResponse struct {
|
||||||
|
Object string `json:"object"`
|
||||||
|
Data []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object,omitempty"`
|
||||||
|
OwnedBy string `json:"owned_by,omitempty"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModels 调用 GET {baseURL}/models 获取可用模型 id 列表(按字典序)。
|
||||||
|
func (c *Client) ListModels(ctx context.Context) ([]string, error) {
|
||||||
|
if c == nil {
|
||||||
|
return nil, fmt.Errorf("openai client is not initialized")
|
||||||
|
}
|
||||||
|
if c.config == nil {
|
||||||
|
return nil, fmt.Errorf("openai config is nil")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.config.APIKey) == "" {
|
||||||
|
return nil, fmt.Errorf("openai api key is empty")
|
||||||
|
}
|
||||||
|
if c.isClaude() {
|
||||||
|
return nil, fmt.Errorf("claude provider does not support models list API")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://api.openai.com/v1"
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/models", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build openai models request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("call openai models api: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read openai models response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, &APIError{
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
Body: string(respBody),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var list ModelsListResponse
|
||||||
|
if err := json.Unmarshal(respBody, &list); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode openai models response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, len(list.Data))
|
||||||
|
models := make([]string, 0, len(list.Data))
|
||||||
|
for _, item := range list.Data {
|
||||||
|
id := strings.TrimSpace(item.ID)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
models = append(models, id)
|
||||||
|
}
|
||||||
|
sort.Strings(models)
|
||||||
|
if len(models) == 0 {
|
||||||
|
return nil, fmt.Errorf("models list is empty")
|
||||||
|
}
|
||||||
|
return models, nil
|
||||||
|
}
|
||||||
|
|||||||
+46
-16
@@ -84,8 +84,9 @@ func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.Open
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyClaudeExtendedThinking sets Anthropic Messages API `thinking` when absent from ExtraRequestFields.
|
// applyClaudeExtendedThinking sets Anthropic Messages API fields per official guidance:
|
||||||
// Uses adaptive + summarized display by default (per Anthropic guidance for Claude 4.x); Sonnet 3.7 uses enabled+budget.
|
// - Adaptive models (4.6+): thinking.type=adaptive; output_config.effort only when user sets effort (API default is high).
|
||||||
|
// - Sonnet 3.7: thinking.type=enabled + budget_tokens=10000 (doc example); effort is not mapped — use extra_request_fields for custom budget.
|
||||||
func applyClaudeExtendedThinking(cfg *einoopenai.ChatModelConfig, mode, effort, model string) {
|
func applyClaudeExtendedThinking(cfg *einoopenai.ChatModelConfig, mode, effort, model string) {
|
||||||
if cfg == nil || mode == "off" {
|
if cfg == nil || mode == "off" {
|
||||||
return
|
return
|
||||||
@@ -93,31 +94,60 @@ func applyClaudeExtendedThinking(cfg *einoopenai.ChatModelConfig, mode, effort,
|
|||||||
if cfg.ExtraFields == nil {
|
if cfg.ExtraFields == nil {
|
||||||
cfg.ExtraFields = make(map[string]any)
|
cfg.ExtraFields = make(map[string]any)
|
||||||
}
|
}
|
||||||
if _, exists := cfg.ExtraFields["thinking"]; exists {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
m := strings.ToLower(strings.TrimSpace(model))
|
m := strings.ToLower(strings.TrimSpace(model))
|
||||||
thinking := map[string]any{
|
sonnet37 := isClaudeSonnet37(m)
|
||||||
"type": "adaptive",
|
|
||||||
"display": "summarized",
|
if _, exists := cfg.ExtraFields["thinking"]; !exists {
|
||||||
|
cfg.ExtraFields["thinking"] = claudeThinkingForModel(m, sonnet37)
|
||||||
}
|
}
|
||||||
// Sonnet 3.7: manual extended thinking is the documented path.
|
|
||||||
if strings.Contains(m, "claude-3-7-sonnet") || strings.Contains(m, "3-7-sonnet") || strings.Contains(m, "sonnet-3.7") {
|
applyClaudeOutputConfigEffort(cfg, effort, sonnet37)
|
||||||
thinking = map[string]any{
|
}
|
||||||
|
|
||||||
|
// claudeSonnet37DefaultBudgetTokens matches Anthropic extended-thinking documentation examples (budget_tokens with max_tokens 16000).
|
||||||
|
const claudeSonnet37DefaultBudgetTokens = 10000
|
||||||
|
|
||||||
|
func isClaudeSonnet37(m string) bool {
|
||||||
|
return strings.Contains(m, "claude-3-7-sonnet") ||
|
||||||
|
strings.Contains(m, "3-7-sonnet") ||
|
||||||
|
strings.Contains(m, "sonnet-3.7")
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeThinkingForModel(m string, sonnet37 bool) map[string]any {
|
||||||
|
if sonnet37 {
|
||||||
|
return map[string]any{
|
||||||
"type": "enabled",
|
"type": "enabled",
|
||||||
"budget_tokens": 10000,
|
"budget_tokens": claudeSonnet37DefaultBudgetTokens,
|
||||||
"display": "summarized",
|
"display": "summarized",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Opus 4.7+: manual enabled+budget rejected — keep adaptive only.
|
// Opus 4.7+: manual enabled+budget rejected — adaptive only.
|
||||||
if strings.Contains(m, "opus-4-7") || strings.Contains(m, "opus-4.7") {
|
if strings.Contains(m, "opus-4-7") || strings.Contains(m, "opus-4.7") {
|
||||||
thinking = map[string]any{
|
return map[string]any{
|
||||||
"type": "adaptive",
|
"type": "adaptive",
|
||||||
"display": "summarized",
|
"display": "summarized",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = effort // reserved: map to Anthropic effort / output_config when API stabilizes in one place
|
return map[string]any{
|
||||||
cfg.ExtraFields["thinking"] = thinking
|
"type": "adaptive",
|
||||||
|
"display": "summarized",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyClaudeOutputConfigEffort sets top-level output_config.effort only when effort is explicitly configured.
|
||||||
|
// Omitted effort uses the API default (high); do not inject effort on mode:on alone.
|
||||||
|
func applyClaudeOutputConfigEffort(cfg *einoopenai.ChatModelConfig, effort string, sonnet37 bool) {
|
||||||
|
if cfg == nil || sonnet37 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := cfg.ExtraFields["output_config"]; exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e := effortStringForAPI(effort)
|
||||||
|
if e == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.ExtraFields["output_config"] = map[string]any{"effort": e}
|
||||||
}
|
}
|
||||||
|
|
||||||
func effectiveMode(sr *config.OpenAIReasoningConfig, client *ClientIntent, allowClient bool) string {
|
func effectiveMode(sr *config.OpenAIReasoningConfig, client *ClientIntent, allowClient bool) string {
|
||||||
|
|||||||
@@ -80,3 +80,80 @@ func TestApplyOpenAICompat_maxPassthrough(t *testing.T) {
|
|||||||
t.Fatalf("max effort wire=%q, want max", got)
|
t.Fatalf("max effort wire=%q, want max", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyClaude_adaptiveOutputConfigEffort(t *testing.T) {
|
||||||
|
cfg := &einoopenai.ChatModelConfig{}
|
||||||
|
oa := &config.OpenAIConfig{
|
||||||
|
Provider: "claude",
|
||||||
|
Model: "claude-opus-4-8",
|
||||||
|
Reasoning: config.OpenAIReasoningConfig{
|
||||||
|
Mode: "on",
|
||||||
|
Effort: "xhigh",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ApplyToEinoChatModelConfig(cfg, oa, nil)
|
||||||
|
th, ok := cfg.ExtraFields["thinking"].(map[string]any)
|
||||||
|
if !ok || th["type"] != "adaptive" {
|
||||||
|
t.Fatalf("thinking=%#v", cfg.ExtraFields["thinking"])
|
||||||
|
}
|
||||||
|
oc, ok := cfg.ExtraFields["output_config"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected output_config")
|
||||||
|
}
|
||||||
|
if oc["effort"] != "xhigh" {
|
||||||
|
t.Fatalf("effort=%v", oc["effort"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyClaude_sonnet37OfficialBudget(t *testing.T) {
|
||||||
|
cfg := &einoopenai.ChatModelConfig{}
|
||||||
|
oa := &config.OpenAIConfig{
|
||||||
|
Provider: "claude",
|
||||||
|
Model: "claude-3-7-sonnet-latest",
|
||||||
|
Reasoning: config.OpenAIReasoningConfig{
|
||||||
|
Mode: "on",
|
||||||
|
Effort: "low", // 3.7 has no output_config.effort; effort is not mapped to budget_tokens
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ApplyToEinoChatModelConfig(cfg, oa, nil)
|
||||||
|
th, ok := cfg.ExtraFields["thinking"].(map[string]any)
|
||||||
|
if !ok || th["type"] != "enabled" {
|
||||||
|
t.Fatalf("thinking=%#v", cfg.ExtraFields["thinking"])
|
||||||
|
}
|
||||||
|
if th["budget_tokens"] != claudeSonnet37DefaultBudgetTokens {
|
||||||
|
t.Fatalf("budget_tokens=%v, want official example %d", th["budget_tokens"], claudeSonnet37DefaultBudgetTokens)
|
||||||
|
}
|
||||||
|
if _, hasOC := cfg.ExtraFields["output_config"]; hasOC {
|
||||||
|
t.Fatal("sonnet 3.7 should not set output_config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyClaude_onWithoutEffortOmitsOutputConfig(t *testing.T) {
|
||||||
|
cfg := &einoopenai.ChatModelConfig{}
|
||||||
|
oa := &config.OpenAIConfig{
|
||||||
|
Provider: "claude",
|
||||||
|
Model: "claude-sonnet-4-6",
|
||||||
|
Reasoning: config.OpenAIReasoningConfig{
|
||||||
|
Mode: "on",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ApplyToEinoChatModelConfig(cfg, oa, nil)
|
||||||
|
if _, hasOC := cfg.ExtraFields["output_config"]; hasOC {
|
||||||
|
t.Fatal("on without explicit effort should omit output_config (API default high)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyClaude_autoWithoutEffortSkipsOutputConfig(t *testing.T) {
|
||||||
|
cfg := &einoopenai.ChatModelConfig{}
|
||||||
|
oa := &config.OpenAIConfig{
|
||||||
|
Provider: "claude",
|
||||||
|
Model: "claude-sonnet-4-6",
|
||||||
|
Reasoning: config.OpenAIReasoningConfig{
|
||||||
|
Mode: "auto",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ApplyToEinoChatModelConfig(cfg, oa, nil)
|
||||||
|
if _, hasOC := cfg.ExtraFields["output_config"]; hasOC {
|
||||||
|
t.Fatal("auto without effort should omit output_config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1178
-47
File diff suppressed because it is too large
Load Diff
+405
-1
@@ -444,6 +444,18 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-header-actions .btn-danger,
|
||||||
|
.page-header-actions .btn-secondary,
|
||||||
|
.page-header-actions .btn-primary {
|
||||||
|
padding: 7px 16px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.page-content {
|
.page-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -5839,6 +5851,267 @@ header {
|
|||||||
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.2);
|
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Model picker: input + custom dropdown */
|
||||||
|
.model-pick-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: stretch;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-input {
|
||||||
|
flex: 1 1 140px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-input:hover {
|
||||||
|
border-color: rgba(0, 102, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-input.error {
|
||||||
|
border-color: var(--error-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-fetch-link {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-self: center;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--accent-color);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 4px 2px;
|
||||||
|
transition: color 0.15s ease, opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-fetch-link:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-native {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-dropdown {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 148px;
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 10px 0 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-trigger-label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-trigger-meta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 20px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 102, 255, 0.1);
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-caret {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: transform 0.2s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-dropdown.open .model-pick-caret {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-trigger:hover:not(:disabled) {
|
||||||
|
border-color: rgba(0, 102, 255, 0.45);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-dropdown.open .model-pick-trigger {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.1);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-dropdown.is-disabled .model-pick-trigger {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-menu {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
z-index: 2000;
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: min(360px, 92vw);
|
||||||
|
max-height: 300px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.14), 0 2px 8px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-dropdown.open .model-pick-menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: model-pick-menu-in 0.16s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes model-pick-menu-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-menu-header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 10px 14px 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-menu-list {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(0, 0, 0, 0.15) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-menu-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-menu-list::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.12s ease, color 0.12s ease;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-option:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-option.is-selected {
|
||||||
|
background: rgba(0, 102, 255, 0.08);
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-option-check {
|
||||||
|
flex: 0 0 14px;
|
||||||
|
width: 14px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--accent-color);
|
||||||
|
opacity: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-option.is-selected .model-pick-option-check {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pick-option-label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.batch-execute-now-label {
|
.batch-execute-now-label {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -10880,6 +11153,124 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
|||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-sort-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-sort-btn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-sort-btn:hover,
|
||||||
|
.conversation-sort-btn[aria-expanded="true"] {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-sort-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 200;
|
||||||
|
min-width: 156px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-sort-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 8px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-sort-option:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-sort-option.is-selected {
|
||||||
|
background: rgba(0, 102, 255, 0.08);
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-sort-option.is-selected:hover {
|
||||||
|
background: rgba(0, 102, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-sort-option-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-sort-option:hover .conversation-sort-option-icon {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-sort-option.is-selected .conversation-sort-option-icon {
|
||||||
|
background: rgba(0, 102, 255, 0.12);
|
||||||
|
color: var(--accent-color);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 102, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-sort-option-label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-sort-option-check {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 14px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-color);
|
||||||
|
opacity: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-sort-option.is-selected .conversation-sort-option-check {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -13545,10 +13936,23 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.batch-task-header .batch-task-edit-btn {
|
.batch-task-header .batch-task-edit-btn {
|
||||||
/* 仅把“编辑”(首个操作按钮)推到最右,避免多个按钮都 margin-left:auto 导致挤压/错位 */
|
/* 仅把「编辑」(首个操作按钮)推到最右,避免多个按钮都 margin-left:auto 导致挤压/错位 */
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.batch-task-header .batch-task-edit-btn--push {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-task-header .batch-task-run-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-task-header .batch-task-run-btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.batch-task-header .batch-task-delete-btn {
|
.batch-task-header .batch-task-delete-btn {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -436,6 +436,9 @@
|
|||||||
"conversationGroups": "Conversation groups",
|
"conversationGroups": "Conversation groups",
|
||||||
"addGroup": "New group",
|
"addGroup": "New group",
|
||||||
"recentConversations": "Recent conversations",
|
"recentConversations": "Recent conversations",
|
||||||
|
"sortConversations": "Sort",
|
||||||
|
"sortByCreatedAt": "Created time",
|
||||||
|
"sortByUpdatedAt": "Updated time",
|
||||||
"batchManage": "Batch manage",
|
"batchManage": "Batch manage",
|
||||||
"paginationShow": "Show {{start}}-{{end}} of {{total}}",
|
"paginationShow": "Show {{start}}-{{end}} of {{total}}",
|
||||||
"paginationRange": "{{start}}-{{end}}/{{total}}",
|
"paginationRange": "{{start}}-{{end}}/{{total}}",
|
||||||
@@ -676,7 +679,12 @@
|
|||||||
"viewConversation": "View conversation",
|
"viewConversation": "View conversation",
|
||||||
"viewVulnerabilities": "View vulnerabilities",
|
"viewVulnerabilities": "View vulnerabilities",
|
||||||
"viewVulnerabilitiesQueueTitle": "View vulnerabilities: open management filtered to this queue",
|
"viewVulnerabilitiesQueueTitle": "View vulnerabilities: open management filtered to this queue",
|
||||||
"retryTask": "Retry",
|
"runSingleTask": "Run task",
|
||||||
|
"confirmRunSingleTask": "Run this task only? The queue will pause when it finishes and will not continue other pending items.",
|
||||||
|
"runSingleTaskFailed": "Failed to run task",
|
||||||
|
"runSingleTaskUnavailable": "Unavailable while the queue or a task is running",
|
||||||
|
"runSingleTaskUnavailableSelf": "This task is running",
|
||||||
|
"runSingleTaskUnavailableQueue": "Queue is running; pause it before running another task individually",
|
||||||
"conversationIdLabel": "Conversation ID",
|
"conversationIdLabel": "Conversation ID",
|
||||||
"statusPending": "Pending",
|
"statusPending": "Pending",
|
||||||
"statusPaused": "Paused",
|
"statusPaused": "Paused",
|
||||||
@@ -1939,6 +1947,13 @@
|
|||||||
"openaiBaseUrlPlaceholder": "https://api.openai.com/v1",
|
"openaiBaseUrlPlaceholder": "https://api.openai.com/v1",
|
||||||
"openaiApiKeyPlaceholder": "Enter OpenAI API Key",
|
"openaiApiKeyPlaceholder": "Enter OpenAI API Key",
|
||||||
"modelPlaceholder": "gpt-4",
|
"modelPlaceholder": "gpt-4",
|
||||||
|
"fetchModels": "Fetch list",
|
||||||
|
"modelsListFetching": "Fetching model list...",
|
||||||
|
"modelsListSelectPlaceholder": "Select a model",
|
||||||
|
"modelsListSuccess": "Loaded {count} models — use the dropdown on the right, or type in the input",
|
||||||
|
"modelsListFailed": "Failed to fetch model list",
|
||||||
|
"modelsListNeedApiKey": "Please enter API Key first",
|
||||||
|
"modelsListClaudeHint": "Claude does not support auto model list; enter the model name manually",
|
||||||
"maxTotalTokens": "Max Context Tokens",
|
"maxTotalTokens": "Max Context Tokens",
|
||||||
"maxTotalTokensPlaceholder": "120000",
|
"maxTotalTokensPlaceholder": "120000",
|
||||||
"maxTotalTokensHint": "Shared by memory compression and attack chain building. Default: 120000",
|
"maxTotalTokensHint": "Shared by memory compression and attack chain building. Default: 120000",
|
||||||
@@ -2684,6 +2699,11 @@
|
|||||||
},
|
},
|
||||||
"c2": {
|
"c2": {
|
||||||
"clipboardCopied": "Copied to clipboard",
|
"clipboardCopied": "Copied to clipboard",
|
||||||
|
"common": {
|
||||||
|
"justNow": "Just now",
|
||||||
|
"minutesAgo": "{{n}}m ago",
|
||||||
|
"hoursAgo": "{{n}}h ago"
|
||||||
|
},
|
||||||
"fmt": {
|
"fmt": {
|
||||||
"durationMs": "{{n}}ms",
|
"durationMs": "{{n}}ms",
|
||||||
"durationSec": "{{n}}s",
|
"durationSec": "{{n}}s",
|
||||||
@@ -2741,6 +2761,8 @@
|
|||||||
"bindHintExternal": "Use 0.0.0.0 to allow external access",
|
"bindHintExternal": "Use 0.0.0.0 to allow external access",
|
||||||
"callbackHost": "Callback host (optional)",
|
"callbackHost": "Callback host (optional)",
|
||||||
"callbackHostHint": "Public IP or hostname stored for payloads/beacons; separate from bind address. If empty, payload generation falls back to bind address / auto-detect.",
|
"callbackHostHint": "Public IP or hostname stored for payloads/beacons; separate from bind address. If empty, payload generation falls back to bind address / auto-detect.",
|
||||||
|
"allowLegacyShell": "Allow unencrypted classic reverse shell (lab only)",
|
||||||
|
"allowLegacyShellHint": "Off by default. When enabled, raw bash/nc TCP connections register sessions and are vulnerable to internet scanners; use encrypted Beacon builds for production.",
|
||||||
"malleableProfile": "Malleable Profile",
|
"malleableProfile": "Malleable Profile",
|
||||||
"malleableProfileHint": "Optional; HTTP/HTTPS Beacon response headers and traffic disguise. Stop and start the listener again for changes to take effect.",
|
"malleableProfileHint": "Optional; HTTP/HTTPS Beacon response headers and traffic disguise. Stop and start the listener again for changes to take effect.",
|
||||||
"malleableProfileNone": "None",
|
"malleableProfileNone": "None",
|
||||||
@@ -2818,10 +2840,22 @@
|
|||||||
"infoFirstSeen": "First seen",
|
"infoFirstSeen": "First seen",
|
||||||
"infoLastCheckin": "Last check-in",
|
"infoLastCheckin": "Last check-in",
|
||||||
"infoNote": "Note",
|
"infoNote": "Note",
|
||||||
|
"infoNoteEmpty": "No notes",
|
||||||
|
"infoSectionIdentity": "Identity",
|
||||||
|
"infoSectionSystem": "System",
|
||||||
|
"infoSectionNetwork": "Network & beacon",
|
||||||
|
"infoSectionTimeline": "Timeline",
|
||||||
|
"infoSectionNote": "Notes",
|
||||||
"adminYes": "Yes",
|
"adminYes": "Yes",
|
||||||
"adminNo": "No",
|
"adminNo": "No",
|
||||||
"promptSleepSeconds": "Sleep interval (seconds)",
|
"promptSleepSeconds": "Sleep interval (seconds)",
|
||||||
"promptJitterPercent": "Jitter percent (0–100)",
|
"promptJitterPercent": "Jitter percent (0–100)",
|
||||||
|
"sleepModalHint": "Saves to the server and queues a sleep task. The implant applies it on the next task poll; later check-ins keep this config.",
|
||||||
|
"sleepModalTitle": "Beacon interval",
|
||||||
|
"sleepModalCurrent": "Current {{sec}}s · jitter {{jitter}}%",
|
||||||
|
"sleepModalPreview": "Estimated {{min}} – {{max}} s",
|
||||||
|
"sleepModalPresets": "Presets",
|
||||||
|
"toastSleepInvalid": "Sleep interval must be at least 1 second",
|
||||||
"toastSleepUpdated": "Sleep settings updated",
|
"toastSleepUpdated": "Sleep settings updated",
|
||||||
"confirmExitSession": "Send exit command to this session?",
|
"confirmExitSession": "Send exit command to this session?",
|
||||||
"confirmDeleteSession": "Remove this session and related tasks/files from the server? (Does not send exit to the implant; use Kill Session to exit the agent.)",
|
"confirmDeleteSession": "Remove this session and related tasks/files from the server? (Does not send exit to the implant; use Kill Session to exit the agent.)",
|
||||||
@@ -2839,7 +2873,25 @@
|
|||||||
"termWaitFinish": "Please wait for the current command to finish",
|
"termWaitFinish": "Please wait for the current command to finish",
|
||||||
"termCtrlC": "Remote interrupt is not supported in this version",
|
"termCtrlC": "Remote interrupt is not supported in this version",
|
||||||
"termQueued": "[Command queued — will run after the current task completes]",
|
"termQueued": "[Command queued — will run after the current task completes]",
|
||||||
"clearTerminal": "Clear"
|
"clearTerminal": "Clear",
|
||||||
|
"batchDelete": "Delete selected",
|
||||||
|
"deleteFiltered": "Delete filtered",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"filterAllStatus": "All statuses",
|
||||||
|
"filterAllListeners": "All listeners",
|
||||||
|
"filterSearchPlaceholder": "Search hostname / user / IP",
|
||||||
|
"filterApply": "Filter",
|
||||||
|
"filterReset": "Reset",
|
||||||
|
"filterSuspicious": "Likely false positives",
|
||||||
|
"filterCount": "{{n}} total, {{selected}} selected",
|
||||||
|
"emptyFilter": "No sessions match the current filters",
|
||||||
|
"listEmpty": "No sessions",
|
||||||
|
"selectPromptTitle": "Select a session",
|
||||||
|
"selectPromptHint": "Click a session in the list on the left to view terminal, files, and tasks.",
|
||||||
|
"confirmBatchDelete": "Delete {{n}} selected session(s)? Related tasks and file records will be removed.",
|
||||||
|
"confirmDeleteFiltered": "Delete all {{n}} session(s) in the current filter results?",
|
||||||
|
"toastSelectFirst": "Select at least one session to delete",
|
||||||
|
"toastBatchDeleted": "Deleted {{n}} session(s)"
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"title": "Task Management",
|
"title": "Task Management",
|
||||||
@@ -2862,6 +2914,8 @@
|
|||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
"emptyAll": "No tasks yet",
|
"emptyAll": "No tasks yet",
|
||||||
"emptySession": "No tasks for this session",
|
"emptySession": "No tasks for this session",
|
||||||
|
"sessionTaskHistory": "Task history",
|
||||||
|
"sessionTaskCount": "{{n}} tasks",
|
||||||
"colTask": "Task",
|
"colTask": "Task",
|
||||||
"colSession": "Session",
|
"colSession": "Session",
|
||||||
"colType": "Type",
|
"colType": "Type",
|
||||||
|
|||||||
@@ -424,6 +424,9 @@
|
|||||||
"conversationGroups": "对话分组",
|
"conversationGroups": "对话分组",
|
||||||
"addGroup": "新建分组",
|
"addGroup": "新建分组",
|
||||||
"recentConversations": "最近对话",
|
"recentConversations": "最近对话",
|
||||||
|
"sortConversations": "排序",
|
||||||
|
"sortByCreatedAt": "创建时间",
|
||||||
|
"sortByUpdatedAt": "更新时间",
|
||||||
"batchManage": "批量管理",
|
"batchManage": "批量管理",
|
||||||
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}}",
|
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}}",
|
||||||
"paginationRange": "{{start}}-{{end}}/{{total}}",
|
"paginationRange": "{{start}}-{{end}}/{{total}}",
|
||||||
@@ -664,7 +667,12 @@
|
|||||||
"viewConversation": "查看对话",
|
"viewConversation": "查看对话",
|
||||||
"viewVulnerabilities": "查看漏洞",
|
"viewVulnerabilities": "查看漏洞",
|
||||||
"viewVulnerabilitiesQueueTitle": "查看漏洞:打开漏洞管理并筛选本队列",
|
"viewVulnerabilitiesQueueTitle": "查看漏洞:打开漏洞管理并筛选本队列",
|
||||||
"retryTask": "重试",
|
"runSingleTask": "单条执行",
|
||||||
|
"confirmRunSingleTask": "确定执行该任务?仅运行这一条,完成后队列会自动暂停,不会继续执行其他待执行项。",
|
||||||
|
"runSingleTaskFailed": "单条执行失败",
|
||||||
|
"runSingleTaskUnavailable": "队列或任务执行中,暂无法单条执行",
|
||||||
|
"runSingleTaskUnavailableSelf": "该任务正在执行中",
|
||||||
|
"runSingleTaskUnavailableQueue": "队列批量执行中,请暂停后再单条执行其它任务",
|
||||||
"conversationIdLabel": "对话ID",
|
"conversationIdLabel": "对话ID",
|
||||||
"statusPending": "待执行",
|
"statusPending": "待执行",
|
||||||
"statusPaused": "已暂停",
|
"statusPaused": "已暂停",
|
||||||
@@ -1927,6 +1935,13 @@
|
|||||||
"openaiBaseUrlPlaceholder": "https://api.openai.com/v1",
|
"openaiBaseUrlPlaceholder": "https://api.openai.com/v1",
|
||||||
"openaiApiKeyPlaceholder": "输入OpenAI API Key",
|
"openaiApiKeyPlaceholder": "输入OpenAI API Key",
|
||||||
"modelPlaceholder": "gpt-4",
|
"modelPlaceholder": "gpt-4",
|
||||||
|
"fetchModels": "获取列表",
|
||||||
|
"modelsListFetching": "正在获取模型列表...",
|
||||||
|
"modelsListSelectPlaceholder": "请选择模型",
|
||||||
|
"modelsListSuccess": "已加载 {count} 个模型,请用右侧下拉框选择,或继续在左侧输入",
|
||||||
|
"modelsListFailed": "获取模型列表失败",
|
||||||
|
"modelsListNeedApiKey": "请先填写 API Key",
|
||||||
|
"modelsListClaudeHint": "Claude 不支持自动获取模型列表,请手动填写",
|
||||||
"maxTotalTokens": "最大上下文 Token 数",
|
"maxTotalTokens": "最大上下文 Token 数",
|
||||||
"maxTotalTokensPlaceholder": "120000",
|
"maxTotalTokensPlaceholder": "120000",
|
||||||
"maxTotalTokensHint": "内存压缩和攻击链构建共用此配置,默认 120000",
|
"maxTotalTokensHint": "内存压缩和攻击链构建共用此配置,默认 120000",
|
||||||
@@ -2672,6 +2687,11 @@
|
|||||||
},
|
},
|
||||||
"c2": {
|
"c2": {
|
||||||
"clipboardCopied": "已复制到剪贴板",
|
"clipboardCopied": "已复制到剪贴板",
|
||||||
|
"common": {
|
||||||
|
"justNow": "刚刚",
|
||||||
|
"minutesAgo": "{{n}} 分钟前",
|
||||||
|
"hoursAgo": "{{n}} 小时前"
|
||||||
|
},
|
||||||
"fmt": {
|
"fmt": {
|
||||||
"durationMs": "{{n}}ms",
|
"durationMs": "{{n}}ms",
|
||||||
"durationSec": "{{n}}秒",
|
"durationSec": "{{n}}秒",
|
||||||
@@ -2729,6 +2749,8 @@
|
|||||||
"bindHintExternal": "使用 0.0.0.0 允许外部访问",
|
"bindHintExternal": "使用 0.0.0.0 允许外部访问",
|
||||||
"callbackHost": "回连地址(可选)",
|
"callbackHost": "回连地址(可选)",
|
||||||
"callbackHostHint": "公网 IP 或域名,写入配置供 Payload/Beacon 使用;与「绑定地址」分离。不填则生成 Payload 时按绑定地址或自动探测。",
|
"callbackHostHint": "公网 IP 或域名,写入配置供 Payload/Beacon 使用;与「绑定地址」分离。不填则生成 Payload 时按绑定地址或自动探测。",
|
||||||
|
"allowLegacyShell": "允许未加密经典反弹 Shell(内网实验)",
|
||||||
|
"allowLegacyShellHint": "默认关闭。开启后 bash/nc 等裸 TCP 连接可登记会话,公网易被扫描器误连;生产环境请使用「生成 Beacon」加密上线。",
|
||||||
"malleableProfile": "Malleable Profile",
|
"malleableProfile": "Malleable Profile",
|
||||||
"malleableProfileHint": "可选;用于 HTTP/HTTPS Beacon 服务端响应头等流量伪装。修改后需停止并重新启动监听器才会生效。",
|
"malleableProfileHint": "可选;用于 HTTP/HTTPS Beacon 服务端响应头等流量伪装。修改后需停止并重新启动监听器才会生效。",
|
||||||
"malleableProfileNone": "不使用",
|
"malleableProfileNone": "不使用",
|
||||||
@@ -2806,10 +2828,22 @@
|
|||||||
"infoFirstSeen": "首次上线",
|
"infoFirstSeen": "首次上线",
|
||||||
"infoLastCheckin": "上次心跳",
|
"infoLastCheckin": "上次心跳",
|
||||||
"infoNote": "备注",
|
"infoNote": "备注",
|
||||||
|
"infoNoteEmpty": "暂无备注",
|
||||||
|
"infoSectionIdentity": "身份信息",
|
||||||
|
"infoSectionSystem": "系统环境",
|
||||||
|
"infoSectionNetwork": "网络与信标",
|
||||||
|
"infoSectionTimeline": "时间线",
|
||||||
|
"infoSectionNote": "备注",
|
||||||
"adminYes": "是",
|
"adminYes": "是",
|
||||||
"adminNo": "否",
|
"adminNo": "否",
|
||||||
"promptSleepSeconds": "Sleep 间隔(秒)",
|
"promptSleepSeconds": "Sleep 间隔(秒)",
|
||||||
"promptJitterPercent": "抖动百分比(0–100)",
|
"promptJitterPercent": "抖动百分比(0–100)",
|
||||||
|
"sleepModalHint": "保存后将写入服务端并下发 sleep 任务;植入体在下次拉取任务后生效,同时后续心跳会同步该配置。",
|
||||||
|
"sleepModalTitle": "心跳配置",
|
||||||
|
"sleepModalCurrent": "当前 {{sec}} 秒 · 抖动 {{jitter}}%",
|
||||||
|
"sleepModalPreview": "预计间隔 {{min}} – {{max}} 秒",
|
||||||
|
"sleepModalPresets": "快捷",
|
||||||
|
"toastSleepInvalid": "Sleep 间隔至少为 1 秒",
|
||||||
"toastSleepUpdated": "Sleep 设置已更新",
|
"toastSleepUpdated": "Sleep 设置已更新",
|
||||||
"confirmExitSession": "向该会话发送退出指令?",
|
"confirmExitSession": "向该会话发送退出指令?",
|
||||||
"confirmDeleteSession": "从服务器删除此会话及其关联任务与文件记录?(不会向植入体发送退出;若需退出目标进程请使用「终止会话」。)",
|
"confirmDeleteSession": "从服务器删除此会话及其关联任务与文件记录?(不会向植入体发送退出;若需退出目标进程请使用「终止会话」。)",
|
||||||
@@ -2827,7 +2861,25 @@
|
|||||||
"termWaitFinish": "请等待当前命令执行完成",
|
"termWaitFinish": "请等待当前命令执行完成",
|
||||||
"termCtrlC": "当前版本暂不支持中断远程命令",
|
"termCtrlC": "当前版本暂不支持中断远程命令",
|
||||||
"termQueued": "[命令已加入队列,将在当前任务完成后执行]",
|
"termQueued": "[命令已加入队列,将在当前任务完成后执行]",
|
||||||
"clearTerminal": "清屏"
|
"clearTerminal": "清屏",
|
||||||
|
"batchDelete": "批量删除",
|
||||||
|
"deleteFiltered": "删除筛选结果",
|
||||||
|
"selectAll": "全选",
|
||||||
|
"filterAllStatus": "全部状态",
|
||||||
|
"filterAllListeners": "全部监听器",
|
||||||
|
"filterSearchPlaceholder": "搜索主机名 / 用户 / IP",
|
||||||
|
"filterApply": "筛选",
|
||||||
|
"filterReset": "重置",
|
||||||
|
"filterSuspicious": "疑似误报",
|
||||||
|
"filterCount": "共 {{n}} 条,已选 {{selected}}",
|
||||||
|
"emptyFilter": "没有符合筛选条件的会话",
|
||||||
|
"listEmpty": "暂无会话",
|
||||||
|
"selectPromptTitle": "选择会话",
|
||||||
|
"selectPromptHint": "在左侧列表中点击一个会话,查看终端、文件与任务详情。",
|
||||||
|
"confirmBatchDelete": "确定删除选中的 {{n}} 个会话?关联任务与文件记录将一并清除。",
|
||||||
|
"confirmDeleteFiltered": "确定删除当前筛选结果中的全部 {{n}} 个会话?",
|
||||||
|
"toastSelectFirst": "请先勾选要删除的会话",
|
||||||
|
"toastBatchDeleted": "已删除 {{n}} 个会话"
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"title": "任务管理",
|
"title": "任务管理",
|
||||||
@@ -2850,6 +2902,8 @@
|
|||||||
"pending": "待处理",
|
"pending": "待处理",
|
||||||
"emptyAll": "暂无任务",
|
"emptyAll": "暂无任务",
|
||||||
"emptySession": "该会话暂无任务",
|
"emptySession": "该会话暂无任务",
|
||||||
|
"sessionTaskHistory": "任务历史",
|
||||||
|
"sessionTaskCount": "共 {{n}} 条",
|
||||||
"colTask": "任务",
|
"colTask": "任务",
|
||||||
"colSession": "会话",
|
"colSession": "会话",
|
||||||
"colType": "类型",
|
"colType": "类型",
|
||||||
|
|||||||
+740
-135
File diff suppressed because it is too large
Load Diff
+98
-9
@@ -5763,6 +5763,95 @@ let conversationGroupMappingCache = {};
|
|||||||
let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端API延迟的情况)
|
let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端API延迟的情况)
|
||||||
let conversationsListLoadSeq = 0; // 对话列表加载序号,避免并发请求导致重复渲染
|
let conversationsListLoadSeq = 0; // 对话列表加载序号,避免并发请求导致重复渲染
|
||||||
const CONVERSATIONS_PAGE_SIZE_KEY = 'cyberstrike.conversations_page_size';
|
const CONVERSATIONS_PAGE_SIZE_KEY = 'cyberstrike.conversations_page_size';
|
||||||
|
const CONVERSATIONS_SORT_KEY = 'cyberstrike.conversations_sort_by';
|
||||||
|
|
||||||
|
function getConversationSortBy() {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(CONVERSATIONS_SORT_KEY);
|
||||||
|
if (saved === 'created_at' || saved === 'updated_at') return saved;
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
return 'updated_at';
|
||||||
|
}
|
||||||
|
|
||||||
|
let conversationSortBy = getConversationSortBy();
|
||||||
|
|
||||||
|
function getConversationSortTime(conv) {
|
||||||
|
const field = conversationSortBy === 'created_at' ? 'createdAt' : 'updatedAt';
|
||||||
|
const raw = conv && conv[field];
|
||||||
|
if (!raw) return new Date(0);
|
||||||
|
const date = new Date(raw);
|
||||||
|
return isNaN(date.getTime()) ? new Date(0) : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConversationSortMenuUI() {
|
||||||
|
const menu = document.getElementById('conversation-sort-menu');
|
||||||
|
const btn = document.getElementById('conversation-sort-btn');
|
||||||
|
if (!menu) return;
|
||||||
|
menu.querySelectorAll('.conversation-sort-option').forEach((option) => {
|
||||||
|
const selected = option.dataset.sort === conversationSortBy;
|
||||||
|
option.classList.toggle('is-selected', selected);
|
||||||
|
option.setAttribute('aria-checked', selected ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
if (btn) {
|
||||||
|
btn.setAttribute('aria-expanded', menu.hidden ? 'false' : 'true');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConversationSortMenu() {
|
||||||
|
const menu = document.getElementById('conversation-sort-menu');
|
||||||
|
const btn = document.getElementById('conversation-sort-btn');
|
||||||
|
if (menu) menu.hidden = true;
|
||||||
|
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleConversationSortMenu(event) {
|
||||||
|
if (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
const menu = document.getElementById('conversation-sort-menu');
|
||||||
|
const btn = document.getElementById('conversation-sort-btn');
|
||||||
|
if (!menu || !btn) return;
|
||||||
|
const willOpen = menu.hidden;
|
||||||
|
closeConversationSortMenu();
|
||||||
|
if (willOpen) {
|
||||||
|
menu.hidden = false;
|
||||||
|
btn.setAttribute('aria-expanded', 'true');
|
||||||
|
updateConversationSortMenuUI();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConversationSortBy(sortBy) {
|
||||||
|
const next = sortBy === 'created_at' ? 'created_at' : 'updated_at';
|
||||||
|
if (next === conversationSortBy) {
|
||||||
|
closeConversationSortMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
conversationSortBy = next;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CONVERSATIONS_SORT_KEY, next);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
updateConversationSortMenuUI();
|
||||||
|
closeConversationSortMenu();
|
||||||
|
conversationsPagination.page = 1;
|
||||||
|
loadConversationsWithGroups(conversationsSearchQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.__conversationSortMenuBound) {
|
||||||
|
window.__conversationSortMenuBound = true;
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
const dropdown = document.getElementById('conversation-sort-dropdown');
|
||||||
|
if (!dropdown || dropdown.contains(event.target)) return;
|
||||||
|
closeConversationSortMenu();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') closeConversationSortMenu();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.toggleConversationSortMenu = toggleConversationSortMenu;
|
||||||
|
window.setConversationSortBy = setConversationSortBy;
|
||||||
|
window.closeConversationSortMenu = closeConversationSortMenu;
|
||||||
|
|
||||||
function getConversationsPageSize() {
|
function getConversationsPageSize() {
|
||||||
try {
|
try {
|
||||||
@@ -6025,6 +6114,9 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
|||||||
const pageSize = conversationsPagination.pageSize;
|
const pageSize = conversationsPagination.pageSize;
|
||||||
const offset = (conversationsPagination.page - 1) * pageSize;
|
const offset = (conversationsPagination.page - 1) * pageSize;
|
||||||
const convParams = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
|
const convParams = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
|
||||||
|
if (conversationSortBy === 'created_at') {
|
||||||
|
convParams.set('sort_by', 'created_at');
|
||||||
|
}
|
||||||
if (searchQuery && searchQuery.trim()) {
|
if (searchQuery && searchQuery.trim()) {
|
||||||
convParams.set('search', searchQuery.trim());
|
convParams.set('search', searchQuery.trim());
|
||||||
} else {
|
} else {
|
||||||
@@ -6114,11 +6206,7 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 按时间排序
|
// 按时间排序
|
||||||
const sortByTime = (a, b) => {
|
const sortByTime = (a, b) => getConversationSortTime(b) - getConversationSortTime(a);
|
||||||
const timeA = a.updatedAt ? new Date(a.updatedAt) : new Date(0);
|
|
||||||
const timeB = b.updatedAt ? new Date(b.updatedAt) : new Date(0);
|
|
||||||
return timeB - timeA;
|
|
||||||
};
|
|
||||||
|
|
||||||
pinnedConvs.sort(sortByTime);
|
pinnedConvs.sort(sortByTime);
|
||||||
normalConvs.sort(sortByTime);
|
normalConvs.sort(sortByTime);
|
||||||
@@ -6146,8 +6234,8 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
|||||||
};
|
};
|
||||||
|
|
||||||
normalConvs.forEach(conv => {
|
normalConvs.forEach(conv => {
|
||||||
const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date();
|
const dateObj = getConversationSortTime(conv);
|
||||||
const validDate = isNaN(dateObj.getTime()) ? new Date() : dateObj;
|
const validDate = dateObj.getTime() === 0 ? new Date() : dateObj;
|
||||||
const groupKey = getConversationGroup(validDate, todayStart, sevenDaysCutoff, yesterdayStart);
|
const groupKey = getConversationGroup(validDate, todayStart, sevenDaysCutoff, yesterdayStart);
|
||||||
groups[groupKey].push({
|
groups[groupKey].push({
|
||||||
...conv,
|
...conv,
|
||||||
@@ -6159,8 +6247,8 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
|||||||
|
|
||||||
if (pinnedConvs.length > 0) {
|
if (pinnedConvs.length > 0) {
|
||||||
pinnedConvs.forEach(conv => {
|
pinnedConvs.forEach(conv => {
|
||||||
const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date();
|
const dateObj = getConversationSortTime(conv);
|
||||||
const validDate = isNaN(dateObj.getTime()) ? new Date() : dateObj;
|
const validDate = dateObj.getTime() === 0 ? new Date() : dateObj;
|
||||||
fragment.appendChild(createConversationListItemWithMenu({
|
fragment.appendChild(createConversationListItemWithMenu({
|
||||||
...conv,
|
...conv,
|
||||||
_timeText: formatConversationTimestamp(validDate, todayStart, yesterdayStart),
|
_timeText: formatConversationTimestamp(validDate, todayStart, yesterdayStart),
|
||||||
@@ -8508,6 +8596,7 @@ function clearGroupSearch() {
|
|||||||
|
|
||||||
// 初始化时加载分组
|
// 初始化时加载分组
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
updateConversationSortMenuUI();
|
||||||
await loadGroups();
|
await loadGroups();
|
||||||
await loadConversationsWithGroups();
|
await loadConversationsWithGroups();
|
||||||
|
|
||||||
|
|||||||
+390
-1
@@ -299,6 +299,7 @@ async function loadConfig(loadTools = true) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fillVisionConfigFromCurrent(currentConfig.vision || {});
|
fillVisionConfigFromCurrent(currentConfig.vision || {});
|
||||||
|
initModelListControls();
|
||||||
|
|
||||||
// 填充FOFA配置
|
// 填充FOFA配置
|
||||||
const fofa = currentConfig.fofa || {};
|
const fofa = currentConfig.fofa || {};
|
||||||
@@ -1569,9 +1570,397 @@ function syncVisionFormEnabled() {
|
|||||||
if (panel) {
|
if (panel) {
|
||||||
panel.style.opacity = enabled ? '1' : '0.55';
|
panel.style.opacity = enabled ? '1' : '0.55';
|
||||||
panel.querySelectorAll('input, select, textarea, a').forEach(el => {
|
panel.querySelectorAll('input, select, textarea, a').forEach(el => {
|
||||||
if (el.id === 'test-vision-btn') return;
|
if (el.id === 'test-vision-btn' || el.id === 'fetch-vision-models-btn' || el.id === 'vision-model-select') return;
|
||||||
el.disabled = !enabled;
|
el.disabled = !enabled;
|
||||||
});
|
});
|
||||||
|
syncModelListFetchButtons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelPickSelectMap = {};
|
||||||
|
let modelPickSelectDocListener = false;
|
||||||
|
|
||||||
|
function modelPickT(key) {
|
||||||
|
return typeof window.t === 'function' ? window.t(key) : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAllModelPickDropdowns() {
|
||||||
|
Object.keys(modelPickSelectMap).forEach(function (id) {
|
||||||
|
modelPickSelectMap[id].wrapper.classList.remove('open');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncModelPickDropdown(selectId) {
|
||||||
|
const reg = modelPickSelectMap[selectId];
|
||||||
|
if (!reg) return;
|
||||||
|
const { select, dropdown, trigger, wrapper, menuList, countBadge } = reg;
|
||||||
|
const placeholder = modelPickT('settingsBasic.modelsListSelectPlaceholder');
|
||||||
|
|
||||||
|
menuList.innerHTML = '';
|
||||||
|
let optionCount = 0;
|
||||||
|
Array.prototype.forEach.call(select.options, function (opt) {
|
||||||
|
if (!opt.value) return;
|
||||||
|
optionCount += 1;
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'model-pick-option';
|
||||||
|
item.setAttribute('role', 'option');
|
||||||
|
item.setAttribute('data-value', opt.value);
|
||||||
|
if (opt.value === select.value) {
|
||||||
|
item.classList.add('is-selected');
|
||||||
|
item.setAttribute('aria-selected', 'true');
|
||||||
|
}
|
||||||
|
const check = document.createElement('span');
|
||||||
|
check.className = 'model-pick-option-check';
|
||||||
|
check.setAttribute('aria-hidden', 'true');
|
||||||
|
check.textContent = '✓';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'model-pick-option-label';
|
||||||
|
label.textContent = opt.textContent;
|
||||||
|
item.appendChild(check);
|
||||||
|
item.appendChild(label);
|
||||||
|
menuList.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedOpt = select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null;
|
||||||
|
const labelEl = trigger.querySelector('.model-pick-trigger-label');
|
||||||
|
if (labelEl) {
|
||||||
|
labelEl.textContent = (selectedOpt && selectedOpt.value) ? selectedOpt.textContent : placeholder;
|
||||||
|
}
|
||||||
|
if (countBadge) {
|
||||||
|
countBadge.textContent = String(optionCount);
|
||||||
|
countBadge.style.display = optionCount > 0 ? '' : 'none';
|
||||||
|
}
|
||||||
|
const header = wrapper.querySelector('.model-pick-menu-header');
|
||||||
|
if (header) {
|
||||||
|
header.textContent = optionCount > 0
|
||||||
|
? placeholder + ' · ' + optionCount
|
||||||
|
: placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger.disabled = !!select.disabled;
|
||||||
|
wrapper.classList.toggle('is-disabled', !!select.disabled);
|
||||||
|
wrapper.style.display = optionCount > 0 ? '' : 'none';
|
||||||
|
select.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function enhanceModelPickSelect(selectId) {
|
||||||
|
const select = document.getElementById(selectId);
|
||||||
|
if (!select) return;
|
||||||
|
if (select.dataset.modelPickEnhanced === '1') {
|
||||||
|
syncModelPickDropdown(selectId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
select.dataset.modelPickEnhanced = '1';
|
||||||
|
select.classList.add('model-pick-native');
|
||||||
|
select.tabIndex = -1;
|
||||||
|
select.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'model-pick-dropdown';
|
||||||
|
wrapper.style.display = 'none';
|
||||||
|
|
||||||
|
const trigger = document.createElement('button');
|
||||||
|
trigger.type = 'button';
|
||||||
|
trigger.className = 'model-pick-trigger';
|
||||||
|
trigger.setAttribute('aria-haspopup', 'listbox');
|
||||||
|
|
||||||
|
const labelSpan = document.createElement('span');
|
||||||
|
labelSpan.className = 'model-pick-trigger-label';
|
||||||
|
labelSpan.textContent = modelPickT('settingsBasic.modelsListSelectPlaceholder');
|
||||||
|
|
||||||
|
const meta = document.createElement('span');
|
||||||
|
meta.className = 'model-pick-trigger-meta';
|
||||||
|
|
||||||
|
const countBadge = document.createElement('span');
|
||||||
|
countBadge.className = 'model-pick-count';
|
||||||
|
countBadge.style.display = 'none';
|
||||||
|
|
||||||
|
const caret = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
caret.setAttribute('class', 'model-pick-caret');
|
||||||
|
caret.setAttribute('viewBox', '0 0 16 16');
|
||||||
|
caret.setAttribute('aria-hidden', 'true');
|
||||||
|
caret.innerHTML = '<path fill="currentColor" d="M4.47 6.47a.75.75 0 0 1 1.06 0L8 8.94l2.47-2.47a.75.75 0 1 1 1.06 1.06l-3 3a.75.75 0 0 1-1.06 0l-3-3a.75.75 0 0 1 0-1.06z"/>';
|
||||||
|
|
||||||
|
meta.appendChild(countBadge);
|
||||||
|
meta.appendChild(caret);
|
||||||
|
trigger.appendChild(labelSpan);
|
||||||
|
trigger.appendChild(meta);
|
||||||
|
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = 'model-pick-menu';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'model-pick-menu-header';
|
||||||
|
menu.appendChild(header);
|
||||||
|
|
||||||
|
const menuList = document.createElement('div');
|
||||||
|
menuList.className = 'model-pick-menu-list';
|
||||||
|
menuList.setAttribute('role', 'listbox');
|
||||||
|
menu.appendChild(menuList);
|
||||||
|
|
||||||
|
const parent = select.parentNode;
|
||||||
|
const fetchLink = parent.querySelector('.model-pick-fetch-link');
|
||||||
|
if (fetchLink) {
|
||||||
|
parent.insertBefore(wrapper, fetchLink);
|
||||||
|
} else {
|
||||||
|
parent.appendChild(wrapper);
|
||||||
|
}
|
||||||
|
wrapper.appendChild(trigger);
|
||||||
|
wrapper.appendChild(menu);
|
||||||
|
wrapper.appendChild(select);
|
||||||
|
|
||||||
|
modelPickSelectMap[selectId] = {
|
||||||
|
wrapper,
|
||||||
|
trigger,
|
||||||
|
menu,
|
||||||
|
menuList,
|
||||||
|
countBadge,
|
||||||
|
select
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!modelPickSelectDocListener) {
|
||||||
|
document.addEventListener('click', closeAllModelPickDropdowns);
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') closeAllModelPickDropdowns();
|
||||||
|
});
|
||||||
|
modelPickSelectDocListener = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (select.disabled) return;
|
||||||
|
const open = wrapper.classList.contains('open');
|
||||||
|
closeAllModelPickDropdowns();
|
||||||
|
if (!open) wrapper.classList.add('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
menuList.addEventListener('click', function (e) {
|
||||||
|
const opt = e.target.closest('.model-pick-option');
|
||||||
|
if (!opt) return;
|
||||||
|
const val = opt.getAttribute('data-value');
|
||||||
|
if (val === null || val === '') return;
|
||||||
|
if (select.value !== val) {
|
||||||
|
select.value = val;
|
||||||
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
wrapper.classList.remove('open');
|
||||||
|
syncModelPickDropdown(selectId);
|
||||||
|
});
|
||||||
|
|
||||||
|
syncModelPickDropdown(selectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initModelListControls() {
|
||||||
|
const providerEl = document.getElementById('openai-provider');
|
||||||
|
if (providerEl && !providerEl.dataset.modelListBound) {
|
||||||
|
providerEl.dataset.modelListBound = '1';
|
||||||
|
providerEl.addEventListener('change', syncModelListFetchButtons);
|
||||||
|
}
|
||||||
|
const visionProv = document.getElementById('vision-provider');
|
||||||
|
if (visionProv && !visionProv.dataset.modelListBound) {
|
||||||
|
visionProv.dataset.modelListBound = '1';
|
||||||
|
visionProv.addEventListener('change', syncModelListFetchButtons);
|
||||||
|
}
|
||||||
|
bindModelSelect('openai');
|
||||||
|
bindModelSelect('vision');
|
||||||
|
syncModelListFetchButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelSelectIds(scope) {
|
||||||
|
if (scope === 'vision') {
|
||||||
|
return { selectId: 'vision-model-select', inputId: 'vision-model' };
|
||||||
|
}
|
||||||
|
return { selectId: 'openai-model-select', inputId: 'openai-model' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindModelSelect(scope) {
|
||||||
|
const { selectId, inputId } = modelSelectIds(scope);
|
||||||
|
const select = document.getElementById(selectId);
|
||||||
|
if (!select || select.dataset.bound) return;
|
||||||
|
select.dataset.bound = '1';
|
||||||
|
enhanceModelPickSelect(selectId);
|
||||||
|
select.addEventListener('change', function () {
|
||||||
|
if (!select.value) return;
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
if (input) input.value = select.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModelListCredentials(scope) {
|
||||||
|
if (scope === 'vision') {
|
||||||
|
const vp = (document.getElementById('vision-provider')?.value || '').trim();
|
||||||
|
const provider = vp || document.getElementById('openai-provider')?.value || 'openai';
|
||||||
|
const baseUrl = (document.getElementById('vision-base-url')?.value || '').trim()
|
||||||
|
|| (document.getElementById('openai-base-url')?.value || '').trim();
|
||||||
|
const apiKey = (document.getElementById('vision-api-key')?.value || '').trim()
|
||||||
|
|| (document.getElementById('openai-api-key')?.value || '').trim();
|
||||||
|
return { provider, base_url: baseUrl, api_key: apiKey };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
provider: document.getElementById('openai-provider')?.value || 'openai',
|
||||||
|
base_url: (document.getElementById('openai-base-url')?.value || '').trim(),
|
||||||
|
api_key: (document.getElementById('openai-api-key')?.value || '').trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncModelListFetchButtons() {
|
||||||
|
const tFn = typeof window.t === 'function' ? window.t : (k) => k;
|
||||||
|
const openaiProv = document.getElementById('openai-provider')?.value || 'openai';
|
||||||
|
const openaiBtn = document.getElementById('fetch-openai-models-btn');
|
||||||
|
const openaiHint = document.getElementById('fetch-openai-models-hint');
|
||||||
|
const openaiSelect = document.getElementById('openai-model-select');
|
||||||
|
const isClaudeOpenai = openaiProv === 'claude';
|
||||||
|
if (openaiBtn) {
|
||||||
|
openaiBtn.style.display = isClaudeOpenai ? 'none' : '';
|
||||||
|
}
|
||||||
|
if (openaiSelect && isClaudeOpenai) {
|
||||||
|
openaiSelect.style.display = 'none';
|
||||||
|
const openaiWrap = modelPickSelectMap['openai-model-select'];
|
||||||
|
if (openaiWrap) openaiWrap.wrapper.style.display = 'none';
|
||||||
|
} else if (openaiSelect && !isClaudeOpenai) {
|
||||||
|
syncModelPickDropdown('openai-model-select');
|
||||||
|
}
|
||||||
|
if (openaiHint) {
|
||||||
|
if (isClaudeOpenai) {
|
||||||
|
openaiHint.textContent = tFn('settingsBasic.modelsListClaudeHint');
|
||||||
|
openaiHint.style.display = '';
|
||||||
|
} else {
|
||||||
|
openaiHint.textContent = '';
|
||||||
|
openaiHint.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vp = (document.getElementById('vision-provider')?.value || '').trim();
|
||||||
|
const visionEffectiveProv = vp || openaiProv;
|
||||||
|
const visionBtn = document.getElementById('fetch-vision-models-btn');
|
||||||
|
const visionHint = document.getElementById('fetch-vision-models-hint');
|
||||||
|
const visionSelect = document.getElementById('vision-model-select');
|
||||||
|
const isClaudeVision = visionEffectiveProv === 'claude';
|
||||||
|
if (visionBtn) {
|
||||||
|
visionBtn.style.display = isClaudeVision ? 'none' : '';
|
||||||
|
}
|
||||||
|
if (visionSelect && isClaudeVision) {
|
||||||
|
visionSelect.style.display = 'none';
|
||||||
|
const visionWrap = modelPickSelectMap['vision-model-select'];
|
||||||
|
if (visionWrap) visionWrap.wrapper.style.display = 'none';
|
||||||
|
} else if (visionSelect && !isClaudeVision) {
|
||||||
|
syncModelPickDropdown('vision-model-select');
|
||||||
|
}
|
||||||
|
if (visionHint) {
|
||||||
|
if (isClaudeVision) {
|
||||||
|
visionHint.textContent = tFn('settingsBasic.modelsListClaudeHint');
|
||||||
|
visionHint.style.display = '';
|
||||||
|
} else {
|
||||||
|
visionHint.textContent = '';
|
||||||
|
visionHint.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateModelSelect(scope, models, currentValue) {
|
||||||
|
const { selectId, inputId } = modelSelectIds(scope);
|
||||||
|
const select = document.getElementById(selectId);
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
if (!select) return;
|
||||||
|
const tFn = typeof window.t === 'function' ? window.t : (k) => k;
|
||||||
|
select.innerHTML = '';
|
||||||
|
const placeholder = document.createElement('option');
|
||||||
|
placeholder.value = '';
|
||||||
|
placeholder.disabled = true;
|
||||||
|
placeholder.textContent = tFn('settingsBasic.modelsListSelectPlaceholder');
|
||||||
|
select.appendChild(placeholder);
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
const addOption = (id) => {
|
||||||
|
const val = (id || '').trim();
|
||||||
|
if (!val || seen.has(val)) return;
|
||||||
|
seen.add(val);
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = val;
|
||||||
|
opt.textContent = val;
|
||||||
|
select.appendChild(opt);
|
||||||
|
};
|
||||||
|
(models || []).forEach(addOption);
|
||||||
|
const cur = (currentValue || (input && input.value) || '').trim();
|
||||||
|
if (cur && seen.has(cur)) {
|
||||||
|
select.value = cur;
|
||||||
|
} else {
|
||||||
|
select.value = '';
|
||||||
|
}
|
||||||
|
enhanceModelPickSelect(selectId);
|
||||||
|
syncModelPickDropdown(selectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchModelList(scope) {
|
||||||
|
const tFn = typeof window.t === 'function' ? window.t : (k) => k;
|
||||||
|
const creds = resolveModelListCredentials(scope);
|
||||||
|
const btnId = scope === 'vision' ? 'fetch-vision-models-btn' : 'fetch-openai-models-btn';
|
||||||
|
const resultId = scope === 'vision' ? 'fetch-vision-models-result' : 'fetch-openai-models-result';
|
||||||
|
const inputId = scope === 'vision' ? 'vision-model' : 'openai-model';
|
||||||
|
const btn = document.getElementById(btnId);
|
||||||
|
const resultEl = document.getElementById(resultId);
|
||||||
|
const inputEl = document.getElementById(inputId);
|
||||||
|
|
||||||
|
if (creds.provider === 'claude') {
|
||||||
|
if (resultEl) {
|
||||||
|
resultEl.textContent = tFn('settingsBasic.modelsListClaudeHint');
|
||||||
|
resultEl.style.color = 'var(--text-muted, #718096)';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!creds.api_key) {
|
||||||
|
if (resultEl) {
|
||||||
|
resultEl.textContent = tFn('settingsBasic.modelsListNeedApiKey');
|
||||||
|
resultEl.style.color = 'var(--error-color, #e53e3e)';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btn) {
|
||||||
|
btn.style.pointerEvents = 'none';
|
||||||
|
btn.style.opacity = '0.5';
|
||||||
|
}
|
||||||
|
if (resultEl) {
|
||||||
|
resultEl.textContent = tFn('settingsBasic.modelsListFetching');
|
||||||
|
resultEl.style.color = 'var(--text-muted, #718096)';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch('/api/config/list-models', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(creds)
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.error || '请求失败');
|
||||||
|
}
|
||||||
|
if (!result.success) {
|
||||||
|
if (resultEl) {
|
||||||
|
resultEl.textContent = (result.supported === false
|
||||||
|
? tFn('settingsBasic.modelsListClaudeHint')
|
||||||
|
: tFn('settingsBasic.modelsListFailed')) + ': ' + (result.error || '');
|
||||||
|
resultEl.style.color = 'var(--error-color, #e53e3e)';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentValue = inputEl ? inputEl.value.trim() : '';
|
||||||
|
populateModelSelect(scope, result.models || [], currentValue);
|
||||||
|
if (resultEl) {
|
||||||
|
const count = result.count != null ? result.count : (result.models || []).length;
|
||||||
|
resultEl.textContent = tFn('settingsBasic.modelsListSuccess').replace('{count}', String(count));
|
||||||
|
resultEl.style.color = 'var(--success-color, #38a169)';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (resultEl) {
|
||||||
|
resultEl.textContent = tFn('settingsBasic.modelsListFailed') + ': ' + error.message;
|
||||||
|
resultEl.style.color = 'var(--error-color, #e53e3e)';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (btn) {
|
||||||
|
btn.style.pointerEvents = '';
|
||||||
|
btn.style.opacity = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-26
@@ -83,6 +83,21 @@ function batchQueueAllowsSubtaskMutation(queue) {
|
|||||||
return queue.status === 'pending' || queue.status === 'paused' || queue.status === 'completed' || queue.status === 'cancelled';
|
return queue.status === 'pending' || queue.status === 'paused' || queue.status === 'completed' || queue.status === 'cancelled';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 是否允许对指定子任务发起单条执行(与后端 queueAllowsSingleTaskRunLocked 对齐) */
|
||||||
|
function batchQueueCanRunSingleTask(queue, task) {
|
||||||
|
if (!queue || !task) return false;
|
||||||
|
if (task.status === 'running') return false;
|
||||||
|
if (queue.status === 'running') return false;
|
||||||
|
return queue.status === 'pending' || queue.status === 'paused' || queue.status === 'completed' || queue.status === 'cancelled';
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchQueueRunSingleTaskDisabledReason(queue, task) {
|
||||||
|
if (!queue || !task) return _t('tasks.runSingleTaskUnavailable');
|
||||||
|
if (task.status === 'running') return _t('tasks.runSingleTaskUnavailableSelf');
|
||||||
|
if (queue.status === 'running') return _t('tasks.runSingleTaskUnavailableQueue');
|
||||||
|
return _t('tasks.runSingleTaskUnavailable');
|
||||||
|
}
|
||||||
|
|
||||||
// HTML转义函数(如果未定义)
|
// HTML转义函数(如果未定义)
|
||||||
if (typeof escapeHtml === 'undefined') {
|
if (typeof escapeHtml === 'undefined') {
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
@@ -1497,6 +1512,8 @@ async function showBatchQueueDetail(queueId) {
|
|||||||
${queue.tasks.map((task, index) => {
|
${queue.tasks.map((task, index) => {
|
||||||
const taskStatus = taskStatusMap[task.status] || { text: task.status, class: 'batch-task-status-unknown' };
|
const taskStatus = taskStatusMap[task.status] || { text: task.status, class: 'batch-task-status-unknown' };
|
||||||
const canEdit = allowSubtaskMutation && task.status !== 'running';
|
const canEdit = allowSubtaskMutation && task.status !== 'running';
|
||||||
|
const canRunSingle = batchQueueCanRunSingleTask(queue, task);
|
||||||
|
const runSingleUnavailableTitle = escapeHtml(batchQueueRunSingleTaskDisabledReason(queue, task));
|
||||||
const taskMessageEscaped = escapeHtml(task.message).replace(/'/g, "'").replace(/"/g, """).replace(/\n/g, "\\n");
|
const taskMessageEscaped = escapeHtml(task.message).replace(/'/g, "'").replace(/"/g, """).replace(/\n/g, "\\n");
|
||||||
return `
|
return `
|
||||||
<div class="batch-task-item ${task.status === 'running' ? 'batch-task-item-active' : ''}" data-queue-id="${queue.id}" data-task-id="${task.id}" data-task-message="${taskMessageEscaped}">
|
<div class="batch-task-item ${task.status === 'running' ? 'batch-task-item-active' : ''}" data-queue-id="${queue.id}" data-task-id="${task.id}" data-task-message="${taskMessageEscaped}">
|
||||||
@@ -1504,10 +1521,10 @@ async function showBatchQueueDetail(queueId) {
|
|||||||
<span class="batch-task-index">#${index + 1}</span>
|
<span class="batch-task-index">#${index + 1}</span>
|
||||||
<span class="batch-task-status ${taskStatus.class}">${taskStatus.text}</span>
|
<span class="batch-task-status ${taskStatus.class}">${taskStatus.text}</span>
|
||||||
<span class="batch-task-message" title="${escapeHtml(task.message)}">${escapeHtml(task.message)}</span>
|
<span class="batch-task-message" title="${escapeHtml(task.message)}">${escapeHtml(task.message)}</span>
|
||||||
|
<button class="btn-secondary btn-small batch-task-run-btn" ${canRunSingle ? `onclick="runSingleBatchTask('${queue.id}', '${task.id}'); event.stopPropagation();"` : `disabled title="${runSingleUnavailableTitle}"`}>` + _t('tasks.runSingleTask') + `</button>
|
||||||
|
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewBatchTaskConversation('${task.conversationId}'); event.stopPropagation();">` + _t('tasks.viewConversation') + `</button>` : ''}
|
||||||
${canEdit ? `<button class="btn-secondary btn-small batch-task-edit-btn" onclick="editBatchTaskFromElement(this); event.stopPropagation();">` + _t('common.edit') + `</button>` : ''}
|
${canEdit ? `<button class="btn-secondary btn-small batch-task-edit-btn" onclick="editBatchTaskFromElement(this); event.stopPropagation();">` + _t('common.edit') + `</button>` : ''}
|
||||||
${canEdit ? `<button class="btn-secondary btn-small btn-danger batch-task-delete-btn" onclick="deleteBatchTaskFromElement(this); event.stopPropagation();">` + _t('common.delete') + `</button>` : ''}
|
${canEdit ? `<button class="btn-secondary btn-small btn-danger batch-task-delete-btn" onclick="deleteBatchTaskFromElement(this); event.stopPropagation();">` + _t('common.delete') + `</button>` : ''}
|
||||||
${allowSubtaskMutation && task.status === 'failed' ? `<button class="btn-secondary btn-small" onclick="retryBatchTask('${queue.id}', '${task.id}'); event.stopPropagation();">` + _t('tasks.retryTask') + `</button>` : ''}
|
|
||||||
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewBatchTaskConversation('${task.conversationId}'); event.stopPropagation();">` + _t('tasks.viewConversation') + `</button>` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
${task.startedAt ? `<div class="batch-task-time">` + _t('batchQueueDetailModal.startLabel') + `: ${new Date(task.startedAt).toLocaleString()}</div>` : ''}
|
${task.startedAt ? `<div class="batch-task-time">` + _t('batchQueueDetailModal.startLabel') + `: ${new Date(task.startedAt).toLocaleString()}</div>` : ''}
|
||||||
${task.completedAt ? `<div class="batch-task-time">` + _t('batchQueueDetailModal.completeLabel') + `: ${new Date(task.completedAt).toLocaleString()}</div>` : ''}
|
${task.completedAt ? `<div class="batch-task-time">` + _t('batchQueueDetailModal.completeLabel') + `: ${new Date(task.completedAt).toLocaleString()}</div>` : ''}
|
||||||
@@ -2270,38 +2287,25 @@ async function saveInlineAgentMode() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 重试失败任务 ---
|
// --- 单条执行 ---
|
||||||
async function retryBatchTask(queueId, taskId) {
|
async function runSingleBatchTask(queueId, taskId) {
|
||||||
if (!queueId || !taskId) return;
|
if (!queueId || !taskId) return;
|
||||||
|
if (!confirm(_t('tasks.confirmRunSingleTask'))) return;
|
||||||
try {
|
try {
|
||||||
// 获取任务消息
|
const response = await apiFetch(`/api/batch-tasks/${queueId}/tasks/${taskId}/run`, {
|
||||||
const detailResp = await apiFetch(`/api/batch-tasks/${queueId}`);
|
|
||||||
if (!detailResp.ok) throw new Error(_t('tasks.getQueueDetailFailed'));
|
|
||||||
const detail = await detailResp.json();
|
|
||||||
const task = detail.queue.tasks.find(t => t.id === taskId);
|
|
||||||
if (!task) throw new Error(_t('tasks.taskNotFound') || 'Task not found');
|
|
||||||
const message = task.message;
|
|
||||||
|
|
||||||
// 先添加新任务(pending),再删除旧任务 — 避免先删后加失败导致任务丢失
|
|
||||||
const addResp = await apiFetch(`/api/batch-tasks/${queueId}/tasks`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ message }),
|
|
||||||
});
|
});
|
||||||
if (!addResp.ok) {
|
const result = await response.json().catch(() => ({}));
|
||||||
const r = await addResp.json().catch(() => ({}));
|
if (!response.ok) {
|
||||||
throw new Error(r.error || _t('tasks.addTaskFailed'));
|
throw new Error(result.error || _t('tasks.runSingleTaskFailed'));
|
||||||
}
|
}
|
||||||
// 新任务添加成功后才删除旧任务
|
if (result.autoStarted === false && result.message) {
|
||||||
const delResp = await apiFetch(`/api/batch-tasks/${queueId}/tasks/${taskId}`, { method: 'DELETE' });
|
alert(result.message);
|
||||||
if (!delResp.ok) {
|
|
||||||
// 删除失败不阻塞(新任务已添加,旧任务保留也不影响)
|
|
||||||
console.warn('删除旧任务失败,但新任务已添加');
|
|
||||||
}
|
}
|
||||||
showBatchQueueDetail(queueId);
|
showBatchQueueDetail(queueId);
|
||||||
refreshBatchQueues();
|
refreshBatchQueues();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('重试任务失败:', e);
|
console.error('单条执行失败:', e);
|
||||||
alert(e.message);
|
alert(e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2437,7 +2441,7 @@ window.startInlineEditRole = startInlineEditRole;
|
|||||||
window.saveInlineRole = saveInlineRole;
|
window.saveInlineRole = saveInlineRole;
|
||||||
window.startInlineEditAgentMode = startInlineEditAgentMode;
|
window.startInlineEditAgentMode = startInlineEditAgentMode;
|
||||||
window.saveInlineAgentMode = saveInlineAgentMode;
|
window.saveInlineAgentMode = saveInlineAgentMode;
|
||||||
window.retryBatchTask = retryBatchTask;
|
window.runSingleBatchTask = runSingleBatchTask;
|
||||||
window.startInlineEditSchedule = startInlineEditSchedule;
|
window.startInlineEditSchedule = startInlineEditSchedule;
|
||||||
window.toggleInlineScheduleCron = toggleInlineScheduleCron;
|
window.toggleInlineScheduleCron = toggleInlineScheduleCron;
|
||||||
window.saveInlineSchedule = saveInlineSchedule;
|
window.saveInlineSchedule = saveInlineSchedule;
|
||||||
|
|||||||
+67
-13
@@ -808,16 +808,49 @@
|
|||||||
<div class="recent-conversations-section">
|
<div class="recent-conversations-section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<span class="section-title" data-i18n="chat.recentConversations">最近对话</span>
|
<span class="section-title" data-i18n="chat.recentConversations">最近对话</span>
|
||||||
<button class="batch-manage-btn" onclick="showBatchManageModal()" data-i18n="chat.batchManage" data-i18n-attr="title" data-i18n-skip-text="true" title="批量管理">
|
<div class="section-header-actions">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<div class="conversation-sort-dropdown" id="conversation-sort-dropdown">
|
||||||
<line x1="3" y1="12" x2="21" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
<button type="button" class="conversation-sort-btn" id="conversation-sort-btn" onclick="toggleConversationSortMenu(event)" aria-haspopup="menu" aria-expanded="false" aria-controls="conversation-sort-menu" data-i18n="chat.sortConversations" data-i18n-attr="title" data-i18n-skip-text="true" title="排序">
|
||||||
<line x1="3" y1="6" x2="21" y2="6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
<line x1="3" y1="18" x2="21" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
<path d="M3 6h18M7 12h10M10 18h4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
<circle cx="8" cy="6" r="1" fill="currentColor"/>
|
</svg>
|
||||||
<circle cx="8" cy="12" r="1" fill="currentColor"/>
|
</button>
|
||||||
<circle cx="8" cy="18" r="1" fill="currentColor"/>
|
<div class="conversation-sort-menu" id="conversation-sort-menu" role="menu" hidden>
|
||||||
</svg>
|
<button type="button" class="conversation-sort-option" role="menuitemradio" data-sort="created_at" onclick="setConversationSortBy('created_at')">
|
||||||
</button>
|
<span class="conversation-sort-option-icon" aria-hidden="true">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="3" y="5" width="18" height="16" rx="2.5" stroke="currentColor" stroke-width="1.75"/>
|
||||||
|
<path d="M3 10h18" stroke="currentColor" stroke-width="1.75"/>
|
||||||
|
<path d="M8 3v3M16 3v3" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
|
||||||
|
<circle cx="12" cy="15" r="1.75" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="conversation-sort-option-label" data-i18n="chat.sortByCreatedAt">创建时间</span>
|
||||||
|
<span class="conversation-sort-option-check" aria-hidden="true">✓</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="conversation-sort-option" role="menuitemradio" data-sort="updated_at" onclick="setConversationSortBy('updated_at')">
|
||||||
|
<span class="conversation-sort-option-icon" aria-hidden="true">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="8" stroke="currentColor" stroke-width="1.75"/>
|
||||||
|
<path d="M12 8v4.5l3 2" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="conversation-sort-option-label" data-i18n="chat.sortByUpdatedAt">更新时间</span>
|
||||||
|
<span class="conversation-sort-option-check" aria-hidden="true">✓</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="batch-manage-btn" onclick="showBatchManageModal()" data-i18n="chat.batchManage" data-i18n-attr="title" data-i18n-skip-text="true" title="批量管理">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<circle cx="8" cy="6" r="1" fill="currentColor"/>
|
||||||
|
<circle cx="8" cy="12" r="1" fill="currentColor"/>
|
||||||
|
<circle cx="8" cy="18" r="1" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="conversations-list" class="conversations-list"></div>
|
<div id="conversations-list" class="conversations-list"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2035,12 +2068,17 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2 data-i18n="c2.sessions.title">会话管理</h2>
|
<h2 data-i18n="c2.sessions.title">会话管理</h2>
|
||||||
<div class="page-header-actions">
|
<div class="page-header-actions">
|
||||||
|
<button type="button" class="btn-danger" id="c2-sessions-batch-delete" disabled onclick="C2.deleteSelectedSessions()"><span data-i18n="c2.sessions.batchDelete">批量删除</span></button>
|
||||||
|
<button type="button" class="btn-secondary" id="c2-sessions-delete-filtered" 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>
|
<button class="btn-secondary" onclick="C2.loadSessions()"><span data-i18n="common.refresh">刷新</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-content" style="padding:0;">
|
<div class="page-content" style="padding:0;">
|
||||||
<div class="c2-session-layout">
|
<div class="c2-session-layout">
|
||||||
<div id="c2-session-list" class="c2-session-sidebar"></div>
|
<div class="c2-session-sidebar-wrap">
|
||||||
|
<div id="c2-session-toolbar" class="c2-sessions-toolbar"></div>
|
||||||
|
<div id="c2-session-list" class="c2-session-sidebar"></div>
|
||||||
|
</div>
|
||||||
<div id="c2-session-main" class="c2-session-main"></div>
|
<div id="c2-session-main" class="c2-session-main"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2408,7 +2446,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="openai-model"><span data-i18n="settingsBasic.model">模型</span> <span style="color: red;">*</span></label>
|
<label for="openai-model"><span data-i18n="settingsBasic.model">模型</span> <span style="color: red;">*</span></label>
|
||||||
<input type="text" id="openai-model" data-i18n="settingsBasic.modelPlaceholder" data-i18n-attr="placeholder" placeholder="gpt-4" required />
|
<div class="model-pick-row">
|
||||||
|
<input type="text" id="openai-model" class="model-pick-input" data-i18n="settingsBasic.modelPlaceholder" data-i18n-attr="placeholder" placeholder="gpt-4" required />
|
||||||
|
<select id="openai-model-select" class="model-pick-native" style="display: none;" title="" aria-hidden="true" tabindex="-1">
|
||||||
|
<option value="" disabled data-i18n="settingsBasic.modelsListSelectPlaceholder">请选择模型</option>
|
||||||
|
</select>
|
||||||
|
<a href="javascript:void(0)" id="fetch-openai-models-btn" class="model-pick-fetch-link" onclick="fetchModelList('openai')" data-i18n="settingsBasic.fetchModels">获取列表</a>
|
||||||
|
</div>
|
||||||
|
<small id="fetch-openai-models-hint" class="form-hint" style="display: none; font-size: 0.75rem; margin-top: 4px;"></small>
|
||||||
|
<span id="fetch-openai-models-result" style="font-size: 0.75rem; margin-top: 2px; display: block;"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="openai-max-total-tokens"><span data-i18n="settingsBasic.maxTotalTokens">最大上下文 Token 数</span></label>
|
<label for="openai-max-total-tokens"><span data-i18n="settingsBasic.maxTotalTokens">最大上下文 Token 数</span></label>
|
||||||
@@ -2486,7 +2532,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="vision-model"><span data-i18n="settingsBasic.visionModel">视觉模型</span> <span style="color: red;">*</span></label>
|
<label for="vision-model"><span data-i18n="settingsBasic.visionModel">视觉模型</span> <span style="color: red;">*</span></label>
|
||||||
<input type="text" id="vision-model" data-i18n="settingsBasic.visionModelPlaceholder" data-i18n-attr="placeholder" placeholder="qwen-vl-max" />
|
<div class="model-pick-row">
|
||||||
|
<input type="text" id="vision-model" class="model-pick-input" data-i18n="settingsBasic.visionModelPlaceholder" data-i18n-attr="placeholder" placeholder="qwen-vl-max" />
|
||||||
|
<select id="vision-model-select" class="model-pick-native" style="display: none;" aria-hidden="true" tabindex="-1">
|
||||||
|
<option value="" disabled data-i18n="settingsBasic.modelsListSelectPlaceholder">请选择模型</option>
|
||||||
|
</select>
|
||||||
|
<a href="javascript:void(0)" id="fetch-vision-models-btn" class="model-pick-fetch-link" onclick="fetchModelList('vision')" data-i18n="settingsBasic.fetchModels">获取列表</a>
|
||||||
|
</div>
|
||||||
|
<small id="fetch-vision-models-hint" class="form-hint" style="display: none; font-size: 0.75rem; margin-top: 4px;"></small>
|
||||||
|
<span id="fetch-vision-models-result" style="font-size: 0.75rem; margin-top: 2px; display: block;"></span>
|
||||||
</div>
|
</div>
|
||||||
<details style="margin-top: 8px;">
|
<details style="margin-top: 8px;">
|
||||||
<summary style="cursor: pointer; font-size: 0.875rem; color: var(--accent-color, #3182ce);" data-i18n="settingsBasic.visionAdvanced">高级:预处理与限制</summary>
|
<summary style="cursor: pointer; font-size: 0.875rem; color: var(--accent-color, #3182ce);" data-i18n="settingsBasic.visionAdvanced">高级:预处理与限制</summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user