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,397 @@
package taskclass_result
import (
"fmt"
"strings"
)
// 说明:
// 1. schedule_read / schedule_analysis 已经各自带有一套卡片 helper
// 2. 这一轮只迁 taskclass 写入结果,如果现在强行把前三批 helper 回抽成公共层,会扩大回归面;
// 3. 因此本包只保留 taskclass.write_result 所需的最小 helper待非 schedule 主链稳定后再统一评估抽象。
func buildWriteResultView(
status string,
title string,
subtitle string,
metrics []MetricField,
items []ItemView,
sections []map[string]any,
observation string,
machinePayload map[string]any,
) WriteResultView {
normalizedStatus := normalizeStatus(status)
if normalizedStatus == "" {
normalizedStatus = StatusDone
}
collapsed := map[string]any{
"title": strings.TrimSpace(title),
"subtitle": strings.TrimSpace(subtitle),
"status": normalizedStatus,
"status_label": resolveStatusLabelCN(normalizedStatus),
"metrics": metricListToMaps(metrics),
}
expanded := map[string]any{
"items": itemListToMaps(items),
"sections": cloneSectionList(sections),
"raw_text": observation,
}
if len(machinePayload) > 0 {
expanded["machine_payload"] = cloneAnyMap(machinePayload)
}
return WriteResultView{
ViewType: ViewTypeWriteResult,
Version: ViewVersionWriteResult,
Collapsed: collapsed,
Expanded: expanded,
}
}
func buildMetric(label string, value string) MetricField {
return MetricField{
Label: strings.TrimSpace(label),
Value: strings.TrimSpace(value),
}
}
func buildKVField(label string, value string) KVField {
return KVField{
Label: strings.TrimSpace(label),
Value: strings.TrimSpace(value),
}
}
func buildItem(title string, subtitle string, tags []string, detailLines []string, meta map[string]any) ItemView {
return ItemView{
Title: strings.TrimSpace(title),
Subtitle: strings.TrimSpace(subtitle),
Tags: normalizeStringSlice(tags),
DetailLines: normalizeStringSlice(detailLines),
Meta: cloneAnyMap(meta),
}
}
func buildItemsSection(title string, items []ItemView) map[string]any {
return map[string]any{
"type": "items",
"title": strings.TrimSpace(title),
"items": itemListToMaps(items),
}
}
func buildKVSection(title string, fields []KVField) map[string]any {
rows := make([]map[string]any, 0, len(fields))
for _, field := range fields {
label := strings.TrimSpace(field.Label)
value := strings.TrimSpace(field.Value)
if label == "" || value == "" {
continue
}
rows = append(rows, map[string]any{
"label": label,
"value": value,
})
}
return map[string]any{
"type": "kv",
"title": strings.TrimSpace(title),
"fields": rows,
}
}
func buildCalloutSection(title string, subtitle string, tone string, detailLines []string) map[string]any {
return map[string]any{
"type": "callout",
"title": strings.TrimSpace(title),
"subtitle": strings.TrimSpace(subtitle),
"tone": strings.TrimSpace(tone),
"detail_lines": normalizeStringSlice(detailLines),
}
}
func normalizeStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case StatusDone:
return StatusDone
case StatusBlocked:
return StatusBlocked
case StatusFailed:
return StatusFailed
default:
return ""
}
}
func resolveStatusLabelCN(status string) string {
switch normalizeStatus(status) {
case StatusDone:
return "已完成"
case StatusBlocked:
return "已阻断"
default:
return "失败"
}
}
func formatSourceCN(source string) string {
switch strings.ToLower(strings.TrimSpace(source)) {
case "chat":
return "对话"
case "memory":
return "记忆"
case "web":
return "网页"
case "":
return "未标注"
default:
return strings.TrimSpace(source)
}
}
func formatModeCN(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "auto":
return "自动排布"
case "manual":
return "手动维护"
default:
return fallbackText(mode, "未标注")
}
}
func formatStrategyCN(strategy string) string {
switch strings.ToLower(strings.TrimSpace(strategy)) {
case "steady":
return "稳态推进"
case "rapid":
return "快速推进"
default:
return fallbackText(strategy, "未标注")
}
}
func formatSubjectTypeCN(subjectType string) string {
switch strings.ToLower(strings.TrimSpace(subjectType)) {
case "quantitative":
return "计算型"
case "memory":
return "记忆型"
case "reading":
return "阅读型"
case "mixed":
return "混合型"
default:
return fallbackText(subjectType, "未标注")
}
}
func formatLevelCN(level string) string {
switch strings.ToLower(strings.TrimSpace(level)) {
case "low":
return "低"
case "medium":
return "中"
case "high":
return "高"
default:
return fallbackText(level, "未标注")
}
}
func formatBoolCN(value bool) string {
if value {
return "是"
}
return "否"
}
func formatDateRangeCN(start string, end string) string {
start = strings.TrimSpace(start)
end = strings.TrimSpace(end)
switch {
case start != "" && end != "":
return fmt.Sprintf("%s 至 %s", start, end)
case start != "":
return start
case end != "":
return end
default:
return "未标注"
}
}
func formatIntListCN(values []int, emptyText string, formatFn func(int) string) string {
if len(values) == 0 {
return strings.TrimSpace(emptyText)
}
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, formatFn(value))
}
return strings.Join(parts, "、")
}
func formatWeekdayCN(day int) string {
switch day {
case 1:
return "周一"
case 2:
return "周二"
case 3:
return "周三"
case 4:
return "周四"
case 5:
return "周五"
case 6:
return "周六"
case 7:
return "周日"
default:
return fmt.Sprintf("星期%d", day)
}
}
func formatEmbeddedTimeCN(item TaskClassItemSummary) string {
if item.EmbeddedWeek <= 0 || item.EmbeddedDay <= 0 || item.EmbeddedSectionFrom <= 0 || item.EmbeddedSectionTo <= 0 {
return "未指定"
}
return fmt.Sprintf(
"第%d周 %s 第%d-%d节",
item.EmbeddedWeek,
formatWeekdayCN(item.EmbeddedDay),
item.EmbeddedSectionFrom,
item.EmbeddedSectionTo,
)
}
func normalizeStringSlice(values []string) []string {
if len(values) == 0 {
return make([]string, 0)
}
out := make([]string, 0, len(values))
for _, value := range values {
text := strings.TrimSpace(value)
if text == "" {
continue
}
out = append(out, text)
}
if len(out) == 0 {
return make([]string, 0)
}
return out
}
func truncateText(text string, limit int) string {
runes := []rune(strings.TrimSpace(text))
if len(runes) == 0 {
return "未填写内容"
}
if limit <= 0 || len(runes) <= limit {
return string(runes)
}
return string(runes[:limit]) + "..."
}
func fallbackText(text string, fallback string) string {
if strings.TrimSpace(text) == "" {
return strings.TrimSpace(fallback)
}
return strings.TrimSpace(text)
}
func metricListToMaps(metrics []MetricField) []map[string]any {
if len(metrics) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(metrics))
for _, metric := range metrics {
label := strings.TrimSpace(metric.Label)
value := strings.TrimSpace(metric.Value)
if label == "" || value == "" {
continue
}
out = append(out, map[string]any{
"label": label,
"value": value,
})
}
if len(out) == 0 {
return make([]map[string]any, 0)
}
return out
}
func itemListToMaps(items []ItemView) []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 {
row := map[string]any{
"title": strings.TrimSpace(item.Title),
"subtitle": strings.TrimSpace(item.Subtitle),
"tags": normalizeStringSlice(item.Tags),
"detail_lines": normalizeStringSlice(item.DetailLines),
}
if len(item.Meta) > 0 {
row["meta"] = cloneAnyMap(item.Meta)
}
out = append(out, row)
}
return out
}
func cloneSectionList(sections []map[string]any) []map[string]any {
if len(sections) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(sections))
for _, section := range sections {
out = append(out, cloneAnyMap(section))
}
return out
}
func cloneAnyMap(input map[string]any) map[string]any {
if len(input) == 0 {
return nil
}
out := make(map[string]any, len(input))
for key, value := range input {
out[key] = cloneAnyValue(value)
}
return out
}
func cloneAnyValue(value any) any {
switch typed := value.(type) {
case map[string]any:
return cloneAnyMap(typed)
case []map[string]any:
out := make([]map[string]any, 0, len(typed))
for _, item := range typed {
out = append(out, cloneAnyMap(item))
}
return out
case []any:
out := make([]any, 0, len(typed))
for _, item := range typed {
out = append(out, cloneAnyValue(item))
}
return out
case []string:
out := make([]string, len(typed))
copy(out, typed)
return out
case []int:
out := make([]int, len(typed))
copy(out, typed)
return out
default:
return typed
}
}

