mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-19 04:20:19 +02:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24390db100 | |||
| c000fe5195 | |||
| 0b4a11d01a | |||
| d433e44a7d | |||
| 7de51fe0ea | |||
| a354cf97e5 | |||
| c180f07c7e | |||
| 15730d3ef4 | |||
| b7fa18b6d4 |
+1
-1
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+393
-1
@@ -5851,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;
|
||||||
@@ -10892,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;
|
||||||
@@ -13557,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",
|
||||||
|
|||||||
@@ -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": "已暂停",
|
||||||
|
|||||||
+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();
|
||||||
|
|
||||||
|
|||||||
+184
-1
@@ -1577,6 +1577,179 @@ function syncVisionFormEnabled() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
function initModelListControls() {
|
||||||
const providerEl = document.getElementById('openai-provider');
|
const providerEl = document.getElementById('openai-provider');
|
||||||
if (providerEl && !providerEl.dataset.modelListBound) {
|
if (providerEl && !providerEl.dataset.modelListBound) {
|
||||||
@@ -1605,6 +1778,7 @@ function bindModelSelect(scope) {
|
|||||||
const select = document.getElementById(selectId);
|
const select = document.getElementById(selectId);
|
||||||
if (!select || select.dataset.bound) return;
|
if (!select || select.dataset.bound) return;
|
||||||
select.dataset.bound = '1';
|
select.dataset.bound = '1';
|
||||||
|
enhanceModelPickSelect(selectId);
|
||||||
select.addEventListener('change', function () {
|
select.addEventListener('change', function () {
|
||||||
if (!select.value) return;
|
if (!select.value) return;
|
||||||
const input = document.getElementById(inputId);
|
const input = document.getElementById(inputId);
|
||||||
@@ -1641,6 +1815,10 @@ function syncModelListFetchButtons() {
|
|||||||
}
|
}
|
||||||
if (openaiSelect && isClaudeOpenai) {
|
if (openaiSelect && isClaudeOpenai) {
|
||||||
openaiSelect.style.display = 'none';
|
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 (openaiHint) {
|
||||||
if (isClaudeOpenai) {
|
if (isClaudeOpenai) {
|
||||||
@@ -1663,6 +1841,10 @@ function syncModelListFetchButtons() {
|
|||||||
}
|
}
|
||||||
if (visionSelect && isClaudeVision) {
|
if (visionSelect && isClaudeVision) {
|
||||||
visionSelect.style.display = 'none';
|
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 (visionHint) {
|
||||||
if (isClaudeVision) {
|
if (isClaudeVision) {
|
||||||
@@ -1705,7 +1887,8 @@ function populateModelSelect(scope, models, currentValue) {
|
|||||||
} else {
|
} else {
|
||||||
select.value = '';
|
select.value = '';
|
||||||
}
|
}
|
||||||
select.style.display = select.options.length > 1 ? '' : 'none';
|
enhanceModelPickSelect(selectId);
|
||||||
|
syncModelPickDropdown(selectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchModelList(scope) {
|
async function fetchModelList(scope) {
|
||||||
|
|||||||
+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;
|
||||||
|
|||||||
+51
-18
@@ -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>
|
||||||
@@ -2413,12 +2446,12 @@
|
|||||||
</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>
|
||||||
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">
|
<div class="model-pick-row">
|
||||||
<input type="text" id="openai-model" data-i18n="settingsBasic.modelPlaceholder" data-i18n-attr="placeholder" placeholder="gpt-4" required style="flex: 1; min-width: 140px;" />
|
<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-select" style="display: none; min-width: 160px; max-width: 240px;" title="">
|
<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>
|
<option value="" disabled data-i18n="settingsBasic.modelsListSelectPlaceholder">请选择模型</option>
|
||||||
</select>
|
</select>
|
||||||
<a href="javascript:void(0)" id="fetch-openai-models-btn" onclick="fetchModelList('openai')" style="font-size: 0.8125rem; color: var(--accent-color, #3182ce); text-decoration: none; cursor: pointer; user-select: none; white-space: nowrap;" data-i18n="settingsBasic.fetchModels">获取列表</a>
|
<a href="javascript:void(0)" id="fetch-openai-models-btn" class="model-pick-fetch-link" onclick="fetchModelList('openai')" data-i18n="settingsBasic.fetchModels">获取列表</a>
|
||||||
</div>
|
</div>
|
||||||
<small id="fetch-openai-models-hint" class="form-hint" style="display: none; font-size: 0.75rem; margin-top: 4px;"></small>
|
<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>
|
<span id="fetch-openai-models-result" style="font-size: 0.75rem; margin-top: 2px; display: block;"></span>
|
||||||
@@ -2499,12 +2532,12 @@
|
|||||||
</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>
|
||||||
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">
|
<div class="model-pick-row">
|
||||||
<input type="text" id="vision-model" data-i18n="settingsBasic.visionModelPlaceholder" data-i18n-attr="placeholder" placeholder="qwen-vl-max" style="flex: 1; min-width: 140px;" />
|
<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-select" style="display: none; min-width: 160px; max-width: 240px;">
|
<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>
|
<option value="" disabled data-i18n="settingsBasic.modelsListSelectPlaceholder">请选择模型</option>
|
||||||
</select>
|
</select>
|
||||||
<a href="javascript:void(0)" id="fetch-vision-models-btn" onclick="fetchModelList('vision')" style="font-size: 0.8125rem; color: var(--accent-color, #3182ce); text-decoration: none; cursor: pointer; user-select: none; white-space: nowrap;" data-i18n="settingsBasic.fetchModels">获取列表</a>
|
<a href="javascript:void(0)" id="fetch-vision-models-btn" class="model-pick-fetch-link" onclick="fetchModelList('vision')" data-i18n="settingsBasic.fetchModels">获取列表</a>
|
||||||
</div>
|
</div>
|
||||||
<small id="fetch-vision-models-hint" class="form-hint" style="display: none; font-size: 0.75rem; margin-top: 4px;"></small>
|
<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>
|
<span id="fetch-vision-models-result" style="font-size: 0.75rem; margin-top: 2px; display: block;"></span>
|
||||||
|
|||||||
Reference in New Issue
Block a user