Version: 0.9.52.dev.260428

后端:
1. 工具结果结构化切流继续推进:schedule 读工具改为“父包 adapter + 子包 view builder”,`queue_pop_head` / `queue_skip_head` 脱离 legacy wrapper,`analyze_health` / `analyze_rhythm` 补齐 `schedule.analysis_result` 诊断卡片。
2. 非 schedule 工具补齐专属结果协议:`web_search` / `web_fetch`、`upsert_task_class`、`context_tools_add` / `context_tools_remove` 全部接入结构化 `ResultView`,注册表继续去 legacy wrapper,同时保持原始 `ObservationText` 供模型链路复用。
3. 工具展示细节继续收口:参数本地化补齐 `domain` / `packs` / `mode` / `all`,deliver 阶段补发段落分隔,避免 execute 与总结正文黏连。

前端:
4. `ToolCardRenderer` 升级为多协议通用渲染器,补齐 read / analysis / web / taskclass / context 卡片渲染、参数折叠区、未知协议兜底与操作明细展示。
5. `AssistantPanel` 修正 `tool_result` 结果回填与卡片布局宽度问题,并新增结构化卡片 fixture / mock 调试入口,便于整体验收。

仓库:
6. 更新工具结果结构化交接文档,补记第四批切流范围、当前切流点与后续收尾建议。
This commit is contained in:
Losita
2026-04-28 20:22:22 +08:00
parent 1a5b2ecd73
commit d89e2830a9
38 changed files with 9180 additions and 1577 deletions

View File