View File

@@ -0,0 +1,114 @@
package taskclass_result
const (
// ViewTypeWriteResult 固定为任务类写入结果卡片的前端识别类型。
ViewTypeWriteResult = "taskclass.write_result"
// ViewVersionWriteResult 固定为当前任务类写入结果结构版本。
ViewVersionWriteResult = 1
// 这里不依赖父包状态常量,避免子包反向 import tools 形成循环依赖。
StatusDone = "done"
StatusFailed = "failed"
StatusBlocked = "blocked"
)
// WriteResultView 是子包暴露给父包 adapter 的纯展示结构。
//
// 职责边界:
// 1. 只承载 view_type / version / collapsed / expanded 四段展示数据;
// 2. 不负责 ToolExecutionResult、SSE、timeline 等父包协议;
// 3. collapsed / expanded 继续保留 map 形态,便于父包直接桥接。
type WriteResultView struct {
ViewType string `json:"view_type"`
Version int `json:"version"`
Collapsed map[string]any `json:"collapsed"`
Expanded map[string]any `json:"expanded"`
}
// MetricField 是 collapsed.metrics 的轻量键值结构。
type MetricField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// KVField 是 expanded.kv section 的轻量键值结构。
type KVField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// ItemView 是 expanded.items / section.items 的通用结构。
type ItemView struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Tags []string `json:"tags"`
DetailLines []string `json:"detail_lines"`
Meta map[string]any `json:"meta,omitempty"`
}
// UpsertResult 承载写入 observation 里可稳定提取的结果字段。
//
// 职责边界:
// 1. 只描述 upsert_task_class 的结果,不承载请求参数;
// 2. ValidationIssues 仅用于展示校验失败原因,不负责重新校验;
// 3. Error / ErrorCode 保持和 observation 一致,避免展示层发明新语义。
type UpsertResult struct {
Tool string
Success bool
TaskClassID int
Created bool
Error string
ErrorCode string
ValidationOK bool
ValidationIssues []string
}
// RequestSummary 描述写入请求中适合前端展示的稳定字段摘要。
//
// 职责边界:
// 1. 只保留卡片展示稳定需要的信息,不回传完整原始 args
// 2. RequestedID 表示调用方请求更新的任务类 ID不等同于持久化后的真实 ID
// 3. Items 已经是展示层可直接消费的扁平摘要,不再承担业务校验职责。
type RequestSummary struct {
RequestedID int
Name string
Mode string
StartDate string
EndDate string
SubjectType string
DifficultyLevel string
CognitiveIntensity string
TotalSlots int
AllowFillerCourse bool
Strategy string
ExcludedSlots []int
ExcludedDaysOfWeek []int
Source string
Items []TaskClassItemSummary
}
// TaskClassItemSummary 描述单个任务项的展示摘要。
type TaskClassItemSummary struct {
ID int
Order int
Content string
EmbeddedWeek int
EmbeddedDay int
EmbeddedSectionFrom int
EmbeddedSectionTo int
}
// BuildUpsertTaskClassViewInput 是 upsert_task_class 卡片 builder 的输入。
//
// 职责边界:
// 1. Observation 必须原样传入,供 raw_text 保留原始结果;
// 2. MachinePayload 只作为调试/后续交互的隐藏字段,不参与标题摘要计算;
// 3. Status 由父包 adapter 传入,子包只负责标准化,不重新推断工具执行链路。
type BuildUpsertTaskClassViewInput struct {
Status string
Observation string
Result UpsertResult
Request RequestSummary
MachinePayload map[string]any
}

