Version: 0.9.50.dev.260428
后端: 1. 工具执行结果协议升级为结构化 ToolExecutionResult——execute/tool_runtime、ToolRegistry、stream extra 与 timeline 持久化统一改为透传 observation_text / summary / argument_view / result_view,不再只回写纯文本结果;context_tools、upsert_task_class 与旧 schedule/web 工具通过兼容包装接入新协议 2. 日程写工具注册继续收口——place / move / swap / batch_move / unplace / queue_apply_head_move 从 registry 内联实现下沉为独立 handler,降低注册表内参数解析与业务逻辑混写 3. 工具结果展示基础能力补齐——新增 execution_result / schedule_operation_handlers 公共件,为日程操作结果、参数本地化展示、blocked/failed/done 状态统一建模 前端: 4. AssistantPanel 接入结构化工具卡片渲染——新增 ToolCardRenderer,tool_call / tool_result 支持 argument_view / result_view 展示;schedule_completed 恢复为时间线内的占位卡片块,避免排程卡片脱离原消息顺序 5. 时间线类型与渲染收敛——schedule_agent.ts 补齐 ToolView 协议,AssistantPanel 改为按块渲染 tool / schedule_card / business_card,并移除旧 demo/prototype 路由与页面,收束正式面板代码路径 仓库: 6. AGENTS.md 新增协作约束——禁止擅自回滚、覆盖或删除用户/其他代理产生的工作区改动
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
13. 写完代码后,如果输入输出格式明确、逻辑可验证(如数据转换函数、解析函数、工具层操作),必须编写单元测试验证正确性。跑完之后删除测试文件(`*_test.go`),禁止把测试文件长期留在项目中。
|
||||
14. 当 Claude Code 帮助操作 git 提交时,commit message 中禁止出现与 Claude 协同相关的描述(如 Co-Authored-By 等),只保留项目本身的内容。
|
||||
15. 实现任何 Eino 新功能之前,必须先阅读 Eino 官方文档并确认对应能力的推荐接入方式与参数语义,禁止在未查文档的情况下直接编码。
|
||||
16. 禁止擅自回滚、覆盖、删除工作区内由用户或其他代理产生的改动;若发现无关改动影响当前任务,必须先说明风险并征得明确同意后再处理。
|
||||
|
||||
## 注释规范(强制)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ func appendToolCallResultHistory(
|
||||
conversationContext *newagentmodel.ConversationContext,
|
||||
toolName string,
|
||||
args map[string]any,
|
||||
result string,
|
||||
result newagenttools.ToolExecutionResult,
|
||||
) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
@@ -50,7 +50,7 @@ func appendToolCallResultHistory(
|
||||
})
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Tool,
|
||||
Content: result,
|
||||
Content: result.ObservationText,
|
||||
ToolCallID: toolCallID,
|
||||
ToolName: toolName,
|
||||
})
|
||||
@@ -98,16 +98,9 @@ func executeToolCall(
|
||||
return fmt.Errorf("连续 %d 次调用临时禁用工具,终止执行: %s",
|
||||
flowState.ConsecutiveCorrections, toolName)
|
||||
}
|
||||
blockedResult := buildTemporarilyDisabledToolResult(toolName)
|
||||
_ = emitter.EmitToolCallResult(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
toolName,
|
||||
"blocked",
|
||||
blockedResult,
|
||||
buildToolArgumentsPreviewCN(toolCall.Arguments),
|
||||
false,
|
||||
)
|
||||
blockedText := buildTemporarilyDisabledToolResult(toolName)
|
||||
blockedResult := newagenttools.BlockedResult(toolName, toolCall.Arguments, blockedText, "tool_temporarily_disabled", blockedText)
|
||||
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, blockedResult, toolCall.Arguments)
|
||||
appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, blockedResult)
|
||||
newagentshared.AppendLLMCorrectionWithHint(
|
||||
conversationContext,
|
||||
@@ -165,16 +158,9 @@ func executeToolCall(
|
||||
}
|
||||
|
||||
if shouldForceFeasibilityNegotiation(flowState, registry, toolName) {
|
||||
blockedResult := buildInfeasibleBlockedResult(flowState)
|
||||
_ = emitter.EmitToolCallResult(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
toolName,
|
||||
"blocked",
|
||||
blockedResult,
|
||||
buildToolArgumentsPreviewCN(toolCall.Arguments),
|
||||
false,
|
||||
)
|
||||
blockedText := buildInfeasibleBlockedResult(flowState)
|
||||
blockedResult := newagenttools.BlockedResult(toolName, toolCall.Arguments, blockedText, "health_negotiation_required", blockedText)
|
||||
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, blockedResult, toolCall.Arguments)
|
||||
appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, blockedResult)
|
||||
return nil
|
||||
}
|
||||
@@ -187,9 +173,10 @@ func executeToolCall(
|
||||
toolCall.Arguments["_user_id"] = flowState.UserID
|
||||
}
|
||||
result := registry.Execute(scheduleState, toolName, toolCall.Arguments)
|
||||
updateHealthSnapshotV2(flowState, toolName, result)
|
||||
updateTaskClassUpsertSnapshot(flowState, toolName, result)
|
||||
updateActiveToolDomainSnapshot(flowState, toolName, result)
|
||||
result = newagenttools.EnsureToolResultDefaults(result, toolCall.Arguments)
|
||||
updateHealthSnapshotV2(flowState, toolName, result.ObservationText)
|
||||
updateTaskClassUpsertSnapshot(flowState, toolName, result.ObservationText)
|
||||
updateActiveToolDomainSnapshot(flowState, toolName, result.ObservationText)
|
||||
afterDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||
log.Printf(
|
||||
"[DEBUG] execute tool chat=%s round=%d tool=%s args=%s before=%s after=%s result_preview=%.200s",
|
||||
@@ -199,17 +186,9 @@ func executeToolCall(
|
||||
marshalArgsForDebug(toolCall.Arguments),
|
||||
beforeDigest,
|
||||
afterDigest,
|
||||
flattenForLog(result),
|
||||
)
|
||||
_ = emitter.EmitToolCallResult(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
toolName,
|
||||
resolveToolEventResultStatus(result),
|
||||
buildToolEventResultSummary(result),
|
||||
buildToolArgumentsPreviewCN(toolCall.Arguments),
|
||||
false,
|
||||
flattenForLog(result.ObservationText),
|
||||
)
|
||||
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, result, toolCall.Arguments)
|
||||
|
||||
appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, result)
|
||||
|
||||
@@ -301,32 +280,18 @@ func executePendingTool(
|
||||
}
|
||||
flowState := runtimeState.EnsureCommonState()
|
||||
if registry.IsToolTemporarilyDisabled(pending.ToolName) {
|
||||
blockedResult := buildTemporarilyDisabledToolResult(pending.ToolName)
|
||||
_ = emitter.EmitToolCallResult(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
pending.ToolName,
|
||||
"blocked",
|
||||
blockedResult,
|
||||
buildToolArgumentsPreviewCN(args),
|
||||
false,
|
||||
)
|
||||
blockedText := buildTemporarilyDisabledToolResult(pending.ToolName)
|
||||
blockedResult := newagenttools.BlockedResult(pending.ToolName, args, blockedText, "tool_temporarily_disabled", blockedText)
|
||||
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, blockedResult, args)
|
||||
appendToolCallResultHistory(conversationContext, pending.ToolName, args, blockedResult)
|
||||
runtimeState.PendingConfirmTool = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
if shouldForceFeasibilityNegotiation(flowState, registry, pending.ToolName) {
|
||||
blockedResult := buildInfeasibleBlockedResult(flowState)
|
||||
_ = emitter.EmitToolCallResult(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
pending.ToolName,
|
||||
"blocked",
|
||||
blockedResult,
|
||||
buildToolArgumentsPreviewCN(args),
|
||||
false,
|
||||
)
|
||||
blockedText := buildInfeasibleBlockedResult(flowState)
|
||||
blockedResult := newagenttools.BlockedResult(pending.ToolName, args, blockedText, "health_negotiation_required", blockedText)
|
||||
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, blockedResult, args)
|
||||
appendToolCallResultHistory(conversationContext, pending.ToolName, args, blockedResult)
|
||||
runtimeState.PendingConfirmTool = nil
|
||||
return nil
|
||||
@@ -340,9 +305,10 @@ func executePendingTool(
|
||||
args["_user_id"] = flowState.UserID
|
||||
}
|
||||
result := registry.Execute(scheduleState, pending.ToolName, args)
|
||||
updateHealthSnapshotV2(flowState, pending.ToolName, result)
|
||||
updateTaskClassUpsertSnapshot(flowState, pending.ToolName, result)
|
||||
updateActiveToolDomainSnapshot(flowState, pending.ToolName, result)
|
||||
result = newagenttools.EnsureToolResultDefaults(result, args)
|
||||
updateHealthSnapshotV2(flowState, pending.ToolName, result.ObservationText)
|
||||
updateTaskClassUpsertSnapshot(flowState, pending.ToolName, result.ObservationText)
|
||||
updateActiveToolDomainSnapshot(flowState, pending.ToolName, result.ObservationText)
|
||||
afterDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||
log.Printf(
|
||||
"[DEBUG] execute pending tool chat=%s round=%d tool=%s args=%s before=%s after=%s result_preview=%.200s",
|
||||
@@ -352,17 +318,9 @@ func executePendingTool(
|
||||
marshalArgsForDebug(args),
|
||||
beforeDigest,
|
||||
afterDigest,
|
||||
flattenForLog(result),
|
||||
)
|
||||
_ = emitter.EmitToolCallResult(
|
||||
executeStatusBlockID,
|
||||
executeStageName,
|
||||
pending.ToolName,
|
||||
resolveToolEventResultStatus(result),
|
||||
buildToolEventResultSummary(result),
|
||||
buildToolArgumentsPreviewCN(args),
|
||||
false,
|
||||
flattenForLog(result.ObservationText),
|
||||
)
|
||||
emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, result, args)
|
||||
|
||||
appendToolCallResultHistory(conversationContext, pending.ToolName, args, result)
|
||||
|
||||
@@ -434,3 +392,27 @@ func buildExecuteNormalizedSpeakTail(streamed, normalized string) string {
|
||||
}
|
||||
return normalized[len(streamed):]
|
||||
}
|
||||
|
||||
func emitToolCallResultEvent(
|
||||
emitter *newagentstream.ChunkEmitter,
|
||||
blockID string,
|
||||
stage string,
|
||||
result newagenttools.ToolExecutionResult,
|
||||
args map[string]any,
|
||||
) {
|
||||
if emitter == nil {
|
||||
return
|
||||
}
|
||||
result = newagenttools.EnsureToolResultDefaults(result, args)
|
||||
_ = emitter.EmitToolCallResult(
|
||||
blockID,
|
||||
stage,
|
||||
result.Tool,
|
||||
result.Status,
|
||||
result.Summary,
|
||||
result.ArgumentsPreview,
|
||||
newagenttools.ToolArgumentViewToMap(result.ArgumentView),
|
||||
newagenttools.ToolDisplayViewToMap(result.ResultView),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -233,12 +233,31 @@ func (e *ChunkEmitter) EmitToolCallStart(blockID, stage, toolName, summary, argu
|
||||
// 协议约束:
|
||||
// 1. status 由调用方明确传入(如 done/blocked/failed);
|
||||
// 2. 结果事件只走 extra.tool,不回写 reasoning_content。
|
||||
func (e *ChunkEmitter) EmitToolCallResult(blockID, stage, toolName, status, summary, argumentsPreview string, includeRole bool) error {
|
||||
func (e *ChunkEmitter) EmitToolCallResult(
|
||||
blockID string,
|
||||
stage string,
|
||||
toolName string,
|
||||
status string,
|
||||
summary string,
|
||||
argumentsPreview string,
|
||||
argumentView map[string]any,
|
||||
resultView map[string]any,
|
||||
includeRole bool,
|
||||
) error {
|
||||
if e == nil || e.emit == nil {
|
||||
return nil
|
||||
}
|
||||
_ = includeRole
|
||||
return e.emitExtraOnly(NewToolResultExtra(blockID, stage, toolName, status, summary, argumentsPreview))
|
||||
return e.emitExtraOnly(NewToolResultExtra(
|
||||
blockID,
|
||||
stage,
|
||||
toolName,
|
||||
status,
|
||||
summary,
|
||||
argumentsPreview,
|
||||
argumentView,
|
||||
resultView,
|
||||
))
|
||||
}
|
||||
|
||||
// emitExtraOnly 仅输出结构化 extra 事件,不附带 content/reasoning。
|
||||
|
||||
@@ -91,6 +91,8 @@ type StreamToolExtra struct {
|
||||
Status string `json:"status,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
ArgumentsPreview string `json:"arguments_preview,omitempty"`
|
||||
ArgumentView map[string]any `json:"argument_view,omitempty"`
|
||||
ResultView map[string]any `json:"result_view,omitempty"`
|
||||
}
|
||||
|
||||
// StreamConfirmExtra 表示一次待确认事件的展示摘要。
|
||||
@@ -234,7 +236,16 @@ func NewToolCallExtra(blockID, stage, toolName, status, summary, argumentsPrevie
|
||||
}
|
||||
|
||||
// NewToolResultExtra 创建“工具结果”事件的 extra。
|
||||
func NewToolResultExtra(blockID, stage, toolName, status, summary, argumentsPreview string) *OpenAIChunkExtra {
|
||||
func NewToolResultExtra(
|
||||
blockID string,
|
||||
stage string,
|
||||
toolName string,
|
||||
status string,
|
||||
summary string,
|
||||
argumentsPreview string,
|
||||
argumentView map[string]any,
|
||||
resultView map[string]any,
|
||||
) *OpenAIChunkExtra {
|
||||
return &OpenAIChunkExtra{
|
||||
Kind: StreamExtraKindToolResult,
|
||||
BlockID: blockID,
|
||||
@@ -245,6 +256,8 @@ func NewToolResultExtra(blockID, stage, toolName, status, summary, argumentsPrev
|
||||
Status: status,
|
||||
Summary: summary,
|
||||
ArgumentsPreview: argumentsPreview,
|
||||
ArgumentView: argumentView,
|
||||
ResultView: resultView,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,20 +36,20 @@ type contextToolsRemoveResult struct {
|
||||
// 职责边界:
|
||||
// 1. 仅负责校验 domain/mode/packs 并返回结构化结果,不直接修改流程状态;
|
||||
// 2. 真正的“激活态写回”由 execute 节点根据工具结果回写 CommonState;
|
||||
// 3. schedule 支持可选 packs;taskclass 目前不支持可选 packs。
|
||||
// 3. schedule 支持可选 packs,taskclass 当前不支持可选 packs。
|
||||
func NewContextToolsAddHandler() ToolHandler {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
|
||||
_ = state
|
||||
|
||||
domain := NormalizeToolDomain(readContextToolString(args["domain"]))
|
||||
if domain == "" {
|
||||
return marshalContextToolsAddResult(contextToolsAddResult{
|
||||
return LegacyResult(ToolNameContextToolsAdd, args, marshalContextToolsAddResult(contextToolsAddResult{
|
||||
Tool: ToolNameContextToolsAdd,
|
||||
Success: false,
|
||||
Action: "reject",
|
||||
Error: "参数非法:domain 仅支持 schedule/taskclass",
|
||||
ErrorCode: "invalid_domain",
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
mode := strings.ToLower(strings.TrimSpace(readContextToolString(args["mode"])))
|
||||
@@ -57,27 +57,27 @@ func NewContextToolsAddHandler() ToolHandler {
|
||||
mode = "replace"
|
||||
}
|
||||
if mode != "replace" && mode != "merge" {
|
||||
return marshalContextToolsAddResult(contextToolsAddResult{
|
||||
return LegacyResult(ToolNameContextToolsAdd, args, marshalContextToolsAddResult(contextToolsAddResult{
|
||||
Tool: ToolNameContextToolsAdd,
|
||||
Success: false,
|
||||
Action: "reject",
|
||||
Domain: domain,
|
||||
Error: "参数非法:mode 仅支持 replace/merge",
|
||||
ErrorCode: "invalid_mode",
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
packsRaw := readContextToolStringSlice(args["packs"])
|
||||
packs, errCode, errText := validateContextPacks(domain, packsRaw, false)
|
||||
if errCode != "" {
|
||||
return marshalContextToolsAddResult(contextToolsAddResult{
|
||||
return LegacyResult(ToolNameContextToolsAdd, args, marshalContextToolsAddResult(contextToolsAddResult{
|
||||
Tool: ToolNameContextToolsAdd,
|
||||
Success: false,
|
||||
Action: "reject",
|
||||
Domain: domain,
|
||||
Error: errText,
|
||||
ErrorCode: errCode,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
// schedule 未显式传 packs 时,默认启用最小可用包(mutation + analyze)。
|
||||
@@ -85,7 +85,7 @@ func NewContextToolsAddHandler() ToolHandler {
|
||||
packs = ResolveEffectiveToolPacks(domain, nil)
|
||||
}
|
||||
|
||||
return marshalContextToolsAddResult(contextToolsAddResult{
|
||||
return LegacyResult(ToolNameContextToolsAdd, args, marshalContextToolsAddResult(contextToolsAddResult{
|
||||
Tool: ToolNameContextToolsAdd,
|
||||
Success: true,
|
||||
Action: "activate",
|
||||
@@ -93,7 +93,7 @@ func NewContextToolsAddHandler() ToolHandler {
|
||||
Packs: packs,
|
||||
Mode: mode,
|
||||
Message: "已激活工具域,可继续调用对应业务工具。",
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,10 +101,10 @@ func NewContextToolsAddHandler() ToolHandler {
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅解析 domain/all/packs 语义并返回结构化结果,不直接触碰上下文存储;
|
||||
// 2. all=true 表示清空动态区业务工具;domain+packs 表示移除该域下指定二级包;
|
||||
// 2. all=true 表示清空动态区业务工具,domain+packs 表示移除该域下指定二级包;
|
||||
// 3. 仅 schedule 支持按 packs 移除,且 core 不允许显式移除。
|
||||
func NewContextToolsRemoveHandler() ToolHandler {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
|
||||
_ = state
|
||||
|
||||
all := readContextToolBool(args["all"])
|
||||
@@ -116,56 +116,56 @@ func NewContextToolsRemoveHandler() ToolHandler {
|
||||
all = true
|
||||
}
|
||||
if all {
|
||||
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
|
||||
return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{
|
||||
Tool: ToolNameContextToolsRemove,
|
||||
Success: true,
|
||||
Action: "clear_all",
|
||||
All: true,
|
||||
Message: "已移除全部业务工具域,仅保留上下文管理工具。",
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
domain := NormalizeToolDomain(domainRaw)
|
||||
if domain == "" {
|
||||
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
|
||||
return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{
|
||||
Tool: ToolNameContextToolsRemove,
|
||||
Success: false,
|
||||
Action: "reject",
|
||||
Error: "参数非法:需提供 domain=schedule/taskclass 或 all=true",
|
||||
ErrorCode: "invalid_domain",
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
packs, errCode, errText := validateContextPacks(domain, packsRaw, true)
|
||||
if errCode != "" {
|
||||
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
|
||||
return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{
|
||||
Tool: ToolNameContextToolsRemove,
|
||||
Success: false,
|
||||
Action: "reject",
|
||||
Domain: domain,
|
||||
Error: errText,
|
||||
ErrorCode: errCode,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
if len(packs) > 0 {
|
||||
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
|
||||
return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{
|
||||
Tool: ToolNameContextToolsRemove,
|
||||
Success: true,
|
||||
Action: "deactivate_packs",
|
||||
Domain: domain,
|
||||
Packs: packs,
|
||||
Message: "已移除指定工具包。",
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
|
||||
return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{
|
||||
Tool: ToolNameContextToolsRemove,
|
||||
Success: true,
|
||||
Action: "deactivate",
|
||||
Domain: domain,
|
||||
Message: "已移除指定工具域。",
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
773
backend/newAgent/tools/execution_result.go
Normal file
773
backend/newAgent/tools/execution_result.go
Normal file
@@ -0,0 +1,773 @@
|
||||
package newagenttools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
|
||||
)
|
||||
|
||||
const (
|
||||
ToolStatusDone = "done"
|
||||
ToolStatusFailed = "failed"
|
||||
ToolStatusBlocked = "blocked"
|
||||
)
|
||||
|
||||
// ToolDisplayView 描述工具结果的结构化展示视图。
|
||||
type ToolDisplayView struct {
|
||||
ViewType string `json:"view_type,omitempty"`
|
||||
Version int `json:"version,omitempty"`
|
||||
Collapsed map[string]any `json:"collapsed,omitempty"`
|
||||
Expanded map[string]any `json:"expanded,omitempty"`
|
||||
}
|
||||
|
||||
// ToolArgumentView 描述工具参数的结构化展示视图。
|
||||
type ToolArgumentView struct {
|
||||
ViewType string `json:"view_type,omitempty"`
|
||||
Version int `json:"version,omitempty"`
|
||||
Collapsed map[string]any `json:"collapsed,omitempty"`
|
||||
Expanded map[string]any `json:"expanded,omitempty"`
|
||||
}
|
||||
|
||||
// ToolExecutionResult 是 newAgent 工具主接口的统一结果结构。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责承载 execute、SSE、timeline 所需的最小公共字段;
|
||||
// 2. 负责保留 ObservationText,保证第一阶段 LLM 观察文本不变;
|
||||
// 3. 不负责具体工具业务语义,工具语义由各工具 handler 决定。
|
||||
type ToolExecutionResult struct {
|
||||
Tool string `json:"tool,omitempty"`
|
||||
Status string `json:"status,omitempty"` // done / failed / blocked
|
||||
Success bool `json:"success"`
|
||||
ObservationText string `json:"observation_text,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
ArgumentsPreview string `json:"arguments_preview,omitempty"`
|
||||
ArgumentView *ToolArgumentView `json:"argument_view,omitempty"`
|
||||
ResultView *ToolDisplayView `json:"result_view,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
// LegacyResult 用于未做专属卡片的工具兜底。
|
||||
func LegacyResult(toolName string, args map[string]any, oldText string) ToolExecutionResult {
|
||||
return LegacyResultWithState(toolName, args, nil, oldText)
|
||||
}
|
||||
|
||||
// LegacyResultWithState 在 LegacyResult 基础上支持读取 ScheduleState 补齐中文参数展示。
|
||||
func LegacyResultWithState(toolName string, args map[string]any, state *schedule.ScheduleState, oldText string) ToolExecutionResult {
|
||||
status, success := resolveToolStatusAndSuccess(oldText)
|
||||
errorCode, errorMessage := extractToolErrorInfo(oldText, status)
|
||||
tool := strings.TrimSpace(toolName)
|
||||
toolLabel := resolveToolLabelCN(tool)
|
||||
|
||||
argumentView := buildLocalizedArgumentView(tool, args, state)
|
||||
argumentsPreview := readArgumentSummary(argumentView)
|
||||
|
||||
result := ToolExecutionResult{
|
||||
Tool: tool,
|
||||
Status: status,
|
||||
Success: success,
|
||||
ObservationText: oldText,
|
||||
Summary: buildToolSummary(oldText),
|
||||
ArgumentsPreview: argumentsPreview,
|
||||
ArgumentView: argumentView,
|
||||
ResultView: &ToolDisplayView{
|
||||
ViewType: "legacy_text",
|
||||
Version: 1,
|
||||
Collapsed: map[string]any{
|
||||
"title": buildLegacyTitle(toolLabel, status),
|
||||
"status": status,
|
||||
"status_label": resolveToolStatusLabelCN(status),
|
||||
"tool": tool,
|
||||
"tool_label": toolLabel,
|
||||
"has_output": strings.TrimSpace(oldText) != "",
|
||||
},
|
||||
Expanded: map[string]any{
|
||||
"raw_text_label": "原始结果",
|
||||
"raw_text": oldText,
|
||||
},
|
||||
},
|
||||
ErrorCode: errorCode,
|
||||
ErrorMessage: errorMessage,
|
||||
}
|
||||
return ensureToolResultDefaults(result, args)
|
||||
}
|
||||
|
||||
// BlockedResult 构造被拦截类结果,供 execute 链路统一复用。
|
||||
func BlockedResult(toolName string, args map[string]any, observationText, errorCode, errorMessage string) ToolExecutionResult {
|
||||
result := LegacyResult(toolName, args, observationText)
|
||||
result.Status = ToolStatusBlocked
|
||||
result.Success = false
|
||||
result.ErrorCode = strings.TrimSpace(errorCode)
|
||||
result.ErrorMessage = strings.TrimSpace(errorMessage)
|
||||
if result.ResultView != nil {
|
||||
if result.ResultView.Collapsed == nil {
|
||||
result.ResultView.Collapsed = make(map[string]any)
|
||||
}
|
||||
result.ResultView.Collapsed["status"] = ToolStatusBlocked
|
||||
result.ResultView.Collapsed["status_label"] = resolveToolStatusLabelCN(ToolStatusBlocked)
|
||||
result.ResultView.Collapsed["title"] = buildLegacyTitle(resolveToolLabelCN(toolName), ToolStatusBlocked)
|
||||
}
|
||||
return ensureToolResultDefaults(result, args)
|
||||
}
|
||||
|
||||
// EnsureToolResultDefaults 负责兜底补齐 execute 侧依赖字段,避免空值扩散。
|
||||
func EnsureToolResultDefaults(result ToolExecutionResult, args map[string]any) ToolExecutionResult {
|
||||
return ensureToolResultDefaults(result, args)
|
||||
}
|
||||
|
||||
func ensureToolResultDefaults(result ToolExecutionResult, args map[string]any) ToolExecutionResult {
|
||||
if strings.TrimSpace(result.Tool) == "" {
|
||||
result.Tool = "unknown_tool"
|
||||
}
|
||||
if strings.TrimSpace(result.Status) == "" {
|
||||
if result.Success {
|
||||
result.Status = ToolStatusDone
|
||||
} else {
|
||||
result.Status = ToolStatusFailed
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(result.Summary) == "" {
|
||||
result.Summary = buildToolSummary(result.ObservationText)
|
||||
}
|
||||
if result.ArgumentView == nil {
|
||||
result.ArgumentView = buildLocalizedArgumentView(result.Tool, args, nil)
|
||||
}
|
||||
if strings.TrimSpace(result.ArgumentsPreview) == "" {
|
||||
result.ArgumentsPreview = readArgumentSummary(result.ArgumentView)
|
||||
}
|
||||
if strings.TrimSpace(result.ArgumentsPreview) == "" && len(args) > 0 {
|
||||
result.ArgumentsPreview = fmt.Sprintf("共 %d 个参数", len(args))
|
||||
}
|
||||
if result.ResultView == nil {
|
||||
result.ResultView = &ToolDisplayView{
|
||||
ViewType: "legacy_text",
|
||||
Version: 1,
|
||||
Collapsed: map[string]any{
|
||||
"title": buildLegacyTitle(resolveToolLabelCN(result.Tool), result.Status),
|
||||
"status": result.Status,
|
||||
"status_label": resolveToolStatusLabelCN(result.Status),
|
||||
"tool": strings.TrimSpace(result.Tool),
|
||||
"tool_label": resolveToolLabelCN(result.Tool),
|
||||
"has_output": strings.TrimSpace(result.ObservationText) != "",
|
||||
},
|
||||
Expanded: map[string]any{
|
||||
"raw_text_label": "原始结果",
|
||||
"raw_text": result.ObservationText,
|
||||
},
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ToolArgumentViewToMap 把参数视图转换成 stream/timeline 可直接落库的 map。
|
||||
func ToolArgumentViewToMap(view *ToolArgumentView) map[string]any {
|
||||
if view == nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]any{
|
||||
"view_type": strings.TrimSpace(view.ViewType),
|
||||
"version": view.Version,
|
||||
}
|
||||
if len(view.Collapsed) > 0 {
|
||||
out["collapsed"] = cloneAnyMap(view.Collapsed)
|
||||
}
|
||||
if len(view.Expanded) > 0 {
|
||||
out["expanded"] = cloneAnyMap(view.Expanded)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ToolDisplayViewToMap 把结果视图转换成 stream/timeline 可直接落库的 map。
|
||||
func ToolDisplayViewToMap(view *ToolDisplayView) map[string]any {
|
||||
if view == nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]any{
|
||||
"view_type": strings.TrimSpace(view.ViewType),
|
||||
"version": view.Version,
|
||||
}
|
||||
if len(view.Collapsed) > 0 {
|
||||
out["collapsed"] = cloneAnyMap(view.Collapsed)
|
||||
}
|
||||
if len(view.Expanded) > 0 {
|
||||
out["expanded"] = cloneAnyMap(view.Expanded)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveToolStatusAndSuccess(observation string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(observation)
|
||||
if trimmed == "" {
|
||||
return ToolStatusDone, true
|
||||
}
|
||||
|
||||
// 1. 优先解析 JSON 结构字段,避免依赖自然语言文本。
|
||||
// 2. 若 JSON 明确给出 success/status/error,则以结构字段为准。
|
||||
// 3. 仅在无法结构化解析时,回退关键词兜底。
|
||||
if payload, ok := parseObservationJSON(trimmed); ok {
|
||||
if statusText, ok := readStringFromMap(payload, "status"); ok {
|
||||
status := normalizeToolStatus(statusText)
|
||||
if status != "" {
|
||||
return status, status == ToolStatusDone
|
||||
}
|
||||
}
|
||||
if blocked, ok := readBoolFromMap(payload, "blocked"); ok && blocked {
|
||||
return ToolStatusBlocked, false
|
||||
}
|
||||
if success, ok := readBoolFromMap(payload, "success"); ok {
|
||||
if success {
|
||||
return ToolStatusDone, true
|
||||
}
|
||||
return ToolStatusFailed, false
|
||||
}
|
||||
if errText, ok := readStringFromMap(payload, "error", "err"); ok && strings.TrimSpace(errText) != "" {
|
||||
return ToolStatusFailed, false
|
||||
}
|
||||
}
|
||||
|
||||
lower := strings.ToLower(trimmed)
|
||||
if strings.Contains(trimmed, "阻断") || strings.Contains(trimmed, "禁用") || strings.Contains(lower, "blocked") {
|
||||
return ToolStatusBlocked, false
|
||||
}
|
||||
if strings.Contains(trimmed, "失败") || strings.Contains(lower, "failed") || strings.Contains(lower, "error") {
|
||||
return ToolStatusFailed, false
|
||||
}
|
||||
return ToolStatusDone, true
|
||||
}
|
||||
|
||||
func normalizeToolStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case ToolStatusDone:
|
||||
return ToolStatusDone
|
||||
case ToolStatusFailed:
|
||||
return ToolStatusFailed
|
||||
case ToolStatusBlocked:
|
||||
return ToolStatusBlocked
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func extractToolErrorInfo(observation string, status string) (string, string) {
|
||||
trimmed := strings.TrimSpace(observation)
|
||||
if trimmed == "" || status == ToolStatusDone {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
if payload, ok := parseObservationJSON(trimmed); ok {
|
||||
errorCode, _ := readStringFromMap(payload, "error_code", "code")
|
||||
errorMessage, _ := readStringFromMap(payload, "error", "err", "message", "reason")
|
||||
if strings.TrimSpace(errorCode) == "" && status == ToolStatusBlocked {
|
||||
errorCode = "blocked"
|
||||
}
|
||||
if strings.TrimSpace(errorMessage) != "" {
|
||||
return strings.TrimSpace(errorCode), strings.TrimSpace(errorMessage)
|
||||
}
|
||||
if status == ToolStatusBlocked {
|
||||
return strings.TrimSpace(errorCode), "工具被策略阻断"
|
||||
}
|
||||
return strings.TrimSpace(errorCode), ""
|
||||
}
|
||||
|
||||
if status == ToolStatusBlocked {
|
||||
return "blocked", trimmed
|
||||
}
|
||||
return "", trimmed
|
||||
}
|
||||
|
||||
func buildToolSummary(observation string) string {
|
||||
trimmed := strings.TrimSpace(observation)
|
||||
if trimmed == "" {
|
||||
return "工具已执行完成。"
|
||||
}
|
||||
|
||||
if payload, ok := parseObservationJSON(trimmed); ok {
|
||||
if errText, ok := readStringFromMap(payload, "error", "err", "message"); ok && strings.TrimSpace(errText) != "" {
|
||||
return truncateSummary(fmt.Sprintf("执行失败:%s", strings.TrimSpace(errText)))
|
||||
}
|
||||
if message, ok := readStringFromMap(payload, "result", "summary", "reason", "message"); ok && strings.TrimSpace(message) != "" {
|
||||
return truncateSummary(strings.TrimSpace(message))
|
||||
}
|
||||
if success, ok := readBoolFromMap(payload, "success"); ok && success {
|
||||
return "工具执行成功。"
|
||||
}
|
||||
}
|
||||
|
||||
flat := strings.Join(strings.Fields(trimmed), " ")
|
||||
return truncateSummary(flat)
|
||||
}
|
||||
|
||||
func truncateSummary(text string) string {
|
||||
runes := []rune(strings.TrimSpace(text))
|
||||
if len(runes) <= 48 {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:48]) + "..."
|
||||
}
|
||||
|
||||
func buildLegacyTitle(toolLabel string, status string) string {
|
||||
switch normalizeToolStatus(status) {
|
||||
case ToolStatusDone:
|
||||
return fmt.Sprintf("%s已完成", strings.TrimSpace(toolLabel))
|
||||
case ToolStatusBlocked:
|
||||
return fmt.Sprintf("%s已阻断", strings.TrimSpace(toolLabel))
|
||||
default:
|
||||
return fmt.Sprintf("%s失败", strings.TrimSpace(toolLabel))
|
||||
}
|
||||
}
|
||||
|
||||
func resolveToolStatusLabelCN(status string) string {
|
||||
switch normalizeToolStatus(status) {
|
||||
case ToolStatusDone:
|
||||
return "已完成"
|
||||
case ToolStatusBlocked:
|
||||
return "已阻断"
|
||||
default:
|
||||
return "失败"
|
||||
}
|
||||
}
|
||||
|
||||
func resolveToolLabelCN(toolName string) string {
|
||||
name := strings.TrimSpace(toolName)
|
||||
switch name {
|
||||
case "query_available_slots":
|
||||
return "查询可用时段"
|
||||
case "query_target_tasks":
|
||||
return "查询目标任务"
|
||||
case "queue_status":
|
||||
return "查看队列状态"
|
||||
case "queue_pop_head":
|
||||
return "获取队首任务"
|
||||
case "queue_skip_head":
|
||||
return "跳过队首任务"
|
||||
case "analyze_health":
|
||||
return "综合体检"
|
||||
case "analyze_rhythm":
|
||||
return "分析学习节奏"
|
||||
case "web_search":
|
||||
return "网页搜索"
|
||||
case "web_fetch":
|
||||
return "网页抓取"
|
||||
case "upsert_task_class":
|
||||
return "写入任务类"
|
||||
case ToolNameContextToolsAdd:
|
||||
return "激活工具域"
|
||||
case ToolNameContextToolsRemove:
|
||||
return "移除工具域"
|
||||
case "move":
|
||||
return "移动任务"
|
||||
case "place":
|
||||
return "预排任务"
|
||||
case "swap":
|
||||
return "交换任务"
|
||||
case "batch_move":
|
||||
return "批量移动"
|
||||
case "unplace":
|
||||
return "移出任务"
|
||||
case "queue_apply_head_move":
|
||||
return "应用队首任务"
|
||||
case "get_overview":
|
||||
return "查看总览"
|
||||
case "query_range":
|
||||
return "查询时间范围"
|
||||
case "get_task_info":
|
||||
return "查看任务信息"
|
||||
default:
|
||||
if name == "" {
|
||||
return "工具"
|
||||
}
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
func resolveOperationLabelCN(operation string) string {
|
||||
switch strings.TrimSpace(operation) {
|
||||
case "move":
|
||||
return "移动任务"
|
||||
case "place":
|
||||
return "预排任务"
|
||||
case "swap":
|
||||
return "交换任务"
|
||||
case "batch_move":
|
||||
return "批量移动"
|
||||
case "unplace":
|
||||
return "移出任务"
|
||||
case "queue_apply_head_move":
|
||||
return "应用队首任务"
|
||||
default:
|
||||
return resolveToolLabelCN(operation)
|
||||
}
|
||||
}
|
||||
|
||||
func readArgumentSummary(view *ToolArgumentView) string {
|
||||
if view == nil || len(view.Collapsed) == 0 {
|
||||
return ""
|
||||
}
|
||||
summary, ok := view.Collapsed["summary"].(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(summary)
|
||||
}
|
||||
|
||||
func buildLocalizedArgumentView(toolName string, args map[string]any, state *schedule.ScheduleState) *ToolArgumentView {
|
||||
fields := buildArgumentFields(toolName, args, state)
|
||||
summary := buildArgumentSummary(fields)
|
||||
if summary == "" {
|
||||
summary = "无参数"
|
||||
}
|
||||
return &ToolArgumentView{
|
||||
ViewType: "tool.arguments",
|
||||
Version: 1,
|
||||
Collapsed: map[string]any{
|
||||
"summary": summary,
|
||||
"args_count": len(args),
|
||||
},
|
||||
Expanded: map[string]any{
|
||||
"args": cloneAnyMap(args),
|
||||
"fields": fields,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildArgumentFields(toolName string, args map[string]any, state *schedule.ScheduleState) []map[string]any {
|
||||
if len(args) == 0 {
|
||||
return make([]map[string]any, 0)
|
||||
}
|
||||
keys := make([]string, 0, len(args))
|
||||
for key := range args {
|
||||
if strings.TrimSpace(key) == "_user_id" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.SliceStable(keys, func(i, j int) bool {
|
||||
leftRank := argumentDisplayRank(keys[i])
|
||||
rightRank := argumentDisplayRank(keys[j])
|
||||
if leftRank != rightRank {
|
||||
return leftRank < rightRank
|
||||
}
|
||||
return keys[i] < keys[j]
|
||||
})
|
||||
|
||||
fields := make([]map[string]any, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
raw := args[key]
|
||||
label := resolveArgumentLabelCN(strings.TrimSpace(key))
|
||||
display := formatArgumentDisplay(toolName, strings.TrimSpace(key), raw, args, state)
|
||||
field := map[string]any{
|
||||
"key": key,
|
||||
"label": label,
|
||||
"value": raw,
|
||||
"display": display,
|
||||
}
|
||||
fields = append(fields, field)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func argumentDisplayRank(key string) int {
|
||||
switch strings.TrimSpace(key) {
|
||||
case "task_id", "task_a", "task_b":
|
||||
return 10
|
||||
case "day", "new_day":
|
||||
return 20
|
||||
case "slot_start", "new_slot_start":
|
||||
return 30
|
||||
case "moves":
|
||||
return 40
|
||||
case "reason":
|
||||
return 50
|
||||
default:
|
||||
return 100
|
||||
}
|
||||
}
|
||||
|
||||
func buildArgumentSummary(fields []map[string]any) string {
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
items := make([]string, 0, 2)
|
||||
for _, field := range fields {
|
||||
label, _ := field["label"].(string)
|
||||
display, _ := field["display"].(string)
|
||||
label = strings.TrimSpace(label)
|
||||
display = strings.TrimSpace(display)
|
||||
if label == "" || display == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, fmt.Sprintf("%s:%s", label, display))
|
||||
if len(items) >= 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return fmt.Sprintf("共 %d 个参数", len(fields))
|
||||
}
|
||||
if len(fields) > len(items) {
|
||||
return strings.Join(items, ",") + fmt.Sprintf(" 等 %d 项", len(fields))
|
||||
}
|
||||
return strings.Join(items, ",")
|
||||
}
|
||||
|
||||
func resolveArgumentLabelCN(key string) string {
|
||||
switch strings.TrimSpace(key) {
|
||||
case "task_id":
|
||||
return "任务"
|
||||
case "task_a":
|
||||
return "任务A"
|
||||
case "task_b":
|
||||
return "任务B"
|
||||
case "day":
|
||||
return "目标日期"
|
||||
case "new_day":
|
||||
return "目标日期"
|
||||
case "slot_start":
|
||||
return "目标时段"
|
||||
case "new_slot_start":
|
||||
return "目标时段"
|
||||
case "moves":
|
||||
return "移动列表"
|
||||
case "reason":
|
||||
return "原因"
|
||||
case "status":
|
||||
return "状态"
|
||||
case "limit":
|
||||
return "数量"
|
||||
case "query":
|
||||
return "查询内容"
|
||||
case "url":
|
||||
return "链接"
|
||||
default:
|
||||
return "参数"
|
||||
}
|
||||
}
|
||||
|
||||
func formatArgumentDisplay(
|
||||
toolName string,
|
||||
key string,
|
||||
value any,
|
||||
args map[string]any,
|
||||
state *schedule.ScheduleState,
|
||||
) string {
|
||||
switch key {
|
||||
case "task_id", "task_a", "task_b":
|
||||
if taskID, ok := toInt(value); ok {
|
||||
return resolveTaskLabelByID(state, taskID, true)
|
||||
}
|
||||
case "day", "new_day":
|
||||
if day, ok := toInt(value); ok {
|
||||
return formatDayLabelCN(day)
|
||||
}
|
||||
case "slot_start", "new_slot_start":
|
||||
if slotStart, ok := toInt(value); ok {
|
||||
slotEnd := slotStart
|
||||
if taskID, ok := toInt(args["task_id"]); ok {
|
||||
if task := stateTaskByID(state, taskID); task != nil && task.Duration > 1 {
|
||||
slotEnd = slotStart + task.Duration - 1
|
||||
}
|
||||
}
|
||||
if day, ok := toInt(args["day"]); ok {
|
||||
return fmt.Sprintf("%s%s", formatDayLabelCN(day), formatSlotRangeCN(slotStart, slotEnd))
|
||||
}
|
||||
if day, ok := toInt(args["new_day"]); ok {
|
||||
return fmt.Sprintf("%s%s", formatDayLabelCN(day), formatSlotRangeCN(slotStart, slotEnd))
|
||||
}
|
||||
return formatSlotRangeCN(slotStart, slotEnd)
|
||||
}
|
||||
case "moves":
|
||||
return formatMovesArgumentCN(value, state)
|
||||
}
|
||||
return formatAnyValueCN(value)
|
||||
}
|
||||
|
||||
func formatMovesArgumentCN(value any, state *schedule.ScheduleState) string {
|
||||
list, ok := value.([]any)
|
||||
if !ok {
|
||||
return formatAnyValueCN(value)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return "空"
|
||||
}
|
||||
parts := make([]string, 0, len(list))
|
||||
for _, item := range list {
|
||||
move, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
taskID, _ := toInt(move["task_id"])
|
||||
day, _ := toInt(move["new_day"])
|
||||
slotStart, _ := toInt(move["new_slot_start"])
|
||||
taskLabel := resolveTaskLabelByID(state, taskID, false)
|
||||
slotEnd := slotStart
|
||||
if task := stateTaskByID(state, taskID); task != nil && task.Duration > 1 {
|
||||
slotEnd = slotStart + task.Duration - 1
|
||||
}
|
||||
if day > 0 && slotStart > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%s→%s%s", taskLabel, formatDayLabelCN(day), formatSlotRangeCN(slotStart, slotEnd)))
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return fmt.Sprintf("%d 项", len(list))
|
||||
}
|
||||
if len(parts) > 3 {
|
||||
return strings.Join(parts[:3], ";") + fmt.Sprintf(" 等 %d 项", len(parts))
|
||||
}
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
func formatAnyValueCN(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
text := strings.TrimSpace(typed)
|
||||
if text == "" {
|
||||
return "空"
|
||||
}
|
||||
return text
|
||||
case int:
|
||||
return fmt.Sprintf("%d", typed)
|
||||
case int8:
|
||||
return fmt.Sprintf("%d", typed)
|
||||
case int16:
|
||||
return fmt.Sprintf("%d", typed)
|
||||
case int32:
|
||||
return fmt.Sprintf("%d", typed)
|
||||
case int64:
|
||||
return fmt.Sprintf("%d", typed)
|
||||
case float32:
|
||||
return fmt.Sprintf("%g", typed)
|
||||
case float64:
|
||||
return fmt.Sprintf("%g", typed)
|
||||
case bool:
|
||||
if typed {
|
||||
return "是"
|
||||
}
|
||||
return "否"
|
||||
case []any:
|
||||
return fmt.Sprintf("%d 项", len(typed))
|
||||
default:
|
||||
if value == nil {
|
||||
return "空"
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", value)
|
||||
}
|
||||
return strings.TrimSpace(string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func resolveTaskLabelByID(state *schedule.ScheduleState, taskID int, withID bool) string {
|
||||
if taskID <= 0 {
|
||||
return "未知任务"
|
||||
}
|
||||
task := stateTaskByID(state, taskID)
|
||||
if task == nil {
|
||||
if withID {
|
||||
return fmt.Sprintf("[%d]任务", taskID)
|
||||
}
|
||||
return fmt.Sprintf("任务%d", taskID)
|
||||
}
|
||||
name := strings.TrimSpace(task.Name)
|
||||
if name == "" {
|
||||
name = "任务"
|
||||
}
|
||||
if withID {
|
||||
return fmt.Sprintf("[%d]%s", task.StateID, name)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func stateTaskByID(state *schedule.ScheduleState, taskID int) *schedule.ScheduleTask {
|
||||
if state == nil || taskID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return state.TaskByStateID(taskID)
|
||||
}
|
||||
|
||||
func formatDayLabelCN(day int) string {
|
||||
if day <= 0 {
|
||||
return "未知日期"
|
||||
}
|
||||
return fmt.Sprintf("第%d天", day)
|
||||
}
|
||||
|
||||
func formatSlotRangeCN(start int, end int) string {
|
||||
if start <= 0 && end <= 0 {
|
||||
return "未知时段"
|
||||
}
|
||||
if end <= 0 {
|
||||
end = start
|
||||
}
|
||||
if end < start {
|
||||
end = start
|
||||
}
|
||||
return fmt.Sprintf("第%d-%d节", start, end)
|
||||
}
|
||||
|
||||
func toInt(value any) (int, bool) {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed, true
|
||||
case int8:
|
||||
return int(typed), true
|
||||
case int16:
|
||||
return int(typed), true
|
||||
case int32:
|
||||
return int(typed), true
|
||||
case int64:
|
||||
return int(typed), true
|
||||
case float32:
|
||||
return int(typed), true
|
||||
case float64:
|
||||
return int(typed), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func parseObservationJSON(text string) (map[string]any, bool) {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &payload); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return payload, true
|
||||
}
|
||||
|
||||
func readStringFromMap(payload map[string]any, keys ...string) (string, bool) {
|
||||
for _, key := range keys {
|
||||
raw, exists := payload[key]
|
||||
if !exists || raw == nil {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", raw))
|
||||
if text == "" || text == "<nil>" {
|
||||
continue
|
||||
}
|
||||
return text, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func readBoolFromMap(payload map[string]any, key string) (bool, bool) {
|
||||
raw, exists := payload[key]
|
||||
if !exists {
|
||||
return false, false
|
||||
}
|
||||
value, ok := raw.(bool)
|
||||
return value, ok
|
||||
}
|
||||
|
||||
func cloneAnyMap(input map[string]any) map[string]any {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(input))
|
||||
for k, v := range input {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -11,11 +11,12 @@ import (
|
||||
)
|
||||
|
||||
// ToolHandler 约定所有工具的统一执行签名。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责消费当前 ScheduleState 与模型传入参数;
|
||||
// 2. 返回统一 string 结果,供 execute 节点写回 observation;
|
||||
// 2. 返回 ToolExecutionResult,供 execute 节点写回 observation 与结构化事件;
|
||||
// 3. 不负责 confirm、上下文注入、轮次控制,这些由上层节点处理。
|
||||
type ToolHandler func(state *schedule.ScheduleState, args map[string]any) string
|
||||
type ToolHandler func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult
|
||||
|
||||
// ToolSchemaEntry 描述注入给模型的工具快照。
|
||||
type ToolSchemaEntry struct {
|
||||
@@ -25,6 +26,7 @@ type ToolSchemaEntry struct {
|
||||
}
|
||||
|
||||
// DefaultRegistryDeps 描述默认注册表需要的外部依赖。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只承载工具层需要的依赖注入,不承载业务状态;
|
||||
// 2. 某些依赖即便暂未使用也允许保留,避免业务层重新到处 new;
|
||||
@@ -48,6 +50,7 @@ type ToolRegistry struct {
|
||||
}
|
||||
|
||||
// temporaryDisabledTools 描述“已注册但当前阶段临时禁用”的工具。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 这些工具仍保留定义,避免 prompt / 旧链路 / 历史日志里出现悬空名字;
|
||||
// 2. execute 会在调用前统一阻断,并向模型返回纠错提示;
|
||||
@@ -84,19 +87,27 @@ func (r *ToolRegistry) Register(name, desc, schemaText string, handler ToolHandl
|
||||
}
|
||||
|
||||
// Execute 执行指定工具。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只负责找到 handler 并调用;
|
||||
// 2. 若工具临时禁用,直接返回只读失败文案,不进入 handler;
|
||||
// 3. 不负责参数 schema 级纠错,具体参数错误交由 handler 返回。
|
||||
func (r *ToolRegistry) Execute(state *schedule.ScheduleState, toolName string, args map[string]any) string {
|
||||
// 2. 工具临时禁用时直接返回 blocked 结构化结果,不进入 handler;
|
||||
// 3. 参数 schema 级纠错仍由 handler 内处理。
|
||||
func (r *ToolRegistry) Execute(state *schedule.ScheduleState, toolName string, args map[string]any) ToolExecutionResult {
|
||||
if r.IsToolTemporarilyDisabled(toolName) {
|
||||
return fmt.Sprintf("工具 %q 当前阶段已临时禁用,请优先使用 analyze_health、move、swap 等当前主链工具。", strings.TrimSpace(toolName))
|
||||
observation := fmt.Sprintf("工具 %q 当前阶段已临时禁用,请优先使用 analyze_health、move、swap 等当前主链工具。", strings.TrimSpace(toolName))
|
||||
return BlockedResult(toolName, args, observation, "tool_temporarily_disabled", observation)
|
||||
}
|
||||
handler, ok := r.handlers[toolName]
|
||||
if !ok {
|
||||
return fmt.Sprintf("工具调用失败:未知工具 %q。可用工具:%s", toolName, strings.Join(r.ToolNames(), "、"))
|
||||
observation := fmt.Sprintf("工具调用失败:未知工具 %q。可用工具:%s", toolName, strings.Join(r.ToolNames(), "、"))
|
||||
result := LegacyResult(toolName, args, observation)
|
||||
result.Status = ToolStatusFailed
|
||||
result.Success = false
|
||||
result.ErrorCode = "unknown_tool"
|
||||
result.ErrorMessage = observation
|
||||
return EnsureToolResultDefaults(result, args)
|
||||
}
|
||||
return handler(state, args)
|
||||
return EnsureToolResultDefaults(handler(state, args), args)
|
||||
}
|
||||
|
||||
// HasTool 判断工具是否已注册且当前可见。
|
||||
@@ -138,6 +149,7 @@ func (r *ToolRegistry) Schemas() []ToolSchemaEntry {
|
||||
}
|
||||
|
||||
// SchemasForActiveDomain 返回某业务域当前真正可见的工具 schema。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. context_tools_add/remove 始终保留,用于动态区协议;
|
||||
// 2. 仅当工具域已激活时,才暴露该域下可见工具;
|
||||
@@ -209,7 +221,7 @@ func (r *ToolRegistry) IsWriteTool(name string) bool {
|
||||
}
|
||||
|
||||
// IsScheduleMutationTool 判断工具是否会真实修改 ScheduleState 中的日程布局。
|
||||
// 说明:upsert_task_class 会写库,但不修改当前日程预览,因此不计入此集合。
|
||||
// upsert_task_class 会写库,但不修改当前日程预览,因此不计入此集合。
|
||||
func (r *ToolRegistry) IsScheduleMutationTool(name string) bool {
|
||||
return scheduleMutationTools[strings.TrimSpace(name)]
|
||||
}
|
||||
@@ -253,6 +265,7 @@ func NewDefaultRegistry() *ToolRegistry {
|
||||
}
|
||||
|
||||
// NewDefaultRegistryWithDeps 创建带依赖的默认注册表。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先注册上下文管理工具,保证动态区协议随时可用;
|
||||
// 2. 再注册 schedule 域的读、诊断、写工具;
|
||||
@@ -293,66 +306,66 @@ func registerScheduleReadTools(r *ToolRegistry) {
|
||||
"get_overview",
|
||||
"获取当前窗口总览:保留课程占位统计,展开任务清单。",
|
||||
`{"name":"get_overview","parameters":{}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
wrapLegacyToolHandler("get_overview", func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
_ = args
|
||||
return schedule.GetOverview(state)
|
||||
},
|
||||
}),
|
||||
)
|
||||
r.Register(
|
||||
"query_range",
|
||||
"查看某天或某时段的占用详情。day 必填,slot_start/slot_end 选填。",
|
||||
`{"name":"query_range","parameters":{"day":{"type":"int","required":true},"slot_start":{"type":"int"},"slot_end":{"type":"int"}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
wrapLegacyToolHandler("query_range", func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
day, ok := schedule.ArgsInt(args, "day")
|
||||
if !ok {
|
||||
return "查询失败:缺少必填参数 day。"
|
||||
}
|
||||
return schedule.QueryRange(state, day, schedule.ArgsIntPtr(args, "slot_start"), schedule.ArgsIntPtr(args, "slot_end"))
|
||||
},
|
||||
}),
|
||||
)
|
||||
r.Register(
|
||||
"query_available_slots",
|
||||
"查询候选空位池,适合 move 前筛落点。",
|
||||
`{"name":"query_available_slots","parameters":{"span":{"type":"int"},"duration":{"type":"int"},"limit":{"type":"int"},"allow_embed":{"type":"bool"},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"slot_type":{"type":"string"},"slot_types":{"type":"array","items":{"type":"string"}},"exclude_sections":{"type":"array","items":{"type":"int"}},"after_section":{"type":"int"},"before_section":{"type":"int"},"section_from":{"type":"int"},"section_to":{"type":"int"}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
wrapLegacyToolHandler("query_available_slots", func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.QueryAvailableSlots(state, args)
|
||||
},
|
||||
}),
|
||||
)
|
||||
r.Register(
|
||||
"query_target_tasks",
|
||||
"查询候选任务集合,可按 status/week/day/task_id/category 筛选;支持 enqueue。",
|
||||
`{"name":"query_target_tasks","parameters":{"status":{"type":"string","enum":["all","existing","suggested","pending"]},"category":{"type":"string"},"limit":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"task_ids":{"type":"array","items":{"type":"int"}},"task_id":{"type":"int"},"task_item_ids":{"type":"array","items":{"type":"int"}},"task_item_id":{"type":"int"},"enqueue":{"type":"bool"},"reset_queue":{"type":"bool"}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
wrapLegacyToolHandler("query_target_tasks", func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.QueryTargetTasks(state, args)
|
||||
},
|
||||
}),
|
||||
)
|
||||
r.Register(
|
||||
"queue_pop_head",
|
||||
"弹出并返回当前队首任务;若已有 current 则复用。",
|
||||
`{"name":"queue_pop_head","parameters":{}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
wrapLegacyToolHandler("queue_pop_head", func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.QueuePopHead(state, args)
|
||||
},
|
||||
}),
|
||||
)
|
||||
r.Register(
|
||||
"queue_status",
|
||||
"查看当前队列状态(pending/current/completed/skipped)。",
|
||||
`{"name":"queue_status","parameters":{}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
wrapLegacyToolHandler("queue_status", func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.QueueStatus(state, args)
|
||||
},
|
||||
}),
|
||||
)
|
||||
r.Register(
|
||||
"get_task_info",
|
||||
"查看单个任务详情,包括类别、状态与落位。",
|
||||
`{"name":"get_task_info","parameters":{"task_id":{"type":"int","required":true}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
wrapLegacyToolHandler("get_task_info", func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
taskID, ok := schedule.ArgsInt(args, "task_id")
|
||||
if !ok {
|
||||
return "查询失败:缺少必填参数 task_id。"
|
||||
}
|
||||
return schedule.GetTaskInfo(state, taskID)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -361,17 +374,17 @@ func registerScheduleAnalyzeTools(r *ToolRegistry) {
|
||||
"analyze_rhythm",
|
||||
"分析学习节奏与切换情况。",
|
||||
`{"name":"analyze_rhythm","parameters":{"category":{"type":"string"},"include_pending":{"type":"bool"},"detail":{"type":"string","enum":["summary","full"]},"hard_categories":{"type":"array","items":{"type":"string"}}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
wrapLegacyToolHandler("analyze_rhythm", func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.AnalyzeRhythm(state, args)
|
||||
},
|
||||
}),
|
||||
)
|
||||
r.Register(
|
||||
"analyze_health",
|
||||
"主动优化裁判入口:聚焦 rhythm/semantic_profile/tightness,判断当前是否还值得继续优化,并给出候选。",
|
||||
`{"name":"analyze_health","parameters":{"detail":{"type":"string","enum":["summary","full"]},"dimensions":{"type":"array","items":{"type":"string"}},"threshold":{"type":"string","enum":["strict","normal","relaxed"]}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
wrapLegacyToolHandler("analyze_health", func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.AnalyzeHealth(state, args)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -380,97 +393,45 @@ func registerScheduleMutationTools(r *ToolRegistry) {
|
||||
"place",
|
||||
"将一个待安排任务预排到指定位置。task_id/day/slot_start 必填。",
|
||||
`{"name":"place","parameters":{"task_id":{"type":"int","required":true},"day":{"type":"int","required":true},"slot_start":{"type":"int","required":true}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
taskID, ok := schedule.ArgsInt(args, "task_id")
|
||||
if !ok {
|
||||
return "放置失败:缺少必填参数 task_id。"
|
||||
}
|
||||
day, ok := schedule.ArgsInt(args, "day")
|
||||
if !ok {
|
||||
return "放置失败:缺少必填参数 day。"
|
||||
}
|
||||
slotStart, ok := schedule.ArgsInt(args, "slot_start")
|
||||
if !ok {
|
||||
return "放置失败:缺少必填参数 slot_start。"
|
||||
}
|
||||
return schedule.Place(state, taskID, day, slotStart)
|
||||
},
|
||||
NewPlaceToolHandler(),
|
||||
)
|
||||
r.Register(
|
||||
"move",
|
||||
"将一个已预排任务(仅 suggested)移动到新位置。task_id/new_day/new_slot_start 必填。",
|
||||
`{"name":"move","parameters":{"task_id":{"type":"int","required":true},"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
taskID, ok := schedule.ArgsInt(args, "task_id")
|
||||
if !ok {
|
||||
return "移动失败:缺少必填参数 task_id。"
|
||||
}
|
||||
newDay, ok := schedule.ArgsInt(args, "new_day")
|
||||
if !ok {
|
||||
return "移动失败:缺少必填参数 new_day。"
|
||||
}
|
||||
newSlotStart, ok := schedule.ArgsInt(args, "new_slot_start")
|
||||
if !ok {
|
||||
return "移动失败:缺少必填参数 new_slot_start。"
|
||||
}
|
||||
return schedule.Move(state, taskID, newDay, newSlotStart)
|
||||
},
|
||||
NewMoveToolHandler(),
|
||||
)
|
||||
r.Register(
|
||||
"swap",
|
||||
"交换两个已落位任务的位置。task_a/task_b 必填,且两任务时长必须一致。",
|
||||
`{"name":"swap","parameters":{"task_a":{"type":"int","required":true},"task_b":{"type":"int","required":true}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
taskA, ok := schedule.ArgsInt(args, "task_a")
|
||||
if !ok {
|
||||
return "交换失败:缺少必填参数 task_a。"
|
||||
}
|
||||
taskB, ok := schedule.ArgsInt(args, "task_b")
|
||||
if !ok {
|
||||
return "交换失败:缺少必填参数 task_b。"
|
||||
}
|
||||
return schedule.Swap(state, taskA, taskB)
|
||||
},
|
||||
NewSwapToolHandler(),
|
||||
)
|
||||
r.Register(
|
||||
"batch_move",
|
||||
"原子性批量移动多个任务。moves 必填。",
|
||||
`{"name":"batch_move","parameters":{"moves":{"type":"array","required":true,"items":{"task_id":"int","new_day":"int","new_slot_start":"int"}}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
moves, err := schedule.ArgsMoveList(args)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("批量移动失败:%s", err.Error())
|
||||
}
|
||||
return schedule.BatchMove(state, moves)
|
||||
},
|
||||
NewBatchMoveToolHandler(),
|
||||
)
|
||||
r.Register(
|
||||
"queue_apply_head_move",
|
||||
"将当前队首任务移动到指定位置并自动出队。new_day/new_slot_start 必填。",
|
||||
`{"name":"queue_apply_head_move","parameters":{"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.QueueApplyHeadMove(state, args)
|
||||
},
|
||||
NewQueueApplyHeadMoveToolHandler(),
|
||||
)
|
||||
r.Register(
|
||||
"queue_skip_head",
|
||||
"跳过当前队首任务,将其标记为 skipped。",
|
||||
`{"name":"queue_skip_head","parameters":{"reason":{"type":"string"}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
wrapLegacyToolHandler("queue_skip_head", func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.QueueSkipHead(state, args)
|
||||
},
|
||||
}),
|
||||
)
|
||||
r.Register(
|
||||
"unplace",
|
||||
"将一个已落位任务移除,恢复为待安排状态。task_id 必填。",
|
||||
`{"name":"unplace","parameters":{"task_id":{"type":"int","required":true}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
taskID, ok := schedule.ArgsInt(args, "task_id")
|
||||
if !ok {
|
||||
return "移除失败:缺少必填参数 task_id。"
|
||||
}
|
||||
return schedule.Unplace(state, taskID)
|
||||
},
|
||||
NewUnplaceToolHandler(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -491,18 +452,24 @@ func registerWebTools(r *ToolRegistry, deps DefaultRegistryDeps) {
|
||||
"web_search",
|
||||
"Web 搜索:根据 query 返回结构化检索结果。query 必填。",
|
||||
`{"name":"web_search","parameters":{"query":{"type":"string","required":true},"top_k":{"type":"int"},"domain_allow":{"type":"array","items":{"type":"string"}},"recency_days":{"type":"int"}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
wrapLegacyToolHandler("web_search", func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
_ = state
|
||||
return webSearchHandler.Handle(args)
|
||||
},
|
||||
}),
|
||||
)
|
||||
r.Register(
|
||||
"web_fetch",
|
||||
"抓取指定 URL 的正文内容并做最小清洗。url 必填。",
|
||||
`{"name":"web_fetch","parameters":{"url":{"type":"string","required":true},"max_chars":{"type":"int"}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
wrapLegacyToolHandler("web_fetch", func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
_ = state
|
||||
return webFetchHandler.Handle(args)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func wrapLegacyToolHandler(toolName string, handler func(state *schedule.ScheduleState, args map[string]any) string) ToolHandler {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
|
||||
return LegacyResultWithState(toolName, args, state, handler(state, args))
|
||||
}
|
||||
}
|
||||
|
||||
730
backend/newAgent/tools/schedule_operation_handlers.go
Normal file
730
backend/newAgent/tools/schedule_operation_handlers.go
Normal file
@@ -0,0 +1,730 @@
|
||||
package newagenttools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
|
||||
)
|
||||
|
||||
type scheduleTaskSnapshot struct {
|
||||
Exists bool
|
||||
TaskID int
|
||||
Name string
|
||||
Status string
|
||||
Slots []schedule.TaskSlot
|
||||
DayInfo map[int]schedule.DayMapping
|
||||
}
|
||||
|
||||
type scheduleQueueSnapshot struct {
|
||||
PendingCount int
|
||||
CompletedCount int
|
||||
SkippedCount int
|
||||
CurrentTaskID int
|
||||
CurrentAttempt int
|
||||
LastError string
|
||||
}
|
||||
|
||||
// NewPlaceToolHandler 返回 place 的结构化结果 handler(第一轮真实 result_view)。
|
||||
func NewPlaceToolHandler() ToolHandler {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
|
||||
taskID, ok := schedule.ArgsInt(args, "task_id")
|
||||
if !ok {
|
||||
return buildScheduleArgErrorResult("place", args, "缺少必填参数 task_id。", state)
|
||||
}
|
||||
day, ok := schedule.ArgsInt(args, "day")
|
||||
if !ok {
|
||||
return buildScheduleArgErrorResult("place", args, "缺少必填参数 day。", state)
|
||||
}
|
||||
slotStart, ok := schedule.ArgsInt(args, "slot_start")
|
||||
if !ok {
|
||||
return buildScheduleArgErrorResult("place", args, "缺少必填参数 slot_start。", state)
|
||||
}
|
||||
if state == nil {
|
||||
return buildScheduleArgErrorResult("place", args, "日程状态为空,无法执行预排。", nil)
|
||||
}
|
||||
|
||||
beforeState := state.Clone()
|
||||
observation := schedule.Place(state, taskID, day, slotStart)
|
||||
afterState := state.Clone()
|
||||
|
||||
before := snapshotTask(beforeState, taskID)
|
||||
after := snapshotTask(afterState, taskID)
|
||||
success := after.Exists && taskHasSlotAt(after, day, slotStart) && !sameSlots(before.Slots, after.Slots)
|
||||
|
||||
changes := []map[string]any{
|
||||
buildTaskChange("place", before, after),
|
||||
}
|
||||
affectedDays := collectAffectedDays(changes)
|
||||
return buildScheduleOperationResult("place", args, afterState, observation, success, affectedDays, changes, nil, "")
|
||||
}
|
||||
}
|
||||
|
||||
// NewMoveToolHandler 返回 move 的结构化结果 handler(第一轮真实 result_view)。
|
||||
func NewMoveToolHandler() ToolHandler {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
|
||||
taskID, ok := schedule.ArgsInt(args, "task_id")
|
||||
if !ok {
|
||||
return buildScheduleArgErrorResult("move", args, "缺少必填参数 task_id。", state)
|
||||
}
|
||||
newDay, ok := schedule.ArgsInt(args, "new_day")
|
||||
if !ok {
|
||||
return buildScheduleArgErrorResult("move", args, "缺少必填参数 new_day。", state)
|
||||
}
|
||||
newSlotStart, ok := schedule.ArgsInt(args, "new_slot_start")
|
||||
if !ok {
|
||||
return buildScheduleArgErrorResult("move", args, "缺少必填参数 new_slot_start。", state)
|
||||
}
|
||||
if state == nil {
|
||||
return buildScheduleArgErrorResult("move", args, "日程状态为空,无法执行移动。", nil)
|
||||
}
|
||||
|
||||
beforeState := state.Clone()
|
||||
observation := schedule.Move(state, taskID, newDay, newSlotStart)
|
||||
afterState := state.Clone()
|
||||
|
||||
before := snapshotTask(beforeState, taskID)
|
||||
after := snapshotTask(afterState, taskID)
|
||||
success := before.Exists && after.Exists && taskHasSlotAt(after, newDay, newSlotStart) && !sameSlots(before.Slots, after.Slots)
|
||||
|
||||
changes := []map[string]any{
|
||||
buildTaskChange("move", before, after),
|
||||
}
|
||||
affectedDays := collectAffectedDays(changes)
|
||||
return buildScheduleOperationResult("move", args, afterState, observation, success, affectedDays, changes, nil, "")
|
||||
}
|
||||
}
|
||||
|
||||
// NewSwapToolHandler 返回 swap 的结构化结果 handler(第一轮真实 result_view)。
|
||||
func NewSwapToolHandler() ToolHandler {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
|
||||
taskA, ok := schedule.ArgsInt(args, "task_a")
|
||||
if !ok {
|
||||
return buildScheduleArgErrorResult("swap", args, "缺少必填参数 task_a。", state)
|
||||
}
|
||||
taskB, ok := schedule.ArgsInt(args, "task_b")
|
||||
if !ok {
|
||||
return buildScheduleArgErrorResult("swap", args, "缺少必填参数 task_b。", state)
|
||||
}
|
||||
if state == nil {
|
||||
return buildScheduleArgErrorResult("swap", args, "日程状态为空,无法执行交换。", nil)
|
||||
}
|
||||
|
||||
beforeState := state.Clone()
|
||||
observation := schedule.Swap(state, taskA, taskB)
|
||||
afterState := state.Clone()
|
||||
|
||||
beforeA := snapshotTask(beforeState, taskA)
|
||||
afterA := snapshotTask(afterState, taskA)
|
||||
beforeB := snapshotTask(beforeState, taskB)
|
||||
afterB := snapshotTask(afterState, taskB)
|
||||
|
||||
success := beforeA.Exists &&
|
||||
beforeB.Exists &&
|
||||
afterA.Exists &&
|
||||
afterB.Exists &&
|
||||
sameSlots(beforeA.Slots, afterB.Slots) &&
|
||||
sameSlots(beforeB.Slots, afterA.Slots)
|
||||
|
||||
changes := []map[string]any{
|
||||
buildTaskChange("swap", beforeA, afterA),
|
||||
buildTaskChange("swap", beforeB, afterB),
|
||||
}
|
||||
affectedDays := collectAffectedDays(changes)
|
||||
return buildScheduleOperationResult("swap", args, afterState, observation, success, affectedDays, changes, nil, "")
|
||||
}
|
||||
}
|
||||
|
||||
// NewBatchMoveToolHandler 返回 batch_move 的结构化结果 handler(第一轮真实 result_view)。
|
||||
func NewBatchMoveToolHandler() ToolHandler {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
|
||||
if state == nil {
|
||||
return buildScheduleArgErrorResult("batch_move", args, "日程状态为空,无法执行批量移动。", nil)
|
||||
}
|
||||
moves, err := schedule.ArgsMoveList(args)
|
||||
if err != nil {
|
||||
return buildScheduleArgErrorResult("batch_move", args, err.Error(), state)
|
||||
}
|
||||
|
||||
beforeState := state.Clone()
|
||||
observation := schedule.BatchMove(state, moves)
|
||||
afterState := state.Clone()
|
||||
|
||||
changes := make([]map[string]any, 0, len(moves))
|
||||
success := len(moves) > 0
|
||||
for _, move := range moves {
|
||||
before := snapshotTask(beforeState, move.TaskID)
|
||||
after := snapshotTask(afterState, move.TaskID)
|
||||
changes = append(changes, buildTaskChange("batch_move", before, after))
|
||||
if !after.Exists || !taskHasSlotAt(after, move.NewDay, move.NewSlotStart) {
|
||||
success = false
|
||||
}
|
||||
}
|
||||
|
||||
affectedDays := collectAffectedDays(changes)
|
||||
return buildScheduleOperationResult("batch_move", args, afterState, observation, success, affectedDays, changes, nil, "")
|
||||
}
|
||||
}
|
||||
|
||||
// NewUnplaceToolHandler 返回 unplace 的结构化结果 handler(第一轮真实 result_view)。
|
||||
func NewUnplaceToolHandler() ToolHandler {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
|
||||
taskID, ok := schedule.ArgsInt(args, "task_id")
|
||||
if !ok {
|
||||
return buildScheduleArgErrorResult("unplace", args, "缺少必填参数 task_id。", state)
|
||||
}
|
||||
if state == nil {
|
||||
return buildScheduleArgErrorResult("unplace", args, "日程状态为空,无法执行移出。", nil)
|
||||
}
|
||||
|
||||
beforeState := state.Clone()
|
||||
observation := schedule.Unplace(state, taskID)
|
||||
afterState := state.Clone()
|
||||
|
||||
before := snapshotTask(beforeState, taskID)
|
||||
after := snapshotTask(afterState, taskID)
|
||||
success := before.Exists && len(before.Slots) > 0 && len(after.Slots) == 0 && strings.EqualFold(strings.TrimSpace(after.Status), schedule.TaskStatusPending)
|
||||
|
||||
changes := []map[string]any{
|
||||
buildTaskChange("unplace", before, after),
|
||||
}
|
||||
affectedDays := collectAffectedDays(changes)
|
||||
return buildScheduleOperationResult("unplace", args, afterState, observation, success, affectedDays, changes, nil, "")
|
||||
}
|
||||
}
|
||||
|
||||
// NewQueueApplyHeadMoveToolHandler 返回 queue_apply_head_move 的结构化结果 handler(第一轮真实 result_view)。
|
||||
func NewQueueApplyHeadMoveToolHandler() ToolHandler {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
|
||||
newDay, dayOK := schedule.ArgsInt(args, "new_day")
|
||||
newSlotStart, slotOK := schedule.ArgsInt(args, "new_slot_start")
|
||||
if state == nil {
|
||||
return buildScheduleArgErrorResult("queue_apply_head_move", args, "日程状态为空,无法执行队首任务应用。", nil)
|
||||
}
|
||||
|
||||
// 1. 执行前先记录 current 任务与队列快照,保证成功/失败都可构造稳定结构化视图。
|
||||
// 2. 再执行工具并抓取执行后快照,基于 before/after 计算差异,不依赖自然语言解析。
|
||||
// 3. 如果快照构造异常,外层仍会回退 LegacyResult,保证工具主链路不被展示层影响。
|
||||
beforeState := state.Clone()
|
||||
beforeQueue := snapshotQueue(beforeState)
|
||||
currentTaskID := 0
|
||||
if beforeState != nil && beforeState.RuntimeQueue != nil {
|
||||
currentTaskID = beforeState.RuntimeQueue.CurrentTaskID
|
||||
}
|
||||
beforeTask := snapshotTask(beforeState, currentTaskID)
|
||||
|
||||
observation := schedule.QueueApplyHeadMove(state, args)
|
||||
|
||||
afterState := state.Clone()
|
||||
afterQueue := snapshotQueue(afterState)
|
||||
afterTask := snapshotTask(afterState, currentTaskID)
|
||||
|
||||
success := false
|
||||
if payload, ok := parseObservationJSON(strings.TrimSpace(observation)); ok {
|
||||
if parsedSuccess, exists := payload["success"].(bool); exists {
|
||||
success = parsedSuccess
|
||||
}
|
||||
}
|
||||
if !success {
|
||||
success = currentTaskID > 0 &&
|
||||
(afterQueue.CompletedCount > beforeQueue.CompletedCount) &&
|
||||
(afterQueue.CurrentTaskID != currentTaskID)
|
||||
if dayOK && slotOK && success {
|
||||
success = taskHasSlotAt(afterTask, newDay, newSlotStart)
|
||||
}
|
||||
}
|
||||
|
||||
changes := []map[string]any{
|
||||
buildTaskChange("queue_apply_head_move", beforeTask, afterTask),
|
||||
}
|
||||
affectedDays := collectAffectedDays(changes)
|
||||
queueSnapshot := buildQueueSnapshotWithLabels(beforeQueue, afterQueue)
|
||||
return buildScheduleOperationResult(
|
||||
"queue_apply_head_move",
|
||||
args,
|
||||
afterState,
|
||||
observation,
|
||||
success,
|
||||
affectedDays,
|
||||
changes,
|
||||
queueSnapshot,
|
||||
pickFailureReason(observation, success),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func buildScheduleArgErrorResult(toolName string, args map[string]any, reason string, state *schedule.ScheduleState) ToolExecutionResult {
|
||||
observation := fmt.Sprintf("%s失败:%s", scheduleOperationFailurePrefix(toolName), strings.TrimSpace(reason))
|
||||
return buildScheduleOperationResult(
|
||||
toolName,
|
||||
args,
|
||||
state,
|
||||
observation,
|
||||
false,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
strings.TrimSpace(reason),
|
||||
)
|
||||
}
|
||||
|
||||
func buildScheduleOperationResult(
|
||||
toolName string,
|
||||
args map[string]any,
|
||||
displayState *schedule.ScheduleState,
|
||||
observation string,
|
||||
success bool,
|
||||
affectedDays []int,
|
||||
changes []map[string]any,
|
||||
queueSnapshot map[string]any,
|
||||
failureReason string,
|
||||
) ToolExecutionResult {
|
||||
result := LegacyResultWithState(toolName, args, displayState, observation)
|
||||
|
||||
status := ToolStatusFailed
|
||||
if success {
|
||||
status = ToolStatusDone
|
||||
}
|
||||
operationLabel := resolveOperationLabelCN(toolName)
|
||||
title := fmt.Sprintf("%s%s", operationLabel, resolveResultTitleSuffix(status))
|
||||
subtitle := buildScheduleSubtitle(toolName, changes, success)
|
||||
metrics := []map[string]any{
|
||||
{"label": "任务数量", "value": fmt.Sprintf("%d个", maxInt(len(changes), countMovesFromArgs(args)))},
|
||||
{"label": "影响天数", "value": fmt.Sprintf("%d天", len(affectedDays))},
|
||||
}
|
||||
|
||||
collapsed := map[string]any{
|
||||
"title": title,
|
||||
"subtitle": subtitle,
|
||||
"status": status,
|
||||
"status_label": resolveToolStatusLabelCN(status),
|
||||
"operation": strings.TrimSpace(toolName),
|
||||
"operation_label": operationLabel,
|
||||
"task_count": maxInt(len(changes), countMovesFromArgs(args)),
|
||||
"affected_days_count": len(affectedDays),
|
||||
"metrics": metrics,
|
||||
}
|
||||
|
||||
expanded := map[string]any{
|
||||
"operation": strings.TrimSpace(toolName),
|
||||
"operation_label": operationLabel,
|
||||
"changes": changes,
|
||||
"affected_days": affectedDays,
|
||||
"affected_days_label": formatAffectedDaysLabel(affectedDays),
|
||||
"raw_text": observation,
|
||||
}
|
||||
if queueSnapshot != nil {
|
||||
expanded["queue_snapshot"] = queueSnapshot
|
||||
}
|
||||
if !success {
|
||||
expanded["failure_reason"] = strings.TrimSpace(pickFailureReason(failureReason, false))
|
||||
}
|
||||
|
||||
result.Status = status
|
||||
result.Success = success
|
||||
result.Summary = title
|
||||
result.ArgumentsPreview = readArgumentSummary(result.ArgumentView)
|
||||
result.ResultView = &ToolDisplayView{
|
||||
ViewType: "schedule.operation_result",
|
||||
Version: 1,
|
||||
Collapsed: collapsed,
|
||||
Expanded: expanded,
|
||||
}
|
||||
if !success {
|
||||
result.ErrorCode = "schedule_operation_failed"
|
||||
if strings.TrimSpace(result.ErrorMessage) == "" {
|
||||
result.ErrorMessage = strings.TrimSpace(pickFailureReason(failureReason, false))
|
||||
}
|
||||
}
|
||||
return EnsureToolResultDefaults(result, args)
|
||||
}
|
||||
|
||||
func buildScheduleSubtitle(operation string, changes []map[string]any, success bool) string {
|
||||
if len(changes) == 0 {
|
||||
if success {
|
||||
return fmt.Sprintf("%s已完成", resolveOperationLabelCN(operation))
|
||||
}
|
||||
return fmt.Sprintf("%s执行失败", resolveOperationLabelCN(operation))
|
||||
}
|
||||
|
||||
firstTask := readStringMap(changes[0], "task_label")
|
||||
firstBefore := readStringMap(changes[0], "before_label")
|
||||
firstAfter := readStringMap(changes[0], "after_label")
|
||||
|
||||
switch strings.TrimSpace(operation) {
|
||||
case "move":
|
||||
return fmt.Sprintf("%s:从%s移动到%s", firstTask, firstBefore, firstAfter)
|
||||
case "place":
|
||||
return fmt.Sprintf("%s:预排到%s", firstTask, firstAfter)
|
||||
case "unplace":
|
||||
return fmt.Sprintf("%s:已从%s移出", firstTask, firstBefore)
|
||||
case "swap":
|
||||
if len(changes) >= 2 {
|
||||
secondTask := readStringMap(changes[1], "task_label")
|
||||
return fmt.Sprintf("%s 与 %s 已交换位置", firstTask, secondTask)
|
||||
}
|
||||
return fmt.Sprintf("%s已交换位置", firstTask)
|
||||
case "batch_move":
|
||||
return fmt.Sprintf("批量移动 %d 个任务", len(changes))
|
||||
case "queue_apply_head_move":
|
||||
if success {
|
||||
return fmt.Sprintf("队首任务已移动到%s", firstAfter)
|
||||
}
|
||||
return fmt.Sprintf("队首任务移动失败:%s", firstTask)
|
||||
default:
|
||||
return fmt.Sprintf("%s:%s", resolveOperationLabelCN(operation), firstTask)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveResultTitleSuffix(status string) string {
|
||||
switch normalizeToolStatus(status) {
|
||||
case ToolStatusDone:
|
||||
return "成功"
|
||||
case ToolStatusBlocked:
|
||||
return "已阻断"
|
||||
default:
|
||||
return "失败"
|
||||
}
|
||||
}
|
||||
|
||||
func pickFailureReason(raw string, success bool) string {
|
||||
if success {
|
||||
return ""
|
||||
}
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return "操作失败,请查看原始结果。"
|
||||
}
|
||||
if payload, ok := parseObservationJSON(trimmed); ok {
|
||||
if text, ok := readStringFromMap(payload, "result", "error", "reason", "message", "err"); ok {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func snapshotTask(state *schedule.ScheduleState, taskID int) scheduleTaskSnapshot {
|
||||
if state == nil || taskID <= 0 {
|
||||
return scheduleTaskSnapshot{
|
||||
Exists: false,
|
||||
TaskID: taskID,
|
||||
DayInfo: buildDayInfo(state),
|
||||
}
|
||||
}
|
||||
task := state.TaskByStateID(taskID)
|
||||
if task == nil {
|
||||
return scheduleTaskSnapshot{
|
||||
Exists: false,
|
||||
TaskID: taskID,
|
||||
DayInfo: buildDayInfo(state),
|
||||
}
|
||||
}
|
||||
return scheduleTaskSnapshot{
|
||||
Exists: true,
|
||||
TaskID: task.StateID,
|
||||
Name: strings.TrimSpace(task.Name),
|
||||
Status: strings.TrimSpace(task.Status),
|
||||
Slots: cloneSlots(task.Slots),
|
||||
DayInfo: buildDayInfo(state),
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotQueue(state *schedule.ScheduleState) scheduleQueueSnapshot {
|
||||
if state == nil || state.RuntimeQueue == nil {
|
||||
return scheduleQueueSnapshot{}
|
||||
}
|
||||
return scheduleQueueSnapshot{
|
||||
PendingCount: len(state.RuntimeQueue.PendingTaskIDs),
|
||||
CompletedCount: len(state.RuntimeQueue.CompletedTaskIDs),
|
||||
SkippedCount: len(state.RuntimeQueue.SkippedTaskIDs),
|
||||
CurrentTaskID: state.RuntimeQueue.CurrentTaskID,
|
||||
CurrentAttempt: state.RuntimeQueue.CurrentAttempts,
|
||||
LastError: strings.TrimSpace(state.RuntimeQueue.LastError),
|
||||
}
|
||||
}
|
||||
|
||||
func buildQueueSnapshotWithLabels(before scheduleQueueSnapshot, after scheduleQueueSnapshot) map[string]any {
|
||||
return map[string]any{
|
||||
"before": queueSnapshotToMap(before),
|
||||
"after": queueSnapshotToMap(after),
|
||||
"before_label": queueSummaryLabel(before),
|
||||
"after_label": queueSummaryLabel(after),
|
||||
"summary_label": queueSummaryLabel(after),
|
||||
"last_error_label": strings.TrimSpace(after.LastError),
|
||||
}
|
||||
}
|
||||
|
||||
func queueSnapshotToMap(s scheduleQueueSnapshot) map[string]any {
|
||||
return map[string]any{
|
||||
"pending_count": s.PendingCount,
|
||||
"completed_count": s.CompletedCount,
|
||||
"skipped_count": s.SkippedCount,
|
||||
"current_task_id": s.CurrentTaskID,
|
||||
"current_attempt": s.CurrentAttempt,
|
||||
"last_error": s.LastError,
|
||||
}
|
||||
}
|
||||
|
||||
func queueSummaryLabel(s scheduleQueueSnapshot) string {
|
||||
return fmt.Sprintf(
|
||||
"待处理%d个,已完成%d个,已跳过%d个,当前任务%d,尝试%d次",
|
||||
s.PendingCount,
|
||||
s.CompletedCount,
|
||||
s.SkippedCount,
|
||||
s.CurrentTaskID,
|
||||
s.CurrentAttempt,
|
||||
)
|
||||
}
|
||||
|
||||
func buildTaskChange(operation string, before scheduleTaskSnapshot, after scheduleTaskSnapshot) map[string]any {
|
||||
taskLabel := resolveChangeTaskLabel(before, after)
|
||||
beforeStatusLabel := resolveTaskStatusLabelCN(before.Status)
|
||||
afterStatusLabel := resolveTaskStatusLabelCN(after.Status)
|
||||
beforeLabel := formatPlacementLabel(operation, before.Slots, before.Status, false, false)
|
||||
afterLabel := formatPlacementLabel(operation, after.Slots, after.Status, true, len(before.Slots) > 0)
|
||||
|
||||
change := map[string]any{
|
||||
"task_id": before.TaskID,
|
||||
"name": firstNonEmpty(before.Name, after.Name),
|
||||
"status": map[string]any{
|
||||
"before": before.Status,
|
||||
"after": after.Status,
|
||||
},
|
||||
"before_slots": slotsToView(before.Slots, before.DayInfo),
|
||||
"after_slots": slotsToView(after.Slots, after.DayInfo),
|
||||
"task_label": taskLabel,
|
||||
"before_label": beforeLabel,
|
||||
"after_label": afterLabel,
|
||||
"status_label": fmt.Sprintf("%s -> %s", beforeStatusLabel, afterStatusLabel),
|
||||
"operation_key": operation,
|
||||
}
|
||||
return change
|
||||
}
|
||||
|
||||
func resolveChangeTaskLabel(before scheduleTaskSnapshot, after scheduleTaskSnapshot) string {
|
||||
name := firstNonEmpty(before.Name, after.Name)
|
||||
if name == "" {
|
||||
if before.TaskID > 0 {
|
||||
return fmt.Sprintf("[%d]任务", before.TaskID)
|
||||
}
|
||||
return "任务"
|
||||
}
|
||||
if before.TaskID > 0 {
|
||||
return fmt.Sprintf("[%d]%s", before.TaskID, name)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func resolveTaskStatusLabelCN(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case schedule.TaskStatusPending:
|
||||
return "待安排"
|
||||
case schedule.TaskStatusSuggested:
|
||||
return "已预排"
|
||||
case schedule.TaskStatusExisting:
|
||||
return "已安排"
|
||||
default:
|
||||
return "未知状态"
|
||||
}
|
||||
}
|
||||
|
||||
func formatPlacementLabel(operation string, slots []schedule.TaskSlot, status string, isAfter bool, hadBefore bool) string {
|
||||
if len(slots) > 0 {
|
||||
return formatSlotsLabelCN(slots)
|
||||
}
|
||||
if isAfter && strings.TrimSpace(operation) == "unplace" && hadBefore {
|
||||
return "已移出"
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(status), schedule.TaskStatusPending) {
|
||||
return "未安排"
|
||||
}
|
||||
return "未安排"
|
||||
}
|
||||
|
||||
func formatSlotsLabelCN(slots []schedule.TaskSlot) string {
|
||||
if len(slots) == 0 {
|
||||
return "未安排"
|
||||
}
|
||||
parts := make([]string, 0, len(slots))
|
||||
for _, slot := range slots {
|
||||
parts = append(parts, fmt.Sprintf("%s %s", formatDayLabelCN(slot.Day), formatSlotRangeCN(slot.SlotStart, slot.SlotEnd)))
|
||||
}
|
||||
return strings.Join(parts, "、")
|
||||
}
|
||||
|
||||
func formatAffectedDaysLabel(affectedDays []int) string {
|
||||
if len(affectedDays) == 0 {
|
||||
return "无"
|
||||
}
|
||||
parts := make([]string, 0, len(affectedDays))
|
||||
for _, day := range affectedDays {
|
||||
parts = append(parts, formatDayLabelCN(day))
|
||||
}
|
||||
return strings.Join(parts, "、")
|
||||
}
|
||||
|
||||
func slotsToView(slots []schedule.TaskSlot, dayInfo map[int]schedule.DayMapping) []map[string]any {
|
||||
if len(slots) == 0 {
|
||||
return make([]map[string]any, 0)
|
||||
}
|
||||
result := make([]map[string]any, 0, len(slots))
|
||||
for _, slot := range slots {
|
||||
entry := map[string]any{
|
||||
"day": slot.Day,
|
||||
"slot_start": slot.SlotStart,
|
||||
"slot_end": slot.SlotEnd,
|
||||
}
|
||||
if info, ok := dayInfo[slot.Day]; ok {
|
||||
entry["week"] = info.Week
|
||||
entry["day_of_week"] = info.DayOfWeek
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func collectAffectedDays(changes []map[string]any) []int {
|
||||
if len(changes) == 0 {
|
||||
return make([]int, 0)
|
||||
}
|
||||
set := make(map[int]struct{})
|
||||
for _, change := range changes {
|
||||
collectDaysFromSlotView(set, change["before_slots"])
|
||||
collectDaysFromSlotView(set, change["after_slots"])
|
||||
}
|
||||
days := make([]int, 0, len(set))
|
||||
for day := range set {
|
||||
days = append(days, day)
|
||||
}
|
||||
sort.Ints(days)
|
||||
return days
|
||||
}
|
||||
|
||||
func collectDaysFromSlotView(target map[int]struct{}, raw any) {
|
||||
list, ok := raw.([]map[string]any)
|
||||
if ok {
|
||||
for _, item := range list {
|
||||
day, ok := item["day"].(int)
|
||||
if ok {
|
||||
target[day] = struct{}{}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
anyList, ok := raw.([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, item := range anyList {
|
||||
itemMap, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch day := itemMap["day"].(type) {
|
||||
case int:
|
||||
target[day] = struct{}{}
|
||||
case float64:
|
||||
target[int(day)] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildDayInfo(state *schedule.ScheduleState) map[int]schedule.DayMapping {
|
||||
if state == nil || len(state.Window.DayMapping) == 0 {
|
||||
return map[int]schedule.DayMapping{}
|
||||
}
|
||||
info := make(map[int]schedule.DayMapping, len(state.Window.DayMapping))
|
||||
for _, item := range state.Window.DayMapping {
|
||||
info[item.DayIndex] = item
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func cloneSlots(slots []schedule.TaskSlot) []schedule.TaskSlot {
|
||||
if len(slots) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]schedule.TaskSlot, len(slots))
|
||||
copy(out, slots)
|
||||
return out
|
||||
}
|
||||
|
||||
func sameSlots(a []schedule.TaskSlot, b []schedule.TaskSlot) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i].Day != b[i].Day || a[i].SlotStart != b[i].SlotStart || a[i].SlotEnd != b[i].SlotEnd {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func taskHasSlotAt(snapshot scheduleTaskSnapshot, day int, slotStart int) bool {
|
||||
for _, slot := range snapshot.Slots {
|
||||
if slot.Day == day && slot.SlotStart == slotStart {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readStringMap(input map[string]any, key string) string {
|
||||
value, ok := input[key]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
text, _ := value.(string)
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func countMovesFromArgs(args map[string]any) int {
|
||||
moves, ok := args["moves"].([]any)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return len(moves)
|
||||
}
|
||||
|
||||
func maxInt(values ...int) int {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
maxValue := values[0]
|
||||
for _, value := range values[1:] {
|
||||
if value > maxValue {
|
||||
maxValue = value
|
||||
}
|
||||
}
|
||||
return maxValue
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func scheduleOperationFailurePrefix(toolName string) string {
|
||||
switch strings.TrimSpace(toolName) {
|
||||
case "place":
|
||||
return "放置"
|
||||
case "move", "queue_apply_head_move":
|
||||
return "移动"
|
||||
case "swap":
|
||||
return "交换"
|
||||
case "batch_move":
|
||||
return "批量移动"
|
||||
case "unplace":
|
||||
return "移除"
|
||||
default:
|
||||
return strings.TrimSpace(toolName)
|
||||
}
|
||||
}
|
||||
@@ -56,76 +56,76 @@ type taskClassUpsertToolResult struct {
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做参数解析、合法性校验、调用依赖、返回统一 JSON;
|
||||
// 2. 不负责草案生成,草案由 prompt+LLM 完成;
|
||||
// 2. 不负责草稿生成,草稿由 prompt+LLM 完成;
|
||||
// 3. 不依赖 ScheduleState,可在纯聊天场景调用(execute 会注入 _user_id)。
|
||||
func NewTaskClassUpsertToolHandler(deps TaskClassWriteDeps) ToolHandler {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
|
||||
_ = state
|
||||
|
||||
if deps.UpsertTaskClass == nil {
|
||||
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
|
||||
return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{
|
||||
Tool: "upsert_task_class",
|
||||
Success: false,
|
||||
Validation: taskClassValidationResult{OK: false, Issues: []string{"任务类写库依赖未注入"}},
|
||||
Error: "任务类写库依赖未注入",
|
||||
ErrorCode: "dependency_missing",
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
userID, ok := readUpsertUserID(args["_user_id"])
|
||||
if !ok || userID <= 0 {
|
||||
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
|
||||
return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{
|
||||
Tool: "upsert_task_class",
|
||||
Success: false,
|
||||
Validation: taskClassValidationResult{OK: false, Issues: []string{"无法识别用户身份"}},
|
||||
Error: "工具调用失败:无法识别用户身份",
|
||||
ErrorCode: "missing_user_id",
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
input, parseErr := parseTaskClassUpsertInput(args)
|
||||
if parseErr != nil {
|
||||
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
|
||||
return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{
|
||||
Tool: "upsert_task_class",
|
||||
Success: false,
|
||||
Validation: taskClassValidationResult{OK: false, Issues: []string{parseErr.Error()}},
|
||||
Error: parseErr.Error(),
|
||||
ErrorCode: "invalid_args",
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
issues := validateTaskClassUpsertRequest(input.Request, input.ID)
|
||||
if len(issues) > 0 {
|
||||
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
|
||||
return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{
|
||||
Tool: "upsert_task_class",
|
||||
Success: false,
|
||||
Validation: taskClassValidationResult{OK: false, Issues: issues},
|
||||
Error: strings.Join(issues, ";"),
|
||||
ErrorCode: "validation_failed",
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
result, err := deps.UpsertTaskClass(userID, input)
|
||||
if err != nil {
|
||||
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
|
||||
return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{
|
||||
Tool: "upsert_task_class",
|
||||
Success: false,
|
||||
Validation: taskClassValidationResult{OK: false, Issues: []string{"持久化写入失败"}},
|
||||
Error: err.Error(),
|
||||
ErrorCode: "persist_failed",
|
||||
})
|
||||
}))
|
||||
}
|
||||
if result.TaskClassID <= 0 {
|
||||
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
|
||||
return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{
|
||||
Tool: "upsert_task_class",
|
||||
Success: false,
|
||||
Validation: taskClassValidationResult{OK: false, Issues: []string{"未返回有效 task_class_id"}},
|
||||
Error: "写入后未返回有效 task_class_id",
|
||||
ErrorCode: "invalid_persist_result",
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
|
||||
return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{
|
||||
Tool: "upsert_task_class",
|
||||
Success: true,
|
||||
TaskClassID: result.TaskClassID,
|
||||
@@ -133,7 +133,7 @@ func NewTaskClassUpsertToolHandler(deps TaskClassWriteDeps) ToolHandler {
|
||||
Validation: taskClassValidationResult{OK: true, Issues: []string{}},
|
||||
Error: "",
|
||||
ErrorCode: "",
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -363,12 +363,19 @@ func buildTimelinePayloadFromStreamExtra(extra *newagentstream.OpenAIChunkExtra)
|
||||
"display_mode": string(extra.DisplayMode),
|
||||
}
|
||||
if extra.Tool != nil {
|
||||
payload["tool"] = map[string]any{
|
||||
toolPayload := map[string]any{
|
||||
"name": strings.TrimSpace(extra.Tool.Name),
|
||||
"status": strings.TrimSpace(extra.Tool.Status),
|
||||
"summary": strings.TrimSpace(extra.Tool.Summary),
|
||||
"arguments_preview": strings.TrimSpace(extra.Tool.ArgumentsPreview),
|
||||
}
|
||||
if len(extra.Tool.ArgumentView) > 0 {
|
||||
toolPayload["argument_view"] = cloneTimelinePayload(extra.Tool.ArgumentView)
|
||||
}
|
||||
if len(extra.Tool.ResultView) > 0 {
|
||||
toolPayload["result_view"] = cloneTimelinePayload(extra.Tool.ResultView)
|
||||
}
|
||||
payload["tool"] = toolPayload
|
||||
}
|
||||
if extra.Confirm != nil {
|
||||
payload["confirm"] = map[string]any{
|
||||
|
||||
@@ -3,11 +3,20 @@ import type { ApiResponse } from '@/types/api'
|
||||
import type { PlacedItem, SchedulePreviewData } from '@/types/dashboard'
|
||||
import { extractErrorMessage } from '@/utils/http'
|
||||
|
||||
export type ToolView = {
|
||||
view_type?: string
|
||||
version?: number
|
||||
collapsed?: Record<string, any>
|
||||
expanded?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface TimelineToolPayload {
|
||||
name: string
|
||||
status: 'start' | 'done' | 'blocked' | 'failed'
|
||||
status: 'start' | 'done' | 'blocked' | 'failed' | string
|
||||
summary: string
|
||||
arguments_preview?: string
|
||||
argument_view?: ToolView
|
||||
result_view?: ToolView
|
||||
}
|
||||
|
||||
export interface TimelineConfirmPayload {
|
||||
|
||||
@@ -22,7 +22,8 @@ import {
|
||||
getConversationTimeline,
|
||||
type TimelineEvent,
|
||||
type TimelineToolPayload,
|
||||
type TimelineConfirmPayload
|
||||
type TimelineConfirmPayload,
|
||||
type ToolView
|
||||
} from '@/api/schedule_agent'
|
||||
import { refreshToken } from '@/api/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -41,6 +42,7 @@ import ScheduleFineTuneModal from '@/components/assistant/ScheduleFineTuneModal.
|
||||
import { formatConversationTime, formatMessageTime } from '@/utils/date'
|
||||
import { renderMarkdown } from '@/utils/markdown'
|
||||
import BusinessCardRenderer from '@/components/assistant/cards/BusinessCardRenderer.vue'
|
||||
import ToolCardRenderer from '@/components/dashboard/ToolCardRenderer.vue'
|
||||
import type {
|
||||
TimelineBusinessCardPayload,
|
||||
TaskQueryCardData,
|
||||
@@ -77,6 +79,8 @@ interface StreamToolExtraPayload {
|
||||
status?: string
|
||||
summary?: string
|
||||
arguments_preview?: string
|
||||
argument_view?: ToolView
|
||||
result_view?: ToolView
|
||||
}
|
||||
|
||||
interface StreamExtraPayload {
|
||||
@@ -108,6 +112,8 @@ interface ToolTraceEvent {
|
||||
summary: string
|
||||
detail?: string
|
||||
toolName?: string
|
||||
argumentView?: ToolView
|
||||
resultView?: ToolView
|
||||
}
|
||||
|
||||
interface StatusTraceEvent {
|
||||
@@ -265,6 +271,7 @@ const conversationContextStatsLoadingMap = reactive<Record<string, boolean>>({})
|
||||
const conversationContextStatsReadyMap = reactive<Record<string, boolean>>({})
|
||||
const conversationListItemRevealMap = reactive<Record<string, boolean>>({})
|
||||
const scheduleResultMap = reactive<Record<string, SchedulePreviewData>>({})
|
||||
const scheduleResultSeqMap = reactive<Record<string, number>>({})
|
||||
const businessCardEventsMap = reactive<Record<string, TimelineBusinessCardPayload[]>>({})
|
||||
const isFineTuneModalVisible = ref(false)
|
||||
const fineTuneLoading = ref(false)
|
||||
@@ -693,6 +700,7 @@ function clearToolTraceState(messageId: string) {
|
||||
delete assistantContentBlocksMap[messageId]
|
||||
delete assistantTimelineLastKindMap[messageId]
|
||||
delete scheduleResultMap[messageId]
|
||||
delete scheduleResultSeqMap[messageId]
|
||||
delete businessCardEventsMap[messageId]
|
||||
for (const key of Object.keys(toolTraceExpandedMap)) {
|
||||
if (key.startsWith(`${messageId}:tool:`)) {
|
||||
@@ -707,6 +715,8 @@ function appendToolTraceEvent(
|
||||
summary: string,
|
||||
detail = '',
|
||||
toolName = '',
|
||||
argumentView?: ToolView,
|
||||
resultView?: ToolView,
|
||||
) {
|
||||
const normalizedSummary = summary.trim()
|
||||
if (!normalizedSummary) {
|
||||
@@ -745,6 +755,8 @@ function appendToolTraceEvent(
|
||||
summary: normalizedSummary,
|
||||
detail: normalizedDetail || undefined,
|
||||
toolName: normalizedToolName || undefined,
|
||||
argumentView,
|
||||
resultView,
|
||||
})
|
||||
assistantTimelineLastKindMap[messageId] = 'tool'
|
||||
}
|
||||
@@ -1569,6 +1581,17 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[]
|
||||
})
|
||||
}
|
||||
|
||||
if (scheduleResultMap[source.id]) {
|
||||
blocks.push({
|
||||
id: `${source.id}:schedule-card`,
|
||||
type: 'schedule_card',
|
||||
seq: scheduleResultSeqMap[source.id] || 1000000,
|
||||
schedulePreview: scheduleResultMap[source.id],
|
||||
sourceId: source.id,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
const contentBlocks = assistantContentBlocksMap[source.id] || []
|
||||
if (contentBlocks.length > 0) {
|
||||
hasContentBlock = true
|
||||
@@ -1599,16 +1622,6 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[]
|
||||
}
|
||||
}
|
||||
|
||||
const schedulePreview = scheduleResultMap[dm.id]
|
||||
if (schedulePreview) {
|
||||
blocks.push({
|
||||
id: `${dm.id}:schedule-card`,
|
||||
type: 'schedule_card',
|
||||
seq: nextAssistantTimelineSeq(),
|
||||
schedulePreview,
|
||||
})
|
||||
}
|
||||
|
||||
if (!hasContentBlock && dm.content) {
|
||||
fallbackSeq += 1
|
||||
blocks.push({
|
||||
@@ -2038,17 +2051,32 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[
|
||||
case 'tool_call':
|
||||
if (event.payload?.tool) {
|
||||
const t = event.payload.tool
|
||||
appendToolTraceEvent(mid, mapToolEventState(t.status), normalizeToolSummary(t), buildToolDetail(t), t.name)
|
||||
appendToolTraceEvent(mid, mapToolEventState(t.status), normalizeToolSummary(t), buildToolDetail(t), t.name, t.argument_view, t.result_view)
|
||||
}
|
||||
break
|
||||
|
||||
case 'tool_result':
|
||||
if (event.payload?.tool) {
|
||||
const t = event.payload.tool
|
||||
appendToolTraceEvent(mid, mapToolEventState(t.status), normalizeToolSummary(t), buildToolDetail(t), t.name)
|
||||
appendToolTraceEvent(mid, mapToolEventState(t.status), normalizeToolSummary(t), buildToolDetail(t), t.name, t.argument_view, t.result_view)
|
||||
}
|
||||
break
|
||||
|
||||
case 'schedule_completed':
|
||||
// 为该 assistant message 添加一个 schedule_card 占位卡
|
||||
scheduleResultMap[mid] = {
|
||||
conversation_id: conversationId,
|
||||
trace_id: '',
|
||||
summary: '日程表编排已就绪',
|
||||
candidate_plans: [],
|
||||
hybrid_entries: [],
|
||||
task_class_ids: [],
|
||||
generated_at: event.created_at || new Date().toISOString(),
|
||||
is_placeholder: true
|
||||
} as any
|
||||
scheduleResultSeqMap[mid] = event.seq || nextAssistantTimelineSeq()
|
||||
break
|
||||
|
||||
case 'confirm_request':
|
||||
confirmOnlyStreamMap[mid] = true
|
||||
// 记录确认卡片
|
||||
@@ -2532,6 +2560,24 @@ function handleStreamExtraEvent(extra: StreamExtraPayload | undefined, assistant
|
||||
return
|
||||
}
|
||||
|
||||
if (extra.kind === 'schedule_completed') {
|
||||
// 为当前助理消息添加一个排程卡片占位符
|
||||
const mid = assistantMessage.id
|
||||
scheduleResultMap[mid] = {
|
||||
conversation_id: selectedConversationId.value,
|
||||
trace_id: '',
|
||||
summary: '日程表编排已就绪',
|
||||
candidate_plans: [],
|
||||
hybrid_entries: [],
|
||||
task_class_ids: [],
|
||||
generated_at: new Date().toISOString(),
|
||||
is_placeholder: true
|
||||
} as any
|
||||
scheduleResultSeqMap[mid] = nextAssistantTimelineSeq()
|
||||
scheduleScrollMessagesToBottom(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (extra.kind === 'tool_call' && extra.tool) {
|
||||
appendToolTraceEvent(
|
||||
assistantMessage.id,
|
||||
@@ -2539,6 +2585,8 @@ function handleStreamExtraEvent(extra: StreamExtraPayload | undefined, assistant
|
||||
normalizeToolSummary(extra.tool),
|
||||
buildToolDetail(extra.tool),
|
||||
`${extra.tool.name || ''}`,
|
||||
extra.tool.argument_view,
|
||||
extra.tool.result_view,
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -2550,6 +2598,8 @@ function handleStreamExtraEvent(extra: StreamExtraPayload | undefined, assistant
|
||||
normalizeToolSummary(extra.tool),
|
||||
buildToolDetail(extra.tool),
|
||||
`${extra.tool.name || ''}`,
|
||||
extra.tool.argument_view,
|
||||
extra.tool.result_view,
|
||||
)
|
||||
if (extra.tool.status === 'done') {
|
||||
void loadConversationContextStats(selectedConversationId.value, true)
|
||||
@@ -3160,30 +3210,19 @@ onBeforeUnmount(() => {
|
||||
<div v-else class="chat-message__assistant-flow">
|
||||
<TransitionGroup name="inner-fade">
|
||||
<div v-for="block in getDisplayAssistantBlocks(dm)" :key="block.id">
|
||||
<article v-if="block.type === 'tool'" class="chat-message__tool">
|
||||
<button
|
||||
type="button"
|
||||
class="chat-message__tool-head"
|
||||
@click="block.event && toggleToolTraceExpanded(block.event.id)"
|
||||
>
|
||||
<span class="chat-message__tool-icon" aria-hidden="true">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="chat-message__tool-summary">{{ block.event?.summary }}</span>
|
||||
<em class="chat-message__tool-badge">{{ getToolTraceStateLabel(block.event?.state || 'completed') }}</em>
|
||||
<span class="chat-message__tool-chevron" :class="{ 'chat-message__tool-chevron--expanded': block.event ? isToolTraceExpanded(block.event.id) : false }" aria-hidden="true">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p v-if="block.event && isToolTraceExpanded(block.event.id) && block.event.detail" class="chat-message__tool-detail">
|
||||
{{ block.event.detail }}
|
||||
</p>
|
||||
</article>
|
||||
<ToolCardRenderer
|
||||
v-if="block.type === 'tool' && block.event"
|
||||
:payload="{
|
||||
name: block.event.toolName || '',
|
||||
status: block.event.state === 'called' ? 'start' : (block.event.state === 'completed' ? 'done' : block.event.state),
|
||||
summary: block.event.summary,
|
||||
arguments_preview: block.event.detail,
|
||||
argument_view: block.event.argumentView,
|
||||
result_view: block.event.resultView
|
||||
}"
|
||||
:expanded="isToolTraceExpanded(block.id)"
|
||||
@toggle="toggleToolTraceExpanded(block.id)"
|
||||
/>
|
||||
|
||||
<div v-else-if="block.type === 'status'" class="chat-message__status-line">
|
||||
<span class="chat-message__status-icon" aria-hidden="true">
|
||||
|
||||
633
frontend/src/components/dashboard/ToolCardRenderer.vue
Normal file
633
frontend/src/components/dashboard/ToolCardRenderer.vue
Normal file
@@ -0,0 +1,633 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { TimelineToolPayload, ToolView } from '@/api/schedule_agent'
|
||||
|
||||
const props = defineProps<{
|
||||
payload: TimelineToolPayload
|
||||
expanded: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle'): void
|
||||
}>()
|
||||
|
||||
function getStatusLabel(status: string) {
|
||||
const map: Record<string, string> = {
|
||||
start: '进行中',
|
||||
done: '已完成',
|
||||
failed: '失败',
|
||||
blocked: '已拦截',
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
// 模拟原有的 getOperationLabel 逻辑(如果后端没传标签)
|
||||
function getOperationFallbackLabel(op: string) {
|
||||
const map: Record<string, string> = {
|
||||
move: '移动',
|
||||
place: '放置',
|
||||
swap: '交换',
|
||||
batch_move: '批量移动',
|
||||
unplace: '取消放置',
|
||||
queue_apply_head_move: '队列首项确认',
|
||||
}
|
||||
return map[op] || op
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="tool-card"
|
||||
:class="[
|
||||
`tool-card--${payload.status}`,
|
||||
{ 'tool-card--expanded': expanded },
|
||||
]"
|
||||
>
|
||||
<!-- 1. 折叠态头部 (优先取 result_view.collapsed) -->
|
||||
<header class="tool-card__header" @click="emit('toggle')">
|
||||
<div class="tool-card__icon-box">
|
||||
<svg v-if="payload.status === 'failed'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="tool-card__title-group">
|
||||
<div class="tool-card__title-row">
|
||||
<h3 class="tool-card__title">
|
||||
{{ payload.result_view?.collapsed?.title || payload.summary }}
|
||||
</h3>
|
||||
<span class="tool-card__badge">
|
||||
{{ payload.result_view?.collapsed?.status_label || getStatusLabel(payload.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="tool-card__subtitle">
|
||||
{{ payload.result_view?.collapsed?.subtitle || payload.arguments_preview }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 简短指标区 -->
|
||||
<div v-if="!expanded && payload.result_view?.collapsed?.metrics" class="tool-card__metrics">
|
||||
<div v-for="(m, mi) in payload.result_view.collapsed.metrics" :key="mi" class="metric-item">
|
||||
<span class="metric-value">{{ m.value }}</span>
|
||||
<span class="metric-label">{{ m.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!expanded && payload.result_view?.collapsed?.operation_label" class="tool-card__metrics">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">{{ payload.result_view.collapsed.operation_label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card__chevron" :class="{ 'tool-card__chevron--expanded': expanded }">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 2. 展开态详情 -->
|
||||
<transition name="tool-expand">
|
||||
<section v-if="expanded" class="tool-card__content">
|
||||
<div class="tool-card__divider"></div>
|
||||
|
||||
<!-- 2.1 参数展示 (优先读取 argument_view) -->
|
||||
<div v-if="payload.argument_view" class="section-block section-arguments">
|
||||
<h4 class="detail-section-title">参数详情</h4>
|
||||
<p v-if="payload.argument_view.collapsed?.summary" class="arg-summary">
|
||||
{{ payload.argument_view.collapsed.summary }}
|
||||
</p>
|
||||
<div v-if="payload.argument_view.expanded?.fields" class="arg-fields">
|
||||
<div v-for="(f, fi) in payload.argument_view.expanded.fields" :key="fi" class="arg-field-item">
|
||||
<span class="arg-label">{{ f.label }}</span>
|
||||
<span class="arg-value">{{ f.display }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2.2 结果渲染: schedule.operation_result -->
|
||||
<div v-if="payload.result_view?.view_type === 'schedule.operation_result'" class="section-block view-operation">
|
||||
<h4 class="detail-section-title">操作结果</h4>
|
||||
<div v-if="payload.result_view.expanded?.changes?.length" class="changes-list">
|
||||
<div v-for="(change, idx) in payload.result_view.expanded.changes" :key="idx" class="change-item">
|
||||
<div class="change-item__header">
|
||||
<span class="change-item__task-icon"></span>
|
||||
<span class="change-item__task-name">{{ change.task_label }}</span>
|
||||
<span v-if="change.status_label" class="change-item__status-tag">{{ change.status_label }}</span>
|
||||
</div>
|
||||
|
||||
<div class="change-item__path">
|
||||
<div class="slot-box slot-box--before">
|
||||
<span class="slot-tag">之前</span>
|
||||
<div class="slot-text">{{ change.before_label || '未排程' }}</div>
|
||||
</div>
|
||||
<div class="path-arrow">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="slot-box slot-box--after">
|
||||
<span class="slot-tag">之后</span>
|
||||
<div class="slot-text">{{ change.after_label || '未排程' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 队列快照 (带标签) -->
|
||||
<div v-if="payload.result_view.expanded?.queue_snapshot" class="queue-snapshot">
|
||||
<h5 class="sub-section-title">{{ payload.result_view.expanded.queue_snapshot.summary_label || '队列变更' }}</h5>
|
||||
<div class="queue-compare">
|
||||
<div class="queue-side">
|
||||
<span class="queue-count">{{ payload.result_view.expanded.queue_snapshot.before_label }}</span>
|
||||
</div>
|
||||
<div class="queue-arrow"></div>
|
||||
<div class="queue-side">
|
||||
<span class="queue-count highlight">{{ payload.result_view.expanded.queue_snapshot.after_label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 失败信息 -->
|
||||
<div v-if="payload.result_view.expanded?.failure_reason" class="failure-box">
|
||||
<span class="failure-icon">!</span>
|
||||
<p class="failure-text">{{ payload.result_view.expanded.failure_reason }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2.3 结果渲染: legacy_text -->
|
||||
<div v-else-if="payload.result_view?.view_type === 'legacy_text'" class="section-block view-legacy">
|
||||
<h4 class="detail-section-title">{{ payload.result_view.expanded?.raw_text_label || '输出内容' }}</h4>
|
||||
<div class="raw-text-container">
|
||||
<pre class="raw-text">{{ payload.result_view.expanded?.raw_text }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2.4 旧协议兜底 -->
|
||||
<div v-else-if="!payload.result_view" class="section-block view-old-fallback">
|
||||
<h4 class="detail-section-title">工具输出 (兼容模式)</h4>
|
||||
<div class="fallback-summary-box">
|
||||
<p class="fallback-summary">{{ payload.summary }}</p>
|
||||
<div v-if="payload.arguments_preview" class="fallback-json-box">
|
||||
<span class="json-label">调用参数:</span>
|
||||
<code>{{ payload.arguments_preview }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 原始 Observation (仅开发/调试可见入口, 默认收起) -->
|
||||
<details v-if="payload.result_view?.expanded?.raw_text" class="debug-details">
|
||||
<summary>调试信息 (RAW Observation)</summary>
|
||||
<pre class="debug-raw-pre">{{ payload.result_view.expanded.raw_text }}</pre>
|
||||
</details>
|
||||
</section>
|
||||
</transition>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Tool Card Styles */
|
||||
.tool-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #eef2f6;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.02);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.tool-card:hover {
|
||||
border-color: #d1d5db;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.tool-card--expanded {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 8px 16px -4px rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
|
||||
.tool-card--failed {
|
||||
border-left: 4px solid #f43f5e;
|
||||
}
|
||||
|
||||
.tool-card--done {
|
||||
border-left: 4px solid #10b981;
|
||||
}
|
||||
|
||||
.tool-card__header {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tool-card__icon-box {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.tool-card--done .tool-card__icon-box {
|
||||
color: #10b981;
|
||||
background: #f0fdf4;
|
||||
border-color: #dcfce7;
|
||||
}
|
||||
|
||||
.tool-card--failed .tool-card__icon-box {
|
||||
color: #f43f5e;
|
||||
background: #fff1f2;
|
||||
border-color: #fee2e2;
|
||||
}
|
||||
|
||||
.tool-card__title-group {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tool-card__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
.tool-card__title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.tool-card__badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 8px;
|
||||
border-radius: 6px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.tool-card--done .tool-card__badge {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.tool-card--failed .tool-card__badge {
|
||||
background: #fff1f2;
|
||||
color: #e11d48;
|
||||
}
|
||||
|
||||
.tool-card__subtitle {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tool-card__metrics {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.metric-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: #334155;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 9px;
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tool-card__chevron {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #cbd5e1;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.tool-card__chevron--expanded {
|
||||
transform: rotate(180deg);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.tool-card__content {
|
||||
padding: 0 16px 20px;
|
||||
}
|
||||
|
||||
.tool-card__divider {
|
||||
height: 1px;
|
||||
background: #f1f5f9;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-block + .section-block {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.detail-section-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Arguments */
|
||||
.arg-summary {
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.arg-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
background: #f8fafc;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.arg-field-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.arg-label {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.arg-value {
|
||||
font-size: 12px;
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Operation Changes */
|
||||
.changes-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.change-item {
|
||||
background: #ffffff;
|
||||
border: 1px solid #f1f5f9;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.change-item__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.change-item__task-icon {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #3b82f6;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.change-item__task-name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.change-item__status-tag {
|
||||
font-size: 10px;
|
||||
color: #2563eb;
|
||||
background: #eff6ff;
|
||||
padding: 0px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.change-item__path {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.slot-box {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.slot-box--before {
|
||||
background: #fdfdfd;
|
||||
border: 1px dashed #e2e8f0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.slot-box--after {
|
||||
background: #f0f7ff;
|
||||
border: 1px solid #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.slot-tag {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 2px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.slot-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Queue Snapshot */
|
||||
.sub-section-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.queue-snapshot {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #eef2f6;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.queue-compare {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.queue-side {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.queue-count {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.queue-count.highlight {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.queue-arrow {
|
||||
flex: 2;
|
||||
height: 2px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Legacy Text */
|
||||
.raw-text-container {
|
||||
background: #1e293b;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.raw-text {
|
||||
margin: 0;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #e2e8f0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Fallback Old */
|
||||
.fallback-summary-box {
|
||||
padding: 12px;
|
||||
background: #fefce8;
|
||||
border: 1px solid #fef3c7;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.fallback-summary {
|
||||
font-size: 13px;
|
||||
color: #92400e;
|
||||
font-weight: 600;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.fallback-json-box {
|
||||
font-size: 11px;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
/* Failure */
|
||||
.failure-box {
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
background: #fff1f2;
|
||||
border: 1px solid #fee2e2;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.failure-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #f43f5e;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.failure-text {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #9f1239;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Debug */
|
||||
.debug-details {
|
||||
margin-top: 24px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.debug-details summary {
|
||||
font-size: 11px;
|
||||
color: #cbd5e1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.debug-raw-pre {
|
||||
margin-top: 10px;
|
||||
font-size: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
color: #94a3b8;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.tool-expand-enter-active,
|
||||
.tool-expand-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
max-height: 1000px;
|
||||
}
|
||||
|
||||
.tool-expand-enter-from,
|
||||
.tool-expand-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -5,9 +5,6 @@ import AuthView from '@/views/AuthView.vue'
|
||||
import AssistantView from '@/views/AssistantView.vue'
|
||||
import DashboardView from '@/views/DashboardView.vue'
|
||||
import ScheduleView from '@/views/ScheduleView.vue'
|
||||
import ToolTracePrototypeView from '@/views/ToolTracePrototypeView.vue'
|
||||
import TaskInteractiveDemo from '@/views/TaskInteractiveDemo.vue'
|
||||
import DesignDemo from '@/views/DesignDemo.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -16,11 +13,6 @@ const router = createRouter({
|
||||
path: '/',
|
||||
redirect: '/dashboard',
|
||||
},
|
||||
{
|
||||
path: '/demo-task',
|
||||
name: 'demo-task',
|
||||
component: TaskInteractiveDemo,
|
||||
},
|
||||
{
|
||||
path: '/auth',
|
||||
name: 'auth',
|
||||
@@ -53,16 +45,6 @@ const router = createRouter({
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/prototype/tool-trace',
|
||||
name: 'tool-trace-prototype',
|
||||
component: ToolTracePrototypeView,
|
||||
},
|
||||
{
|
||||
path: '/design-demo',
|
||||
name: 'design-demo',
|
||||
component: DesignDemo,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
// --- 数据结构定义 ---
|
||||
interface Task {
|
||||
id: string
|
||||
title: string
|
||||
priority_group: 1 | 2 | 3 | 4
|
||||
deadline_at?: string
|
||||
is_completed: boolean
|
||||
}
|
||||
|
||||
// --- 四象限元数据 (深度对齐首页提示词 & 视觉) ---
|
||||
const quadMeta: any = {
|
||||
1: { title: '重要且紧急', caption: '优先处理', tone: 'danger', bg: 'linear-gradient(180deg, #fff1f2 0%, #fff7f7 100%)', text: '#ef4444' },
|
||||
2: { title: '重要不紧急', caption: '持续推进', tone: 'primary', bg: 'linear-gradient(180deg, #eef7ff 0%, #f7fbff 100%)', text: '#3b82f6' },
|
||||
3: { title: '简单不重要', caption: '顺手完成', tone: 'warning', bg: 'linear-gradient(180deg, #fff8df 0%, #fffdf1 100%)', text: '#f59e0b' },
|
||||
4: { title: '不简单不重要', caption: '谨慎投入', tone: 'slate', bg: 'linear-gradient(180deg, #f2f5fb 0%, #f8fafc 100%)', text: '#64748b' }
|
||||
}
|
||||
|
||||
// --- 卡片模拟数据 ---
|
||||
const cardData = {
|
||||
query: {
|
||||
query: '我第一象限里还有哪些事情?',
|
||||
group: 1 as const,
|
||||
tasks: [
|
||||
{ id: '1', title: '修复生产环境登录异常', priority_group: 1, deadline_at: '2024-05-20 09:00', is_completed: false },
|
||||
{ id: '2', title: '提交年度安全审计报告', priority_group: 1, deadline_at: '今天 18:00', is_completed: false },
|
||||
{ id: '3', title: '确认猎选系统的集成计划', priority_group: 1, deadline_at: '明天', is_completed: false }
|
||||
] as Task[]
|
||||
},
|
||||
receipt: {
|
||||
title: '联系供应商确认物料进度',
|
||||
group: 2 as const,
|
||||
id: 'TASK-520',
|
||||
created_at: '刚才'
|
||||
}
|
||||
}
|
||||
|
||||
// --- 交互控制 ---
|
||||
const activeView = ref<'query' | 'receipt'>('query')
|
||||
const currentTone = ref<'danger' | 'primary' | 'warning' | 'slate'>('danger')
|
||||
|
||||
const switchTone = (tone: any) => {
|
||||
currentTone.value = tone
|
||||
// 模拟不同象限的查询结果
|
||||
const toneToGroup: any = { danger: 1, primary: 2, warning: 3, slate: 4 }
|
||||
cardData.query.group = toneToGroup[tone]
|
||||
cardData.receipt.group = toneToGroup[tone]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="design-demo-page">
|
||||
<div class="page-background">
|
||||
<div class="shape shape-1"></div>
|
||||
<div class="shape shape-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="page-header">
|
||||
<div class="chip">UI Refined V3.0</div>
|
||||
<h1>业务卡片收敛方案</h1>
|
||||
<p>首页风格同步 · 软渐变不晃眼 · 语义对齐</p>
|
||||
</div>
|
||||
|
||||
<div class="demo-wrapper">
|
||||
<!-- 预览控制台 -->
|
||||
<aside class="demo-sidebar">
|
||||
<div class="sidebar-block">
|
||||
<h3>切换卡片类型</h3>
|
||||
<div class="view-btns">
|
||||
<button @click="activeView = 'query'" :class="{ active: activeView === 'query' }">查询记录</button>
|
||||
<button @click="activeView = 'receipt'" :class="{ active: activeView === 'receipt' }">创建回执</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-block">
|
||||
<h3>模拟目标象限</h3>
|
||||
<div class="tone-btns">
|
||||
<button v-for="(v, k) in quadMeta" :key="k" @click="switchTone(v.tone)" :class="[v.tone, { active: currentTone === v.tone }]">
|
||||
{{ v.tone === 'danger' ? 'Q1' : v.tone === 'primary' ? 'Q2' : v.tone === 'warning' ? 'Q3' : 'Q4' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 画布区域 -->
|
||||
<main class="demo-canvas">
|
||||
<!-- 场景 A:任务查询结果 -->
|
||||
<div v-if="activeView === 'query'" class="card-stage" :key="'query-' + currentTone">
|
||||
<div class="card-label">预览:跨象限/单象限查询结果列表</div>
|
||||
<div class="chat-inline-mockup">
|
||||
<div class="business-card-final query-results" :style="{ background: quadMeta[cardData.query.group].bg }">
|
||||
<header class="card-header-final">
|
||||
<div class="header-left">
|
||||
<p class="eyebrow">{{ quadMeta[cardData.query.group].caption }}</p>
|
||||
<h3>{{ cardData.query.query }}</h3>
|
||||
</div>
|
||||
<div class="count-badge">找到 {{ cardData.query.tasks.length }} 项</div>
|
||||
</header>
|
||||
|
||||
<div class="card-content-final">
|
||||
<div class="task-items-final">
|
||||
<div v-for="task in cardData.query.tasks" :key="task.id" class="task-item-final">
|
||||
<div class="item-check">
|
||||
<div class="check-circle" :style="{ borderColor: quadMeta[cardData.query.group].text }"></div>
|
||||
</div>
|
||||
<div class="item-body">
|
||||
<div class="item-title">{{ task.title }}</div>
|
||||
<div class="item-meta">
|
||||
<span class="q-pill" :style="{ color: quadMeta[cardData.query.group].text, background: quadMeta[cardData.query.group].text + '10' }">
|
||||
Q{{ task.priority_group }} {{ quadMeta[task.priority_group].title }}
|
||||
</span>
|
||||
<span v-if="task.deadline_at" class="time-pill">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
{{ task.deadline_at }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-more-final">查看完整任务列表</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 场景 B:任务创建回执 -->
|
||||
<div v-else class="card-stage" :key="'receipt-' + currentTone">
|
||||
<div class="card-label">预览:任务创建成功的轻量回执</div>
|
||||
<div class="chat-inline-mockup">
|
||||
<div class="business-card-final creation-receipt" :style="{ background: quadMeta[cardData.receipt.group].bg }">
|
||||
<div class="receipt-inner">
|
||||
<div class="receipt-header-final">
|
||||
<div class="success-ring-v3" :style="{ background: quadMeta[cardData.receipt.group].text + '20', color: quadMeta[cardData.receipt.group].text }">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
<div class="success-msg">
|
||||
<strong>任务已由助手成功创建</strong>
|
||||
<span>归类至:{{ quadMeta[cardData.receipt.group].title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="receipt-task-card">
|
||||
<div class="task-card-title">{{ cardData.receipt.title }}</div>
|
||||
<div class="task-card-footer">
|
||||
<span class="task-id-final">ID: {{ cardData.receipt.id }}</span>
|
||||
<span class="task-time-final">创建于今日 {{ cardData.receipt.created_at }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="receipt-actions-final">
|
||||
<button class="btn-action-outline">调整象限</button>
|
||||
<button class="btn-action-fill" :style="{ background: quadMeta[cardData.receipt.group].text }">打开详情</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.design-demo-page {
|
||||
padding: 80px 24px;
|
||||
background: #fdfdfe;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-family: 'Inter', -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.page-background { position: fixed; inset: 0; z-index: -1; }
|
||||
.shape { position: absolute; border-radius: 50%; filter: blur(80px); opacity: 0.1; }
|
||||
.shape-1 { width: 500px; height: 500px; background: #3b82f6; top: -10%; left: -10%; }
|
||||
.shape-2 { width: 400px; height: 400px; background: #f43f5e; bottom: -5%; right: -5%; }
|
||||
|
||||
.page-header { text-align: center; margin-bottom: 60px; }
|
||||
.chip { display: inline-block; padding: 4px 12px; background: #f1f5f9; color: #475569; border-radius: 100px; font-size: 11px; font-weight: 800; margin-bottom: 12px; }
|
||||
.page-header h1 { font-size: 32px; font-weight: 900; letter-spacing: -0.04em; color: #0f172a; margin-bottom: 8px; }
|
||||
.page-header p { font-size: 16px; color: #64748b; font-weight: 500; }
|
||||
|
||||
.demo-wrapper { display: flex; gap: 48px; max-width: 1000px; margin: 0 auto; align-items: flex-start; }
|
||||
|
||||
.demo-sidebar { width: 200px; display: flex; flex-direction: column; gap: 32px; position: sticky; top: 80px; }
|
||||
.sidebar-block h3 { font-size: 13px; font-weight: 800; color: #94a3b8; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.view-btns, .tone-btns { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.view-btns button, .tone-btns button { padding: 10px 14px; border: 1px solid #f1f5f9; background: white; border-radius: 12px; font-size: 13px; font-weight: 700; color: #475569; cursor: pointer; transition: all 0.2s; text-align: left; }
|
||||
.view-btns button.active { background: #0f172a; color: white; border-color: #0f172a; }
|
||||
|
||||
.tone-btns { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.tone-btns button { text-align: center; }
|
||||
.tone-btns button.danger.active { background: #fee2e2; color: #ef4444; border-color: #ef4444; }
|
||||
.tone-btns button.primary.active { background: #dbeafe; color: #3b82f6; border-color: #3b82f6; }
|
||||
.tone-btns button.warning.active { background: #fef3c7; color: #d97706; border-color: #d97706; }
|
||||
.tone-btns button.slate.active { background: #f1f5f9; color: #475569; border-color: #475569; }
|
||||
|
||||
.demo-canvas { flex: 1; min-width: 0; }
|
||||
.card-stage { animation: stage-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
|
||||
@keyframes stage-in { 0% { opacity: 0; transform: translateY(20px); } 100% { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
.card-label { font-size: 12px; color: #94a3b8; margin-bottom: 12px; font-weight: 600; padding-left: 8px; }
|
||||
.chat-inline-mockup { padding: 40px; background: rgba(255, 255, 255, 0.4); border-radius: 40px; border: 1px solid rgba(0,0,0,0.02); backdrop-filter: blur(20px); display: flex; justify-content: center; }
|
||||
|
||||
/* --- Final Business Card Refinement --- */
|
||||
.business-card-final { width: 100%; max-width: 380px; border-radius: 28px; border: 1px solid rgba(17, 24, 39, 0.08); box-shadow: 0 4px 20px rgba(0,0,0,0.02); overflow: hidden; transition: all 0.3s; }
|
||||
.business-card-final:hover { transform: translateY(-4px); box-shadow: 0 12px 40px rgba(15, 23, 42, 0.06); }
|
||||
|
||||
/* Header Sync with Homepage */
|
||||
.card-header-final { padding: 24px 24px 16px; display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.eyebrow { font-size: 11px; font-weight: 800; color: rgba(30, 41, 59, 0.5); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 6px; }
|
||||
.card-header-final h3 { font-size: 24px; font-weight: 850; color: #1e293b; margin: 0; line-height: 1.1; letter-spacing: -0.02em; }
|
||||
.count-badge { padding: 4px 12px; background: rgba(255, 255, 255, 0.8); border-radius: 100px; font-size: 11px; font-weight: 700; color: #475569; box-shadow: 0 2px 8px rgba(0,0,0,0.02); }
|
||||
|
||||
/* Content List Sync */
|
||||
.task-items-final { padding: 0 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.task-item-final { background: rgba(255, 255, 255, 0.95); border: 1px solid rgba(0,0,0,0.04); border-radius: 18px; padding: 14px 16px; display: flex; gap: 14px; align-items: center; }
|
||||
.check-circle { width: 22px; height: 22px; border-radius: 50%; border: 2px solid #e2e8f0; }
|
||||
|
||||
.item-title { font-size: 15px; font-weight: 700; color: #122033; margin-bottom: 4px; }
|
||||
.item-meta { display: flex; gap: 10px; align-items: center; }
|
||||
.q-pill { font-size: 10px; font-weight: 800; padding: 1px 8px; border-radius: 4px; }
|
||||
.time-pill { font-size: 10px; color: #94a3b8; display: flex; align-items: center; gap: 4px; font-weight: 500; }
|
||||
|
||||
.btn-more-final { width: calc(100% - 32px); margin: 16px 16px 20px; padding: 12px; border: none; background: rgba(255, 255, 255, 0.6); border-radius: 14px; font-size: 13px; font-weight: 800; color: #475569; cursor: pointer; transition: all 0.2s; }
|
||||
.btn-more-final:hover { background: white; }
|
||||
|
||||
/* Receipt Card Refinement */
|
||||
.receipt-inner { padding: 24px; display: flex; flex-direction: column; gap: 20px; }
|
||||
.receipt-header-final { display: flex; gap: 14px; align-items: center; }
|
||||
.success-ring-v3 { width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.success-msg { display: flex; flex-direction: column; }
|
||||
.success-msg strong { font-size: 15px; font-weight: 850; color: #0f172a; }
|
||||
.success-msg span { font-size: 12px; color: #64748b; font-weight: 500; }
|
||||
|
||||
.receipt-task-card { background: rgba(255, 255, 255, 0.95); border: 1px solid rgba(0,0,0,0.03); border-radius: 20px; padding: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.01); }
|
||||
.task-card-title { font-size: 17px; font-weight: 800; color: #1e293b; margin-bottom: 12px; line-height: 1.4; }
|
||||
.task-card-footer { display: flex; justify-content: space-between; font-size: 11px; font-weight: 600; color: #94a3b8; border-top: 1px solid #f1f5f9; padding-top: 10px; }
|
||||
|
||||
.receipt-actions-final { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.btn-action-outline { height: 42px; border: 1px solid #e2e8f0; background: white; border-radius: 12px; font-size: 13px; font-weight: 750; color: #475569; cursor: pointer; }
|
||||
.btn-action-fill { height: 42px; border: none; border-radius: 12px; color: white; font-size: 13px; font-weight: 800; cursor: pointer; box-shadow: 0 8px 20px rgba(0,0,0,0.1); }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 800px) {
|
||||
.demo-wrapper { flex-direction: column; }
|
||||
.demo-sidebar { width: 100%; position: static; gap: 20px; }
|
||||
.tone-btns { grid-template-columns: repeat(4, 1fr); }
|
||||
.chat-inline-mockup { padding: 20px; border-radius: 24px; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,356 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
// --- 模拟数据 ---
|
||||
const tasks = ref([
|
||||
{ id: 1, title: '完成系统架构重构方案', is_completed: false, ddl: '2024-04-25T10:00:00', priority: 1 },
|
||||
{ id: 2, title: '准备技术分享 PPT', is_completed: true, ddl: '2024-04-24T14:00:00', priority: 2 },
|
||||
{ id: 3, title: '代码 Review:用户模块', is_completed: false, ddl: '2024-04-23T18:00:00', priority: 3 },
|
||||
])
|
||||
|
||||
const quadrantMap = {
|
||||
1: { label: '重要且紧急', color: '#ef4444' },
|
||||
2: { label: '重要不紧急', color: '#3b82f6' },
|
||||
3: { label: '简单不重要', color: '#f59e0b' },
|
||||
4: { label: '不简单不重要', color: '#64748b' },
|
||||
}
|
||||
|
||||
// --- 交互状态 ---
|
||||
const editDialogVisible = ref(false)
|
||||
const currentEditingTask = reactive({
|
||||
id: 0,
|
||||
title: '',
|
||||
ddl: '',
|
||||
priority: 1
|
||||
})
|
||||
|
||||
// 1. 切换状态 (独立响应区)
|
||||
const toggleTask = (task: any) => {
|
||||
task.is_completed = !task.is_completed
|
||||
ElMessage.success({
|
||||
message: task.is_completed ? '标记为已完成' : '已恢复为待办',
|
||||
customClass: 'premium-msg'
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 编辑任务 (正中主响应区)
|
||||
const openEdit = (task: any) => {
|
||||
currentEditingTask.id = task.id
|
||||
currentEditingTask.title = task.title
|
||||
currentEditingTask.ddl = task.ddl
|
||||
currentEditingTask.priority = task.priority
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 3. 删除任务 (悬浮侧响应区)
|
||||
const deleteTask = (task: any) => {
|
||||
ElMessageBox.confirm('确定要删除这个任务吗?此操作不可撤销。', '确认删除', {
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
confirmButtonClass: 'flat-btn-danger',
|
||||
cancelButtonClass: 'flat-btn-ghost',
|
||||
center: true,
|
||||
}).then(() => {
|
||||
tasks.value = tasks.value.filter(t => t.id !== task.id)
|
||||
ElMessage.success('任务已安全移除')
|
||||
})
|
||||
}
|
||||
|
||||
const saveEdit = () => {
|
||||
const task = tasks.value.find(t => t.id === currentEditingTask.id)
|
||||
if (task) {
|
||||
task.title = currentEditingTask.title
|
||||
task.ddl = currentEditingTask.ddl
|
||||
task.priority = currentEditingTask.priority
|
||||
editDialogVisible.value = false
|
||||
ElMessage.success('任务已更新')
|
||||
}
|
||||
}
|
||||
|
||||
const formatSimpleDate = (dateStr: string) => {
|
||||
if (!dateStr) return '无截止时间'
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getMonth() + 1}-${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demo-page">
|
||||
<div class="demo-card">
|
||||
<header class="card-header">
|
||||
<div class="card-title">
|
||||
<p class="subtitle">UPGRADED INTERACTION</p>
|
||||
<h2>全参数扁平化示例</h2>
|
||||
</div>
|
||||
<span class="count-badge">{{ tasks.length }} 项</span>
|
||||
</header>
|
||||
|
||||
<div class="task-list">
|
||||
<TransitionGroup name="task-anime">
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="task-item"
|
||||
:class="{ 'is-completed': task.is_completed }"
|
||||
>
|
||||
<!-- 区域1: 独立勾选框 -->
|
||||
<div class="check-box-wrapper" @click.stop="toggleTask(task)">
|
||||
<div class="check-box-inner">
|
||||
<svg v-if="task.is_completed" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 区域2: 文本主体内容 (点击触发编辑) -->
|
||||
<div class="task-body" @click="openEdit(task)">
|
||||
<div class="task-text-row">
|
||||
<span class="task-text">{{ task.title }}</span>
|
||||
<span class="priority-tag" :style="{ color: quadrantMap[task.priority as keyof typeof quadrantMap].color }">
|
||||
{{ quadrantMap[task.priority as keyof typeof quadrantMap].label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="task-meta">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||
{{ formatSimpleDate(task.ddl) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 区域3: 悬浮操作菜单 -->
|
||||
<div class="hover-actions-panel">
|
||||
<button class="action-btn-mini delete-btn" @click.stop="deleteTask(task)">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<div class="footer-tip">快捷操作:点击任务主体进行全参数编辑</div>
|
||||
</div>
|
||||
|
||||
<!-- 深度扁平化编辑弹窗 -->
|
||||
<el-dialog
|
||||
v-model="editDialogVisible"
|
||||
title="编辑详细参数"
|
||||
width="440px"
|
||||
align-center
|
||||
class="flat-dialog"
|
||||
:show-close="false"
|
||||
>
|
||||
<div class="flat-form">
|
||||
<div class="form-item">
|
||||
<label>任务标题</label>
|
||||
<input v-model="currentEditingTask.title" class="flat-input" placeholder="输入任务标题..." />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-item half">
|
||||
<label>优先级象限</label>
|
||||
<el-select v-model="currentEditingTask.priority" popper-class="flat-select-popper" class="flat-select">
|
||||
<el-option v-for="(v, k) in quadrantMap" :key="k" :label="v.label" :value="Number(k)" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="form-item half">
|
||||
<label>截止日期</label>
|
||||
<el-date-picker
|
||||
v-model="currentEditingTask.ddl"
|
||||
type="datetime"
|
||||
placeholder="选择时间"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
value-format="YYYY-MM-DD[T]HH:mm:ss"
|
||||
class="flat-picker"
|
||||
popper-class="flat-picker-popper"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flat-footer">
|
||||
<button class="flat-btn ghost" @click="editDialogVisible = false">放弃修改</button>
|
||||
<button class="flat-btn primary" @click="saveEdit">保存并同步</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 基础布局 */
|
||||
.demo-page {
|
||||
min-height: 100vh;
|
||||
background: #fdfdfd;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.demo-card {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #eeeeee;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.03);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
color: #94a3b8;
|
||||
letter-spacing: 0.2em;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.card-title h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 列表样式 */
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
position: relative;
|
||||
background: #ffffff;
|
||||
border: 1px solid #f1f5f9;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
border-color: #e2e8f0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.check-box-inner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
border: 2px solid #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
background: #fff;
|
||||
margin-right: 14px;
|
||||
}
|
||||
|
||||
.is-completed .check-box-inner {
|
||||
background: #10b981;
|
||||
border-color: #10b981;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.task-body { flex: 1; min-width: 0; }
|
||||
.task-text-row { display: flex; align-items: center; gap: 8px; margin-bottom: 2px; }
|
||||
.task-text { font-size: 15px; font-weight: 600; color: #1e293b; }
|
||||
.is-completed .task-text { color: #94a3b8; text-decoration: line-through; }
|
||||
|
||||
.priority-tag { font-size: 11px; font-weight: 700; background: rgba(0,0,0,0.03); padding: 2px 6px; border-radius: 4px; }
|
||||
.task-meta { font-size: 12px; color: #94a3b8; display: flex; align-items: center; gap: 4px; }
|
||||
|
||||
.hover-actions-panel {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.task-item:hover .hover-actions-panel { opacity: 1; }
|
||||
.action-btn-mini { border: none; background: transparent; color: #ef4444; cursor: pointer; padding: 4px; border-radius: 6px; }
|
||||
.action-btn-mini:hover { background: #fee2e2; }
|
||||
|
||||
/* 深度扁平化弹窗 - 关键美化部分 */
|
||||
:global(.flat-dialog) {
|
||||
border-radius: 16px !important;
|
||||
border: 1px solid #f1f5f9 !important;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1) !important;
|
||||
}
|
||||
|
||||
:global(.flat-dialog .el-dialog__header) {
|
||||
padding: 24px 28px 10px !important;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
:global(.flat-dialog .el-dialog__title) {
|
||||
font-size: 16px !important;
|
||||
font-weight: 800 !important;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
:global(.flat-dialog .el-dialog__body) {
|
||||
padding: 10px 28px 24px !important;
|
||||
}
|
||||
|
||||
.flat-form { display: flex; flex-direction: column; gap: 20px; }
|
||||
.form-row { display: flex; gap: 16px; }
|
||||
.form-item { display: flex; flex-direction: column; gap: 8px; }
|
||||
.form-item.half { flex: 1; }
|
||||
.form-item label { font-size: 12px; font-weight: 800; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
/* 纯扁平输入框 */
|
||||
.flat-input {
|
||||
height: 42px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 0 14px;
|
||||
background: #f8fafc;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
font-weight: 600;
|
||||
}
|
||||
.flat-input:focus { border-color: #3b82f6; background: #fff; }
|
||||
|
||||
/* Element Plus 深度覆盖 */
|
||||
:global(.flat-select .el-select__wrapper),
|
||||
:global(.flat-picker.el-input__wrapper) {
|
||||
background-color: #f8fafc !important;
|
||||
box-shadow: none !important;
|
||||
border: 1px solid #e2e8f0 !important;
|
||||
border-radius: 10px !important;
|
||||
height: 42px !important;
|
||||
}
|
||||
|
||||
.flat-footer { display: flex; justify-content: flex-end; gap: 10px; border-top: 1px solid #f1f5f9; padding-top: 20px; }
|
||||
.flat-btn { height: 42px; padding: 0 20px; border-radius: 10px; font-weight: 700; font-size: 13px; cursor: pointer; border: none; transition: all 0.2s; }
|
||||
.flat-btn.primary { background: #0f172a; color: #fff; }
|
||||
.flat-btn.primary:hover { background: #334155; }
|
||||
.flat-btn.ghost { background: transparent; color: #64748b; }
|
||||
.flat-btn.ghost:hover { background: #f1f5f9; }
|
||||
|
||||
.footer-tip { margin-top: 24px; font-size: 12px; color: #94a3b8; text-align: center; }
|
||||
|
||||
/* 动画 */
|
||||
.task-anime-enter-active { transition: all 0.3s ease; }
|
||||
.task-anime-enter-from { opacity: 0; transform: scale(0.95); }
|
||||
.task-anime-leave-to { opacity: 0; transform: scale(1.05); }
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user