@@ -0,0 +1,458 @@
package newagenttools
import (
"strings"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
taskclassresult "github.com/LoveLosita/smartflow/backend/newAgent/tools/taskclass_result"
)
type taskClassUpsertExecutionInput struct {
Result taskClassUpsertToolResult
Normalized *TaskClassUpsertInput
}
// NewTaskClassUpsertToolHandler 返回 upsert_task_class 的结构化结果 handler。
//
// 职责边界:
// 1. 只负责参数解析、校验、调用依赖与包装结构化结果;
// 2. 不改变既有写库语义、confirm 语义与 observation JSON 合约;
// 3. 老实现暂以 legacy 函数保留,便于本轮并行迁移后回溯与对照。
func NewTaskClassUpsertToolHandler(deps TaskClassWriteDeps) ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
_ = state
if deps.UpsertTaskClass == nil {
return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
Result: 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 buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
Result: 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 buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
Result: 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 buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
Result: taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: issues},
Error: strings.Join(issues, ""),
ErrorCode: "validation_failed",
},
Normalized: &input,
})
}
result, err := deps.UpsertTaskClass(userID, input)
if err != nil {
return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
Result: taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{"持久化写入失败"}},
Error: err.Error(),
ErrorCode: "persist_failed",
},
Normalized: &input,
})
}
if result.TaskClassID <= 0 {
return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
Result: taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{"未返回有效 task_class_id"}},
Error: "写入后未返回有效 task_class_id",
ErrorCode: "invalid_persist_result",
},
Normalized: &input,
})
}
return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
Result: taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: true,
TaskClassID: result.TaskClassID,
Created: result.Created,
Validation: taskClassValidationResult{OK: true, Issues: []string{}},
Error: "",
ErrorCode: "",
},
Normalized: &input,
})
}
}
// buildTaskClassUpsertExecutionResult 负责把 upsert_task_class 的原始 observation 包装成结构化卡片。
//
// 步骤化说明:
// 1. 先沿用 LegacyResult 保留 observation、参数预览、错误码提取等既有链路能力
// 2. 再把规范化请求摘要和写入结果投影到 taskclass_result 子包,避免展示层反向依赖父包;
// 3. 最后只替换 ResultView / Summary不改写写库语义、confirm 语义和原始错误文本。
func buildTaskClassUpsertExecutionResult(
args map[string]any,
input taskClassUpsertExecutionInput,
) ToolExecutionResult {
observation := marshalTaskClassUpsertResult(input.Result)
legacy := LegacyResult("upsert_task_class", args, observation)
requestSummary := buildTaskClassRequestSummary(args, input.Normalized)
view := taskclassresult.BuildUpsertTaskClassView(taskclassresult.BuildUpsertTaskClassViewInput{
Status: legacy.Status,
Observation: observation,
Result: taskclassresult.UpsertResult{
Tool: strings.TrimSpace(input.Result.Tool),
Success: input.Result.Success,
TaskClassID: input.Result.TaskClassID,
Created: input.Result.Created,
Error: strings.TrimSpace(input.Result.Error),
ErrorCode: strings.TrimSpace(input.Result.ErrorCode),
ValidationOK: input.Result.Validation.OK,
ValidationIssues: append([]string(nil), input.Result.Validation.Issues...),
},
Request: requestSummary,
MachinePayload: buildTaskClassMachinePayload(input.Result, requestSummary),
})
return buildTaskClassWriteExecutionResult(legacy, args, view)
}
// buildTaskClassWriteExecutionResult 负责把子包纯展示视图包回父包统一协议。
func buildTaskClassWriteExecutionResult(
legacy ToolExecutionResult,
args map[string]any,
view taskclassresult.WriteResultView,
) ToolExecutionResult {
result := legacy
status := normalizeToolStatus(result.Status)
if status == "" {
status = ToolStatusDone
}
if collapsedStatus, ok := readStringAnyMap(view.Collapsed, "status"); ok {
if normalized := normalizeToolStatus(collapsedStatus); normalized != "" {
status = normalized
}
}
collapsed := cloneAnyMap(view.Collapsed)
if collapsed == nil {
collapsed = make(map[string]any)
}
expanded := cloneAnyMap(view.Expanded)
if expanded == nil {
expanded = make(map[string]any)
}
collapsed["status"] = status
if _, exists := collapsed["status_label"]; !exists {
collapsed["status_label"] = resolveToolStatusLabelCN(status)
}
if _, exists := expanded["raw_text"]; !exists {
expanded["raw_text"] = result.ObservationText
}
viewType := strings.TrimSpace(view.ViewType)
if viewType == "" {
viewType = taskclassresult.ViewTypeWriteResult
}
version := view.Version
if version <= 0 {
version = taskclassresult.ViewVersionWriteResult
}
result.Status = status
result.Success = status == ToolStatusDone
result.ResultView = &ToolDisplayView{
ViewType: viewType,
Version: version,
Collapsed: collapsed,
Expanded: expanded,
}
if title, ok := readStringAnyMap(collapsed, "title"); ok {
result.Summary = title
}
if !result.Success {
errorCode, errorMessage := extractToolErrorInfo(result.ObservationText, status)
if strings.TrimSpace(result.ErrorCode) == "" {
result.ErrorCode = strings.TrimSpace(errorCode)
}
if strings.TrimSpace(result.ErrorMessage) == "" {
result.ErrorMessage = strings.TrimSpace(errorMessage)
}
}
return EnsureToolResultDefaults(result, args)
}
func buildTaskClassRequestSummary(
args map[string]any,
normalized *TaskClassUpsertInput,
) taskclassresult.RequestSummary {
summary := buildTaskClassRequestSummaryFromArgs(args)
if normalized == nil {
return summary
}
summary.RequestedID = normalized.ID
summary.Name = strings.TrimSpace(normalized.Request.Name)
summary.Mode = strings.TrimSpace(normalized.Request.Mode)
summary.StartDate = strings.TrimSpace(normalized.Request.StartDate)
summary.EndDate = strings.TrimSpace(normalized.Request.EndDate)
summary.SubjectType = strings.TrimSpace(normalized.Request.SubjectType)
summary.DifficultyLevel = strings.TrimSpace(normalized.Request.DifficultyLevel)
summary.CognitiveIntensity = strings.TrimSpace(normalized.Request.CognitiveIntensity)
summary.TotalSlots = normalized.Request.Config.TotalSlots
summary.AllowFillerCourse = normalized.Request.Config.AllowFillerCourse
summary.Strategy = strings.TrimSpace(normalized.Request.Config.Strategy)
summary.ExcludedSlots = cloneIntSlice(normalized.Request.Config.ExcludedSlots)
summary.ExcludedDaysOfWeek = cloneIntSlice(normalized.Request.Config.ExcludedDaysOfWeek)
summary.Source = strings.TrimSpace(normalized.Source)
summary.Items = buildTaskClassItemsSummary(normalized.Request.Items)
return summary
}
func buildTaskClassRequestSummaryFromArgs(args map[string]any) taskclassresult.RequestSummary {
summary := taskclassresult.RequestSummary{
RequestedID: readOptionalInt(args, "id"),
Source: readOptionalString(args, "source"),
Items: make([]taskclassresult.TaskClassItemSummary, 0),
ExcludedSlots: make([]int, 0),
ExcludedDaysOfWeek: make([]int, 0),
}
taskClassMap, _ := readAnyMap(args["task_class"])
if taskClassMap == nil {
return summary
}
summary.Name = strings.TrimSpace(readAnyString(taskClassMap["name"]))
summary.Mode = strings.TrimSpace(readAnyString(taskClassMap["mode"]))
summary.StartDate = strings.TrimSpace(readAnyString(taskClassMap["start_date"]))
summary.EndDate = strings.TrimSpace(readAnyString(taskClassMap["end_date"]))
summary.SubjectType = strings.TrimSpace(readAnyString(taskClassMap["subject_type"]))
summary.DifficultyLevel = strings.TrimSpace(readAnyString(taskClassMap["difficulty_level"]))
summary.CognitiveIntensity = strings.TrimSpace(readAnyString(taskClassMap["cognitive_intensity"]))
configMap, _ := readAnyMap(taskClassMap["config"])
summary.TotalSlots = readAnyInt(configMap["total_slots"])
summary.AllowFillerCourse = readAnyBool(configMap["allow_filler_course"])
summary.Strategy = strings.TrimSpace(readAnyString(configMap["strategy"]))
summary.ExcludedSlots = readAnyIntSlice(configMap["excluded_slots"])
summary.ExcludedDaysOfWeek = readAnyIntSlice(configMap["excluded_days_of_week"])
rawItems := taskClassMap["items"]
if topLevelItems, exists := args["items"]; exists {
rawItems = topLevelItems
}
summary.Items = buildTaskClassItemsSummaryFromRaw(rawItems)
return summary
}
func buildTaskClassItemsSummary(items []model.UserAddTaskClassItemRequest) []taskclassresult.TaskClassItemSummary {
if len(items) == 0 {
return make([]taskclassresult.TaskClassItemSummary, 0)
}
out := make([]taskclassresult.TaskClassItemSummary, 0, len(items))
for _, item := range items {
summary := taskclassresult.TaskClassItemSummary{
ID: item.ID,
Order: item.Order,
Content: strings.TrimSpace(item.Content),
}
if item.EmbeddedTime != nil {
summary.EmbeddedWeek = item.EmbeddedTime.Week
summary.EmbeddedDay = item.EmbeddedTime.DayOfWeek
summary.EmbeddedSectionFrom = item.EmbeddedTime.SectionFrom
summary.EmbeddedSectionTo = item.EmbeddedTime.SectionTo
}
out = append(out, summary)
}
return out
}
func buildTaskClassItemsSummaryFromRaw(raw any) []taskclassresult.TaskClassItemSummary {
rawList, ok := raw.([]any)
if !ok || len(rawList) == 0 {
return make([]taskclassresult.TaskClassItemSummary, 0)
}
out := make([]taskclassresult.TaskClassItemSummary, 0, len(rawList))
for index, row := range rawList {
itemMap, ok := row.(map[string]any)
if !ok {
continue
}
summary := taskclassresult.TaskClassItemSummary{
ID: readAnyInt(itemMap["id"]),
Order: maxPositiveInt(readAnyInt(itemMap["order"]), index+1),
Content: strings.TrimSpace(firstNonEmptyString(
readAnyString(itemMap["content"]),
readAnyString(itemMap["description"]),
readAnyString(itemMap["title"]),
readAnyString(itemMap["name"]),
)),
}
if embeddedMap, ok := readAnyMap(itemMap["embedded_time"]); ok {
summary.EmbeddedWeek = readAnyInt(embeddedMap["week"])
summary.EmbeddedDay = readAnyInt(embeddedMap["day_of_week"])
summary.EmbeddedSectionFrom = readAnyInt(embeddedMap["section_from"])
summary.EmbeddedSectionTo = readAnyInt(embeddedMap["section_to"])
}
out = append(out, summary)
}
return out
}
func buildTaskClassMachinePayload(
result taskClassUpsertToolResult,
request taskclassresult.RequestSummary,
) map[string]any {
return map[string]any{
"parsed_result": map[string]any{
"tool": strings.TrimSpace(result.Tool),
"success": result.Success,
"task_class_id": result.TaskClassID,
"created": result.Created,
"validation": map[string]any{
"ok": result.Validation.OK,
"issues": append([]string(nil), result.Validation.Issues...),
},
"error": strings.TrimSpace(result.Error),
"error_code": strings.TrimSpace(result.ErrorCode),
},
"input_summary": map[string]any{
"requested_id": request.RequestedID,
"name": request.Name,
"mode": request.Mode,
"start_date": request.StartDate,
"end_date": request.EndDate,
"subject_type": request.SubjectType,
"difficulty_level": request.DifficultyLevel,
"cognitive_intensity": request.CognitiveIntensity,
"total_slots": request.TotalSlots,
"allow_filler_course": request.AllowFillerCourse,
"strategy": request.Strategy,
"excluded_slots": cloneIntSlice(request.ExcludedSlots),
"excluded_days_of_week": cloneIntSlice(request.ExcludedDaysOfWeek),
"source": request.Source,
"items": buildTaskClassItemMachinePayload(request.Items),
},
}
}
func buildTaskClassItemMachinePayload(items []taskclassresult.TaskClassItemSummary) []map[string]any {
if len(items) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
out = append(out, map[string]any{
"id": item.ID,
"order": item.Order,
"content": item.Content,
"embedded_week": item.EmbeddedWeek,
"embedded_day": item.EmbeddedDay,
"section_from": item.EmbeddedSectionFrom,
"section_to": item.EmbeddedSectionTo,
})
}
return out
}
func readOptionalString(args map[string]any, key string) string {
if len(args) == 0 {
return ""
}
return strings.TrimSpace(readAnyString(args[key]))
}
func readOptionalInt(args map[string]any, key string) int {
if len(args) == 0 {
return 0
}
return readAnyInt(args[key])
}
func readAnyInt(raw any) int {
value, ok := readUpsertInt(raw)
if !ok {
return 0
}
return value
}
func readAnyBool(raw any) bool {
value, ok := raw.(bool)
return ok && value
}
func readAnyIntSlice(raw any) []int {
switch typed := raw.(type) {
case []int:
return cloneIntSlice(typed)
case []any:
out := make([]int, 0, len(typed))
for _, item := range typed {
value, ok := readUpsertInt(item)
if !ok {
continue
}
out = append(out, value)
}
return out
default:
return make([]int, 0)
}
}
func cloneIntSlice(values []int) []int {
if len(values) == 0 {
return make([]int, 0)
}
out := make([]int, len(values))
copy(out, values)
return out
}
func maxPositiveInt(left int, right int) int {
if left <= 0 {
return right
}
if right <= 0 {
return left
}
if left > right {
return left
}
return right
}