View File

@@ -0,0 +1,242 @@
package taskclass_result
import "fmt"
// BuildUpsertTaskClassView 把 upsert_task_class 的稳定结果摘要转成任务类写入卡片。
//
// 步骤化说明:
// 1. 先基于父包传入的 Status/Result 生成折叠态标题、摘要和稳定指标,保证成功/失败都能快速扫读;
// 2. 再把任务类字段、配置、任务项列表和失败原因拆成 kv/items/callout section避免前端继续回退 raw_text
// 3. raw_text 与 machine_payload 始终保留,便于模型链路、调试链路和后续交互共用同一份 observation 语义。
func BuildUpsertTaskClassView(input BuildUpsertTaskClassViewInput) WriteResultView {
status := normalizeStatus(input.Status)
if status == "" {
if input.Result.Success {
status = StatusDone
} else {
status = StatusFailed
}
}
items := buildTaskClassItemViews(input.Request.Items)
sections := buildUpsertSections(input.Result, input.Request, items, status)
return buildWriteResultView(
status,
buildUpsertTitle(input.Result, status),
buildUpsertSubtitle(input.Result, input.Request, status),
buildUpsertMetrics(input.Result, input.Request),
items,
sections,
input.Observation,
input.MachinePayload,
)
}
func buildUpsertTitle(result UpsertResult, status string) string {
if normalizeStatus(status) != StatusDone {
return "任务类写入失败"
}
if result.Created {
return "任务类已创建"
}
return "任务类已更新"
}
func buildUpsertSubtitle(result UpsertResult, request RequestSummary, status string) string {
name := fallbackText(request.Name, "未命名任务类")
itemCount := len(request.Items)
if normalizeStatus(status) == StatusDone {
action := "更新"
if result.Created {
action = "创建"
}
return fmt.Sprintf("已%s「%s」共 %d 项任务", action, name, itemCount)
}
if len(result.ValidationIssues) > 0 {
return fmt.Sprintf("「%s」校验未通过%s", name, result.ValidationIssues[0])
}
if result.Error != "" {
return fmt.Sprintf("「%s」写入失败%s", name, result.Error)
}
return fmt.Sprintf("「%s」写入失败请查看详情", name)
}
func buildUpsertMetrics(result UpsertResult, request RequestSummary) []MetricField {
action := "更新"
if result.Created {
action = "创建"
}
if !result.Success && request.RequestedID == 0 {
action = "创建尝试"
}
if !result.Success && request.RequestedID > 0 {
action = "更新尝试"
}
return []MetricField{
buildMetric("任务类数量", "1 个"),
buildMetric("任务项数量", fmt.Sprintf("%d 项", len(request.Items))),
buildMetric("来源", formatSourceCN(request.Source)),
buildMetric("写入方式", action),
}
}
func buildUpsertSections(
result UpsertResult,
request RequestSummary,
items []ItemView,
status string,
) []map[string]any {
sections := []map[string]any{
buildResultCallout(result, request, status),
buildKVSection("任务类字段", buildTaskClassFields(result, request)),
buildKVSection("排程配置", buildTaskClassConfigFields(request)),
}
if len(items) > 0 {
sections = append(sections, buildItemsSection("任务项列表", items))
} else {
sections = append(sections, buildCalloutSection(
"任务项列表",
"当前没有可展示的任务项。",
"info",
[]string{"如果这是一次失败写入,请优先检查 task_class.items 或顶层 items 入参是否完整。"},
))
}
if len(result.ValidationIssues) > 0 {
sections = append(sections, buildCalloutSection(
"校验失败原因",
"请求参数未通过后端校验。",
"warning",
normalizeStringSlice(result.ValidationIssues),
))
}
return sections
}
func buildResultCallout(result UpsertResult, request RequestSummary, status string) map[string]any {
if normalizeStatus(status) == StatusDone {
action := "更新"
if result.Created {
action = "创建"
}
detailLines := []string{
fmt.Sprintf("任务类:%s", fallbackText(request.Name, "未命名任务类")),
fmt.Sprintf("任务类 ID%d", resolveDisplayTaskClassID(result, request)),
fmt.Sprintf("任务项数量:%d 项", len(request.Items)),
}
return buildCalloutSection(
"写入结果",
fmt.Sprintf("已%s任务类结果可直接用于后续排程。", action),
"success",
detailLines,
)
}
reason := result.Error
if len(result.ValidationIssues) > 0 {
reason = result.ValidationIssues[0]
}
if reason == "" {
reason = "写入流程未返回明确失败原因,请查看原始 observation。"
}
return buildCalloutSection(
"写入失败",
reason,
"danger",
[]string{
fmt.Sprintf("来源:%s", formatSourceCN(request.Source)),
fmt.Sprintf("任务类:%s", fallbackText(request.Name, "未命名任务类")),
fmt.Sprintf("任务项数量:%d 项", len(request.Items)),
},
)
}
func buildTaskClassFields(result UpsertResult, request RequestSummary) []KVField {
return []KVField{
buildKVField("任务类 ID", fmt.Sprintf("%d", resolveDisplayTaskClassID(result, request))),
buildKVField("名称", fallbackText(request.Name, "未命名任务类")),
buildKVField("模式", formatModeCN(request.Mode)),
buildKVField("日期范围", formatDateRangeCN(request.StartDate, request.EndDate)),
buildKVField("学科类型", formatSubjectTypeCN(request.SubjectType)),
buildKVField("难度等级", formatLevelCN(request.DifficultyLevel)),
buildKVField("认知强度", formatLevelCN(request.CognitiveIntensity)),
buildKVField("来源", formatSourceCN(request.Source)),
}
}
func buildTaskClassConfigFields(request RequestSummary) []KVField {
return []KVField{
buildKVField("总节数", fmt.Sprintf("%d", request.TotalSlots)),
buildKVField("允许补位课程", formatBoolCN(request.AllowFillerCourse)),
buildKVField("推进策略", formatStrategyCN(request.Strategy)),
buildKVField("排除半天块", formatIntListCN(request.ExcludedSlots, "无", func(value int) string {
return fmt.Sprintf("第%d块", value)
})),
buildKVField("排除星期", formatIntListCN(request.ExcludedDaysOfWeek, "无", formatWeekdayCN)),
}
}
func buildTaskClassItemViews(items []TaskClassItemSummary) []ItemView {
if len(items) == 0 {
return make([]ItemView, 0)
}
out := make([]ItemView, 0, len(items))
for _, item := range items {
detailLines := []string{
"内容:" + fallbackText(item.Content, "未填写内容"),
"嵌入时间:" + formatEmbeddedTimeCN(item),
}
if item.ID > 0 {
detailLines = append(detailLines, fmt.Sprintf("任务项 ID%d", item.ID))
}
out = append(out, buildItem(
truncateText(item.Content, 28),
fmt.Sprintf("第 %d 项", maxInt(item.Order, 0)),
buildTaskClassItemTags(item),
detailLines,
map[string]any{
"id": item.ID,
"order": item.Order,
"embedded_week": item.EmbeddedWeek,
"embedded_day": item.EmbeddedDay,
"section_from": item.EmbeddedSectionFrom,
"section_to": item.EmbeddedSectionTo,
},
))
}
return out
}
func buildTaskClassItemTags(item TaskClassItemSummary) []string {
tags := []string{fmt.Sprintf("顺序 %d", maxInt(item.Order, 0))}
if item.EmbeddedWeek > 0 && item.EmbeddedDay > 0 {
tags = append(tags, formatEmbeddedTimeCN(item))
} else {
tags = append(tags, "未指定嵌入时间")
}
return tags
}
func resolveDisplayTaskClassID(result UpsertResult, request RequestSummary) int {
if result.TaskClassID > 0 {
return result.TaskClassID
}
return request.RequestedID
}
func maxInt(values ...int) int {
if len(values) == 0 {
return 0
}
best := values[0]
for _, value := range values[1:] {
if value > best {
best = value
}
}
return best
}