Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a5ef6b75c | ||
|
|
fcfe8149bd | ||
|
|
00f182611c | ||
|
|
5773e74086 | ||
|
|
d6f62af0ed | ||
|
|
0f94e4b062 | ||
|
|
aced665ba6 | ||
|
|
9150e6f90b | ||
|
|
8e4f4e0382 | ||
|
|
ba289e7b17 | ||
|
|
89f3906c39 | ||
|
|
bbebd153f4 | ||
|
|
a563d31b1e | ||
|
|
39632c2e7a | ||
|
|
37b96fd94f | ||
|
|
7a98a7e7fe | ||
|
|
360a8a8946 | ||
|
|
452daf3ffa | ||
|
|
626f8e1062 | ||
|
|
a253a40b8e | ||
|
|
b8913dca6a | ||
|
|
1b7e2c0f47 | ||
|
|
8a3ff92518 | ||
|
|
f4798ac852 | ||
|
|
cf2615def6 | ||
|
|
b12f9e29e6 | ||
|
|
4eae71864c | ||
|
|
a7db6a4d0f |
+9
-2
@@ -2,5 +2,12 @@
|
||||
|
||||
- 用途:见 README。定位与边界见设计文档(Agent-System-Documents)。
|
||||
- 技术栈:Go(静态链接,CGO_ENABLED=0)
|
||||
- 构建/验证:见仓内 Makefile 或 Dev 仓顶层 Makefile(待 P1 起补充并保持本节同步)。
|
||||
- 状态:P0 骨架;当前阶段见 Dev 仓任务记录。
|
||||
|
||||
## 验证
|
||||
|
||||
在本仓根目录执行 `go test ./...` 验证 Go 契约与不变量;纯文档修改执行
|
||||
`git diff --check` 并核对受影响链接、命令和来源。本仓没有 Makefile。
|
||||
|
||||
修改 `ts/types.ts` 后在 Dev 仓运行 `make contracts` 同步消费者,再以 `make test`
|
||||
验证固定组件组合;单独运行 Core 测试不能代替提供方与消费者的兼容验证。
|
||||
本仓提供公共契约,不是独立部署服务;当前能力以源码和对应验证记录为准。
|
||||
|
||||
+46
-13
@@ -12,19 +12,52 @@ import "time"
|
||||
type ActivityCard struct {
|
||||
ID ID `json:"id"`
|
||||
// ProjectID/ConversationID 定位归属;ConversationID 为空表示项目级卡片(远期跨会话收件箱)。
|
||||
ProjectID ID `json:"projectId"`
|
||||
ConversationID ID `json:"conversationId,omitempty"`
|
||||
SourceRunID ID `json:"sourceRunId,omitempty"` // 溯源:产生卡片的 run
|
||||
AnchorMessageID ID `json:"anchorMessageId,omitempty"` // 互定位:出生消息(↙ 定位到对话)
|
||||
Kind CardKind `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Status CardStatus `json:"status"`
|
||||
Tone string `json:"tone,omitempty"` // ok | warn | danger | info
|
||||
Body []UINode `json:"body,omitempty"`
|
||||
Actions []UIAction `json:"actions,omitempty"` // 走命令总线;未绑定 command 降级为发消息
|
||||
Pinned bool `json:"pinned,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ProjectID ID `json:"projectId"`
|
||||
ConversationID ID `json:"conversationId,omitempty"`
|
||||
SourceRunID ID `json:"sourceRunId,omitempty"` // 溯源:产生卡片的 run
|
||||
AnchorMessageID ID `json:"anchorMessageId,omitempty"` // 互定位:出生消息(↙ 定位到对话)
|
||||
Kind CardKind `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Status CardStatus `json:"status"`
|
||||
Tone string `json:"tone,omitempty"` // ok | warn | danger | info
|
||||
Body []UINode `json:"body,omitempty"`
|
||||
Actions []UIAction `json:"actions,omitempty"` // 走命令总线;未绑定 command 降级为发消息
|
||||
Pinned bool `json:"pinned,omitempty"`
|
||||
Work *WorkCard `json:"work,omitempty"` // optional background-work snapshot; never a foreground approval
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// WorkCard keeps the complete saved result and an optional, non-blocking next step
|
||||
// on the original task. Reading and answering do not change the result version.
|
||||
type WorkCard struct {
|
||||
Result string `json:"result,omitempty"`
|
||||
ResultVersion int `json:"resultVersion"`
|
||||
ReadVersion int `json:"readVersion"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
Decision *WorkDecision `json:"decision,omitempty"`
|
||||
}
|
||||
|
||||
type WorkDecision struct {
|
||||
RunID ID `json:"runId,omitempty"` // reserved child identity; reconciles acceptance after restart without rerunning
|
||||
Mode string `json:"mode"` // single | multiple
|
||||
State string `json:"state"` // open | deferred | dispatching | submitted
|
||||
Options []WorkOption `json:"options,omitempty"`
|
||||
SelectedIDs []string `json:"selectedIds,omitempty"`
|
||||
FreeText string `json:"freeText,omitempty"`
|
||||
RespondedAt *time.Time `json:"respondedAt,omitempty"`
|
||||
}
|
||||
|
||||
type WorkOption struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// WorkCardAction is version-fenced. Presentation-only opens never mark a result read.
|
||||
type WorkCardAction struct {
|
||||
ResultVersion int `json:"resultVersion"`
|
||||
SelectedIDs []string `json:"selectedIds,omitempty"`
|
||||
FreeText string `json:"freeText,omitempty"`
|
||||
}
|
||||
|
||||
type CardKind string
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWorkCardSurvivesPersistedSnapshot(t *testing.T) {
|
||||
completed := time.Date(2026, 10, 15, 6, 0, 0, 0, time.UTC)
|
||||
card := ActivityCard{ID: "card_work", Status: CardDone, Work: &WorkCard{
|
||||
Result: strings.Repeat("完整结果\n", 200) + "最后一项依据",
|
||||
ResultVersion: 2, ReadVersion: 1, CompletedAt: &completed,
|
||||
Decision: &WorkDecision{Mode: "multiple", State: "submitted", Options: []WorkOption{{ID: "summary", Label: "生成摘要"}},
|
||||
SelectedIDs: []string{"summary"}, FreeText: "请突出关键限制", RespondedAt: &completed},
|
||||
}}
|
||||
data, err := json.Marshal(card)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var restored ActivityCard
|
||||
if err := json.Unmarshal(data, &restored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if restored.Work == nil || restored.Work.Result != card.Work.Result || restored.Work.ReadVersion != 1 || restored.Work.ResultVersion != 2 {
|
||||
t.Fatalf("complete result or unread version lost: %+v", restored.Work)
|
||||
}
|
||||
if d := restored.Work.Decision; d.State != "submitted" || d.SelectedIDs[0] != "summary" || d.FreeText != "请突出关键限制" || !d.RespondedAt.Equal(completed) {
|
||||
t.Fatalf("decision history lost: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyCardOmitsOptionalWork(t *testing.T) {
|
||||
var legacy ActivityCard
|
||||
if err := json.Unmarshal([]byte(`{"id":"old","status":"resolved","body":[{"type":"markdown","markdown":"保留历史"}]}`), &legacy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := json.Marshal(legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if legacy.Work != nil || strings.Contains(string(data), `"work"`) || legacy.Body[0].Markdown != "保留历史" {
|
||||
t.Fatalf("legacy payload changed: %s", data)
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,9 @@ const (
|
||||
EvCardUpdated StreamEventType = "card.updated"
|
||||
EvCardStatus StreamEventType = "card.status"
|
||||
EvCardResolved StreamEventType = "card.resolved"
|
||||
// EvSurfaceUpdated:Generative UI Surface 快照保存后的投影事件(提案 U2;
|
||||
// 载荷 core.SurfaceSnapshot,按会话流发布,前端消费在 U4)。
|
||||
EvSurfaceUpdated StreamEventType = "surface.updated"
|
||||
)
|
||||
|
||||
// ProtocolVersion 是事件信封与 payload 词汇的当前版本。
|
||||
|
||||
@@ -208,6 +208,12 @@ type Usage struct {
|
||||
TotalTokens int `json:"totalTokens"`
|
||||
DurationMs float64 `json:"durationMs"`
|
||||
ToolCalls int `json:"toolCalls"`
|
||||
// 以下四项是 N3 的计费拆分(additive;旧记录为零值省略):
|
||||
// Last* 是最近一次请求的用量(上一请求),Cache* 是缓存命中累计。
|
||||
LastPromptTokens int `json:"lastPromptTokens,omitempty"`
|
||||
LastCompletionTokens int `json:"lastCompletionTokens,omitempty"`
|
||||
CacheReadTokens int `json:"cacheReadTokens,omitempty"`
|
||||
CacheWriteTokens int `json:"cacheWriteTokens,omitempty"`
|
||||
// Model 是实际给出这段用量的模型。空表示当时没有记录。
|
||||
Model string `json:"model,omitempty"`
|
||||
}
|
||||
|
||||
+125
-1
@@ -1,6 +1,9 @@
|
||||
package core
|
||||
|
||||
import "encoding/json"
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 智能体画像(4.7):核心定制机制,全部数据化、可导入导出。
|
||||
type AgentProfile struct {
|
||||
@@ -107,6 +110,16 @@ const (
|
||||
EffectRestart SettingEffect = "restart" // 需重启
|
||||
)
|
||||
|
||||
// SettingScope 声明设置项的写入归属(提案 2026-10-13-rdm-settings-members-duties-perms-increment P2)。
|
||||
// 空串 = 默认:user 与 project 层可写(向后兼容既有 schema)。
|
||||
type SettingScope string
|
||||
|
||||
const (
|
||||
// SettingScopeSystem 受管项:仅系统管理面(admin)可写;user/project 写入被拒,
|
||||
// 生效链忽略这两层的存量覆盖值。
|
||||
SettingScopeSystem SettingScope = "system"
|
||||
)
|
||||
|
||||
// SettingDesc 描述一个设置项;设置页按此渲染并标注生效时机(schema 驱动)。
|
||||
type SettingDesc struct {
|
||||
Key string `json:"key"`
|
||||
@@ -118,6 +131,117 @@ type SettingDesc struct {
|
||||
Options []string `json:"options,omitempty"` // enum
|
||||
Effect SettingEffect `json:"effect"`
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
Scope SettingScope `json:"scope,omitempty"` // 空 = user/project 可写;system = 受管
|
||||
}
|
||||
|
||||
// ManagedPaths 返回 schema 中 scope=system 的键路径(点路径形式)。
|
||||
func ManagedPaths(descs []SettingDesc) []string {
|
||||
out := make([]string, 0, len(descs))
|
||||
for _, d := range descs {
|
||||
if d.Scope == SettingScopeSystem {
|
||||
out = append(out, d.Key)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FlattenSettingLeafPaths 列出 values 的全部叶子点路径。子树为标量或数组时该子树
|
||||
// 根即叶子。用于受管触碰检测:深合并语义下叶子决定最终覆盖面。
|
||||
func FlattenSettingLeafPaths(values map[string]any) []string {
|
||||
var out []string
|
||||
var walk func(prefix string, m map[string]any)
|
||||
walk = func(prefix string, m map[string]any) {
|
||||
for k, v := range m {
|
||||
path := k
|
||||
if prefix != "" {
|
||||
path = prefix + "." + k
|
||||
}
|
||||
if vm, ok := v.(map[string]any); ok && len(vm) > 0 {
|
||||
walk(path, vm)
|
||||
continue
|
||||
}
|
||||
out = append(out, path)
|
||||
}
|
||||
}
|
||||
walk("", values)
|
||||
return out
|
||||
}
|
||||
|
||||
// pathTouchesManaged 判断 path 是否触碰 managed:等于受管路径、位于受管子树内
|
||||
// (受管路径是 path 的前缀),或整体覆盖受管子树(path 是受管路径的前缀——
|
||||
// 标量覆盖整棵受管 map 同样破坏受管值)。
|
||||
func pathTouchesManaged(path string, managed []string) bool {
|
||||
for _, mp := range managed {
|
||||
if path == mp ||
|
||||
strings.HasPrefix(path, mp+".") ||
|
||||
strings.HasPrefix(mp, path+".") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TouchesManagedSettings 返回 values 中触碰受管路径的叶子;空切片 = 无触碰。
|
||||
// 调用方须传入已展开点路径的嵌套 map(normalize 后)。
|
||||
func TouchesManagedSettings(values map[string]any, managed []string) []string {
|
||||
var hits []string
|
||||
for _, p := range FlattenSettingLeafPaths(values) {
|
||||
if pathTouchesManaged(p, managed) {
|
||||
hits = append(hits, p)
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
// StripManagedSettings 返回删除受管路径后的浅拷贝(不改入参)。受管路径的祖先
|
||||
// 若因此只剩空 map 一并删除。入参须为嵌套形式(点路径已展开)。
|
||||
func StripManagedSettings(values map[string]any, managed []string) map[string]any {
|
||||
if len(values) == 0 || len(managed) == 0 {
|
||||
return values
|
||||
}
|
||||
// 叶子命中或祖先整体命中都删;逐叶子重建最简单可靠。
|
||||
leaves := FlattenSettingLeafPaths(values)
|
||||
keep := map[string]any{}
|
||||
for _, leaf := range leaves {
|
||||
if pathTouchesManaged(leaf, managed) {
|
||||
continue
|
||||
}
|
||||
setPath(keep, leaf, lookupAny(values, leaf))
|
||||
}
|
||||
return keep
|
||||
}
|
||||
|
||||
func setPath(m map[string]any, path string, val any) {
|
||||
parts := strings.Split(path, ".")
|
||||
cur := m
|
||||
for i, p := range parts {
|
||||
if i == len(parts)-1 {
|
||||
cur[p] = val
|
||||
return
|
||||
}
|
||||
next, ok := cur[p].(map[string]any)
|
||||
if !ok {
|
||||
next = map[string]any{}
|
||||
cur[p] = next
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
}
|
||||
|
||||
func lookupAny(m map[string]any, path string) any {
|
||||
parts := strings.Split(path, ".")
|
||||
var cur any = m
|
||||
for _, p := range parts {
|
||||
cm, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
cur, ok = cm[p]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// EffectiveSettings 是覆盖链(system → user → project → conversation)深合并结果。
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestManagedPathsFiltersSystemScope(t *testing.T) {
|
||||
descs := []SettingDesc{
|
||||
{Key: "models.chat.model", Scope: SettingScopeSystem},
|
||||
{Key: "ui.theme"},
|
||||
{Key: "tools.tavily.apiKeys", Scope: SettingScopeSystem},
|
||||
}
|
||||
got := ManagedPaths(descs)
|
||||
sort.Strings(got)
|
||||
want := []string{"models.chat.model", "tools.tavily.apiKeys"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ManagedPaths = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTouchesManagedSettings(t *testing.T) {
|
||||
managed := []string{"models.chat.model", "tools.tavily.apiKeys", "telemetry.otlpEndpoint"}
|
||||
cases := []struct {
|
||||
name string
|
||||
in map[string]any
|
||||
want []string
|
||||
}{
|
||||
{"exact leaf", map[string]any{"models": map[string]any{"chat": map[string]any{"model": "x"}}}, []string{"models.chat.model"}},
|
||||
{"sibling untouched", map[string]any{"models": map[string]any{"chat": map[string]any{"baseUrl": "x"}}}, nil},
|
||||
{"ancestor scalar overwrite", map[string]any{"models": "oops"}, []string{"models"}},
|
||||
{"subtree inside managed", map[string]any{"telemetry": map[string]any{"otlpEndpoint": "http://x"}}, []string{"telemetry.otlpEndpoint"}},
|
||||
{"other group untouched", map[string]any{"ui": map[string]any{"theme": "light"}}, nil},
|
||||
{"empty map", map[string]any{}, nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := TouchesManagedSettings(tc.in, managed)
|
||||
sort.Strings(got)
|
||||
if tc.want == nil && len(got) != 0 {
|
||||
t.Fatalf("TouchesManagedSettings = %v, want none", got)
|
||||
}
|
||||
if tc.want != nil && !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("TouchesManagedSettings = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripManagedSettings(t *testing.T) {
|
||||
managed := []string{"models.chat.model", "models.chat.apiKey"}
|
||||
in := map[string]any{
|
||||
"models": map[string]any{
|
||||
"chat": map[string]any{"model": "x", "baseUrl": "https://b"},
|
||||
"lightweight": map[string]any{"model": "y"},
|
||||
},
|
||||
"ui": map[string]any{"theme": "dark"},
|
||||
}
|
||||
got := StripManagedSettings(in, managed)
|
||||
want := map[string]any{
|
||||
"models": map[string]any{
|
||||
"chat": map[string]any{"baseUrl": "https://b"},
|
||||
"lightweight": map[string]any{"model": "y"},
|
||||
},
|
||||
"ui": map[string]any{"theme": "dark"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("StripManagedSettings = %v, want %v", got, want)
|
||||
}
|
||||
if in["models"].(map[string]any)["chat"].(map[string]any)["model"] != "x" {
|
||||
t.Fatal("StripManagedSettings 不应修改入参")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripManagedSettingsWholeSubtree(t *testing.T) {
|
||||
managed := []string{"models.chat.model", "models.chat.baseUrl", "models.chat.apiKey", "models.chat.fallbacks"}
|
||||
in := map[string]any{
|
||||
"models": map[string]any{"chat": map[string]any{"model": "x", "baseUrl": "b", "apiKey": "k"}},
|
||||
}
|
||||
got := StripManagedSettings(in, managed)
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("整棵受管子树应被剥除(含空父),got %v", got)
|
||||
}
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
package core
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Surface 契约(Generative UI 提案 U2;依据 r2「U1 结果与 r2 修订」):
|
||||
// 有版本的呈现产物信封 + 页面规格校验。信封与版本字段由服务端分配/校验,
|
||||
// 模型只产出 SurfaceSpec;节点级稳定 ID 与来源标注由 UINode 的可选字段承载
|
||||
// (ui.go),旧卡片/表单路径零影响。
|
||||
|
||||
const (
|
||||
// SurfaceSchemaVersion 是 Surface 信封/规格的结构版本。
|
||||
SurfaceSchemaVersion = 1
|
||||
// SurfaceCatalogVersion 是当前认可的组件目录版本(现有 16 类 UINode 词表)。
|
||||
SurfaceCatalogVersion = "ui-v1"
|
||||
// SurfaceMaxNodes / SurfaceMaxDepth / SurfaceMaxBytes 是 U1 试验采纳的资源
|
||||
// 预算(提案 KTD5):超限整页拒绝,不静默裁剪。
|
||||
SurfaceMaxNodes = 200
|
||||
SurfaceMaxDepth = 8
|
||||
SurfaceMaxBytes = 256 * 1024
|
||||
)
|
||||
|
||||
// SurfaceCompat 兼容标识(KTD3):由宿主与已安装组件协商记录,不采信自报值。
|
||||
type SurfaceCompat struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
CatalogVersion string `json:"catalogVersion"`
|
||||
PluginVersion string `json:"pluginVersion,omitempty"`
|
||||
}
|
||||
|
||||
// SurfaceIdentity 身份信封:全部由服务端分配或从当前请求绑定;提交方不能自报。
|
||||
type SurfaceIdentity struct {
|
||||
SurfaceID ID `json:"surfaceId"`
|
||||
Owner string `json:"owner"`
|
||||
ProjectID ID `json:"projectId,omitempty"`
|
||||
ConversationID ID `json:"conversationId,omitempty"`
|
||||
SourceRunID ID `json:"sourceRunId,omitempty"`
|
||||
PluginID string `json:"pluginId"`
|
||||
}
|
||||
|
||||
// SurfaceSpec 是模型可产出的页面规格(KTD3「页面」层)。
|
||||
type SurfaceSpec struct {
|
||||
Title string `json:"title"`
|
||||
Nodes []UINode `json:"nodes"`
|
||||
}
|
||||
|
||||
// SurfaceSnapshot 是已保存的呈现产物:信封+兼容+版本+页面+回放摘要。
|
||||
// Revision 单调递增;更新必须携带 BaseRevision,冲突由服务端拒绝(不自动覆盖)。
|
||||
type SurfaceSnapshot struct {
|
||||
Identity SurfaceIdentity `json:"identity"`
|
||||
Compat SurfaceCompat `json:"compat"`
|
||||
Revision int `json:"revision"`
|
||||
BaseRevision int `json:"baseRevision,omitempty"`
|
||||
ContextVersion string `json:"contextVersion,omitempty"`
|
||||
ActivationEpoch int64 `json:"activationEpoch"`
|
||||
Spec SurfaceSpec `json:"spec"`
|
||||
SemanticText string `json:"semanticText,omitempty"`
|
||||
}
|
||||
|
||||
// SurfaceMeta 是列表投影(不携带整页规格)。
|
||||
type SurfaceMeta struct {
|
||||
SurfaceID ID `json:"surfaceId"`
|
||||
Title string `json:"title"`
|
||||
PluginID string `json:"pluginId"`
|
||||
Revision int `json:"revision"`
|
||||
ConversationID ID `json:"conversationId,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ValidateSurfaceSpec 按目录纪律校验页面规格,返回违规清单(空即合法)。
|
||||
// knownSources 为 nil 表示本层不校验来源授权(U3 数据绑定层职责),仅做格式
|
||||
// 检查;非空时引用/doc-ref 未知来源即为违规。未知类型、非法 props、重复 ID、
|
||||
// 超限均拒绝(U1 试验校验器的 Go 版本:目录=提示=校验三同源)。
|
||||
func ValidateSurfaceSpec(spec SurfaceSpec, knownSources map[string]bool) []string {
|
||||
var errs []string
|
||||
seen := map[string]bool{}
|
||||
nodes := 0
|
||||
|
||||
var walk func(n UINode, path string, depth int)
|
||||
walk = func(n UINode, path string, depth int) {
|
||||
nodes++
|
||||
if nodes > SurfaceMaxNodes {
|
||||
errs = append(errs, fmt.Sprintf("%s: 节点数超过 %d", path, SurfaceMaxNodes))
|
||||
return
|
||||
}
|
||||
if depth > SurfaceMaxDepth {
|
||||
errs = append(errs, fmt.Sprintf("%s: 嵌套深度超过 %d", path, SurfaceMaxDepth))
|
||||
return
|
||||
}
|
||||
if n.ID != "" {
|
||||
if seen[n.ID] {
|
||||
errs = append(errs, fmt.Sprintf("%s: 重复节点 id %q", path, n.ID))
|
||||
}
|
||||
seen[n.ID] = true
|
||||
}
|
||||
for _, s := range n.Source {
|
||||
if s == "" {
|
||||
errs = append(errs, path+": source 含空标识")
|
||||
continue
|
||||
}
|
||||
if knownSources != nil && !knownSources[s] {
|
||||
errs = append(errs, fmt.Sprintf("%s: 引用未知来源 %q", path, s))
|
||||
}
|
||||
}
|
||||
switch n.Type {
|
||||
case UISection:
|
||||
for i, c := range n.Children {
|
||||
walk(c, fmt.Sprintf("%s/child[%d]", path, i), depth+1)
|
||||
}
|
||||
case UIList:
|
||||
if n.Items == nil {
|
||||
errs = append(errs, path+": list 需要 items")
|
||||
}
|
||||
case UIKV:
|
||||
if n.KV == nil {
|
||||
errs = append(errs, path+": kv 需要键值对数组")
|
||||
}
|
||||
case UIForm:
|
||||
if len(n.Fields) == 0 {
|
||||
errs = append(errs, path+": form.fields 需非空")
|
||||
}
|
||||
for _, f := range n.Fields {
|
||||
switch f.Type {
|
||||
case "text", "textarea", "number", "boolean", "select":
|
||||
default:
|
||||
errs = append(errs, fmt.Sprintf("%s: 字段 %q 非法 type %q", path, f.Key, f.Type))
|
||||
}
|
||||
if f.Type == "select" && len(f.Options) == 0 {
|
||||
errs = append(errs, fmt.Sprintf("%s: select 字段 %q 缺 options", path, f.Key))
|
||||
}
|
||||
}
|
||||
case UIMarkdown:
|
||||
if n.Markdown == "" {
|
||||
errs = append(errs, path+": markdown 需要正文")
|
||||
}
|
||||
case UIDocRef:
|
||||
if n.DocID == "" {
|
||||
errs = append(errs, path+": doc-ref 需要 docId")
|
||||
} else if knownSources != nil && !knownSources[string(n.DocID)] {
|
||||
errs = append(errs, fmt.Sprintf("%s: doc-ref 引用未知工件 %q", path, n.DocID))
|
||||
}
|
||||
case UIActions:
|
||||
if len(n.Actions) == 0 {
|
||||
errs = append(errs, path+": actions 需非空")
|
||||
}
|
||||
for _, a := range n.Actions {
|
||||
if a.ID == "" || a.Label == "" {
|
||||
errs = append(errs, fmt.Sprintf("%s: action 缺 id/label", path))
|
||||
}
|
||||
switch a.Style {
|
||||
case "", "primary", "default", "danger":
|
||||
default:
|
||||
errs = append(errs, fmt.Sprintf("%s: action %q 非法 style %q", path, a.ID, a.Style))
|
||||
}
|
||||
}
|
||||
case UIChartLite:
|
||||
if n.Chart == nil || (n.Chart.Kind != "bar" && n.Chart.Kind != "line") ||
|
||||
len(n.Chart.Labels) != len(n.Chart.Series) {
|
||||
errs = append(errs, path+": chart 需要 kind∈{bar,line} 且 labels/series 等长")
|
||||
}
|
||||
case UIAvatar:
|
||||
if n.Avatar == nil || n.Avatar.Name == "" {
|
||||
errs = append(errs, path+": avatar 需要 name")
|
||||
}
|
||||
case UIProgress:
|
||||
if n.Progress == nil || n.Progress.Value < 0 || n.Progress.Value > 1 {
|
||||
errs = append(errs, path+": progress.value 需在 0~1")
|
||||
}
|
||||
case UICompare:
|
||||
if n.Compare == nil {
|
||||
errs = append(errs, path+": compare 需要 left/right")
|
||||
} else {
|
||||
for side, sd := range map[string]UICompareSide{"left": n.Compare.Left, "right": n.Compare.Right} {
|
||||
for i, c := range sd.Nodes {
|
||||
walk(c, fmt.Sprintf("%s/%s[%d]", path, side, i), depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
case UITree:
|
||||
if n.Tree == nil || len(n.Tree.Nodes) == 0 {
|
||||
errs = append(errs, path+": tree.nodes 需非空")
|
||||
}
|
||||
case UITable:
|
||||
if n.Table == nil || len(n.Table.Columns) == 0 {
|
||||
errs = append(errs, path+": table.columns 需非空")
|
||||
} else {
|
||||
for i, r := range n.Table.Rows {
|
||||
if len(r.Cells) != len(n.Table.Columns) {
|
||||
errs = append(errs, fmt.Sprintf("%s: 行[%d] cells 与 columns 不等长", path, i))
|
||||
}
|
||||
}
|
||||
}
|
||||
case UISteps:
|
||||
if n.Steps == nil || len(n.Steps.Steps) == 0 || n.Steps.Current < 0 || n.Steps.Current >= len(n.Steps.Steps) {
|
||||
errs = append(errs, path+": steps 需非空且 current 在范围内")
|
||||
}
|
||||
case UITabs:
|
||||
if n.Tabs == nil || len(n.Tabs.Tabs) == 0 {
|
||||
errs = append(errs, path+": tabs.tabs 需非空")
|
||||
} else {
|
||||
for i, tb := range n.Tabs.Tabs {
|
||||
for j, c := range tb.Nodes {
|
||||
walk(c, fmt.Sprintf("%s/tab[%d][%d]", path, i, j), depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
case UITimeline:
|
||||
if n.Timeline == nil || len(n.Timeline.Items) == 0 {
|
||||
errs = append(errs, path+": timeline.items 需非空")
|
||||
}
|
||||
default:
|
||||
errs = append(errs, fmt.Sprintf("%s: 未知节点类型 %q", path, n.Type))
|
||||
}
|
||||
}
|
||||
|
||||
if spec.Title == "" {
|
||||
errs = append(errs, "$: title 必填")
|
||||
}
|
||||
if len(spec.Nodes) == 0 {
|
||||
errs = append(errs, "$: nodes 需非空")
|
||||
}
|
||||
for i, n := range spec.Nodes {
|
||||
walk(n, fmt.Sprintf("$[%d]", i), 1)
|
||||
}
|
||||
return errs
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func validSpec() SurfaceSpec {
|
||||
return SurfaceSpec{
|
||||
Title: "三家候选工具选型对比",
|
||||
Nodes: []UINode{
|
||||
{Type: UISection, ID: "n1", Title: "一、概览", Children: []UINode{
|
||||
{Type: UIMarkdown, ID: "n1a", Source: []string{"doc:eval/rc-01/vendor1"}, Markdown: "本期评估三家候选。"},
|
||||
}},
|
||||
{Type: UITable, ID: "n2", Source: []string{"doc:eval/rc-01/vendor1", "doc:eval/rc-01/vendor2"}, Table: &UITableSpec{
|
||||
Columns: []UITableColumn{{Key: "k", Label: "维度"}, {Key: "v", Label: "值"}},
|
||||
Rows: []UITableRow{{ID: "r1", Cells: [][2]string{{"k", "价格"}, {"v", "¥8/人/天"}}}},
|
||||
}},
|
||||
{Type: UISteps, ID: "n3", Steps: &UIStepsSpec{Current: 1, Steps: []UIStep{
|
||||
{Label: "需求冻结", Status: "done"}, {Label: "契约定义", Status: "active"},
|
||||
}}},
|
||||
{Type: UIForm, ID: "n4", Fields: []UIField{
|
||||
{Key: "years", Label: "归档年限", Type: "select", Options: []string{"3", "5"}, Required: true},
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSurfaceSpecValid(t *testing.T) {
|
||||
if errs := ValidateSurfaceSpec(validSpec(), nil); len(errs) != 0 {
|
||||
t.Fatalf("合法规格不应有违规: %v", errs)
|
||||
}
|
||||
// JSON 往返后仍合法(信封序列化面)。
|
||||
b, err := json.Marshal(validSpec())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var back SurfaceSpec
|
||||
if err := json.Unmarshal(b, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if errs := ValidateSurfaceSpec(back, nil); len(errs) != 0 {
|
||||
t.Fatalf("JSON 往返后不应有违规: %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSurfaceSpecViolations(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mut func(*SurfaceSpec)
|
||||
want string
|
||||
}{
|
||||
{"未知类型", func(s *SurfaceSpec) { s.Nodes[0].Type = " hologram" }, "未知节点类型"},
|
||||
{"重复节点 id", func(s *SurfaceSpec) { s.Nodes[1].ID = "n1" }, "重复节点 id"},
|
||||
{"表格行列不齐", func(s *SurfaceSpec) {
|
||||
s.Nodes[1].Table.Rows = []UITableRow{{Cells: [][2]string{{"k", "只有一列"}}}}
|
||||
}, "不等长"},
|
||||
{"select 缺 options", func(s *SurfaceSpec) {
|
||||
s.Nodes[3].Fields[0].Options = nil
|
||||
}, "缺 options"},
|
||||
{"steps 越界", func(s *SurfaceSpec) { s.Nodes[2].Steps.Current = 5 }, "current 在范围内"},
|
||||
{"空 title", func(s *SurfaceSpec) { s.Title = "" }, "title 必填"},
|
||||
{"空 nodes", func(s *SurfaceSpec) { s.Nodes = nil }, "nodes 需非空"},
|
||||
{"markdown 空", func(s *SurfaceSpec) {
|
||||
s.Nodes[0].Children[0].Markdown = ""
|
||||
}, "markdown 需要正文"},
|
||||
{"progress 越界", func(s *SurfaceSpec) {
|
||||
s.Nodes = append(s.Nodes, UINode{Type: UIProgress, Progress: &UIProgressSpec{Value: 1.5}})
|
||||
}, "progress.value"},
|
||||
{"未知来源", func(s *SurfaceSpec) {}, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
s := validSpec()
|
||||
c.mut(&s)
|
||||
errs := ValidateSurfaceSpec(s, nil)
|
||||
if c.want == "" {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if strings.Contains(e, c.want) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%s: 期望包含 %q,得到 %v", c.name, c.want, errs)
|
||||
}
|
||||
}
|
||||
// knownSources 非空时:未知来源拒绝、已知来源放行。
|
||||
s := validSpec()
|
||||
errs := ValidateSurfaceSpec(s, map[string]bool{"doc:eval/rc-01/vendor1": true, "doc:eval/rc-01/vendor2": true})
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("已知来源全引用不应违规: %v", errs)
|
||||
}
|
||||
s.Nodes[0].Children[0].Source = append(s.Nodes[0].Children[0].Source, "doc:eval/rc-99/ghost")
|
||||
errs = ValidateSurfaceSpec(s, map[string]bool{"doc:eval/rc-01/vendor1": true})
|
||||
if len(errs) == 0 {
|
||||
t.Fatal("引用未知来源应拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSurfaceSpecLimits(t *testing.T) {
|
||||
deep := UINode{Type: UISection, ID: "d0"}
|
||||
cur := &deep
|
||||
for i := 1; i <= SurfaceMaxDepth+1; i++ {
|
||||
cur.Children = []UINode{{Type: UISection, ID: "d" + string(rune('0'+i%10))}}
|
||||
cur = &cur.Children[0]
|
||||
}
|
||||
if errs := ValidateSurfaceSpec(SurfaceSpec{Title: "t", Nodes: []UINode{deep}}, nil); len(errs) == 0 {
|
||||
t.Fatal("超过嵌套深度应拒绝")
|
||||
}
|
||||
many := make([]UINode, SurfaceMaxNodes+1)
|
||||
for i := range many {
|
||||
many[i] = UINode{Type: UIMarkdown, Markdown: "x"}
|
||||
}
|
||||
if errs := ValidateSurfaceSpec(SurfaceSpec{Title: "t", Nodes: many}, nil); len(errs) == 0 {
|
||||
t.Fatal("超过节点数应拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUINodeBackwardCompat(t *testing.T) {
|
||||
// 旧卡片路径的 UINode JSON(无 id/source)解出后字段为零值,不受影响。
|
||||
var n UINode
|
||||
if err := json.Unmarshal([]byte(`{"type":"list","items":["a"]}`), &n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n.ID != "" || n.Source != nil || n.Type != UIList || len(n.Items) != 1 {
|
||||
t.Fatalf("旧负载解引用异常: %+v", n)
|
||||
}
|
||||
// 新字段双向序列化。
|
||||
b, _ := json.Marshal(UINode{Type: UIMarkdown, ID: "m1", Source: []string{"s1"}, Markdown: "x"})
|
||||
if !strings.Contains(string(b), `"id":"m1"`) || !strings.Contains(string(b), `"source":["s1"]`) {
|
||||
t.Fatalf("新字段序列化缺失: %s", b)
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package core
|
||||
|
||||
// TaskSummaryView 是 U1 写入 ActivityCard.Body 的权威语义草图。
|
||||
// 完整 TaskSnapshot(findings[]、revision、streamScope)留待 U2;本类型只冻结
|
||||
// 运行中/终态的精炼骨架字段与 Body KV 键名,供 Backend 生成、旧客户端回退阅读。
|
||||
type TaskSummaryView struct {
|
||||
Title string // 业务动宾短标题(通常已在 ActivityCard.Title)
|
||||
Focus string // 正在做什么,≤2 行
|
||||
FindingsNote string // U1:无成果时诚实占位;结构化 findings 属 U3
|
||||
Remaining string
|
||||
NextStep string
|
||||
OutcomeLine string // 完成后的结论一行;运行中可空
|
||||
}
|
||||
|
||||
// Body KV 键名(U1 冻结;用户默认卡身可见,勿改成内部材料键)。
|
||||
const (
|
||||
TaskSummaryKVStatus = "状态"
|
||||
TaskSummaryKVFocus = "正在做"
|
||||
TaskSummaryKVFindings = "当前发现"
|
||||
TaskSummaryKVRemaining = "剩余"
|
||||
TaskSummaryKVNextStep = "下一步"
|
||||
)
|
||||
|
||||
// 状态行用户文案(与执行态映射;排队/进行中/完成/失败)。
|
||||
const (
|
||||
TaskSummaryStatusQueued = "排队中"
|
||||
TaskSummaryStatusRunning = "进行中"
|
||||
TaskSummaryStatusDone = "已完成"
|
||||
TaskSummaryStatusFailed = "执行失败"
|
||||
)
|
||||
|
||||
// 无阶段成果时的诚实占位(R03-a);不得编造业务发现。
|
||||
const TaskSummaryNoFindingsYet = "尚无阶段结论,正在读取材料"
|
||||
|
||||
// RunningBody 生成运行中(含排队)进度卡 Body:Markdown 占位 + 固定骨架 KV。
|
||||
// extraKV(材料键等)保留在同一 UIKV 节点,供系统去重,不充当用户进度正文。
|
||||
func (v TaskSummaryView) RunningBody(statusLabel string, extraKV [][2]string) []UINode {
|
||||
focus := stringsOr(v.Focus, "处理已登记工作")
|
||||
findings := stringsOr(v.FindingsNote, TaskSummaryNoFindingsYet)
|
||||
remaining := stringsOr(v.Remaining, "待任务进展后更新")
|
||||
next := stringsOr(v.NextStep, "继续处理已登记工作")
|
||||
if statusLabel == "" {
|
||||
statusLabel = TaskSummaryStatusRunning
|
||||
}
|
||||
kv := append(append([][2]string(nil), extraKV...),
|
||||
[2]string{TaskSummaryKVStatus, statusLabel},
|
||||
[2]string{TaskSummaryKVFocus, focus},
|
||||
[2]string{TaskSummaryKVFindings, findings},
|
||||
[2]string{TaskSummaryKVRemaining, remaining},
|
||||
[2]string{TaskSummaryKVNextStep, next},
|
||||
)
|
||||
return []UINode{
|
||||
{Type: UIMarkdown, Markdown: findings},
|
||||
{Type: UIKV, KV: kv},
|
||||
}
|
||||
}
|
||||
|
||||
// FinishedBody 生成终态卡 Body:结论 Markdown + 骨架 KV(状态为已完成或执行失败)。
|
||||
func (v TaskSummaryView) FinishedBody(statusLabel string, extraKV [][2]string) []UINode {
|
||||
outcome := stringsOr(v.OutcomeLine, "(无结果摘要)")
|
||||
findings := stringsOr(v.FindingsNote, outcome)
|
||||
if statusLabel == "" {
|
||||
statusLabel = TaskSummaryStatusDone
|
||||
}
|
||||
kv := append(append([][2]string(nil), extraKV...),
|
||||
[2]string{TaskSummaryKVStatus, statusLabel},
|
||||
[2]string{TaskSummaryKVFocus, stringsOr(v.Focus, "")},
|
||||
[2]string{TaskSummaryKVFindings, findings},
|
||||
[2]string{TaskSummaryKVRemaining, stringsOr(v.Remaining, "")},
|
||||
[2]string{TaskSummaryKVNextStep, stringsOr(v.NextStep, "")},
|
||||
)
|
||||
// 去掉空值骨架行,避免完成卡出现空白「正在做」。
|
||||
compact := make([][2]string, 0, len(kv))
|
||||
for _, pair := range kv {
|
||||
if pair[1] == "" && (pair[0] == TaskSummaryKVFocus || pair[0] == TaskSummaryKVRemaining || pair[0] == TaskSummaryKVNextStep) {
|
||||
continue
|
||||
}
|
||||
compact = append(compact, pair)
|
||||
}
|
||||
return []UINode{
|
||||
{Type: UIMarkdown, Markdown: outcome},
|
||||
{Type: UIKV, KV: compact},
|
||||
}
|
||||
}
|
||||
|
||||
// CardDisplaysFailed 判断卡是否应按失败展示:含 CardFailed,以及旧客户端
|
||||
// done + danger 兼容映射(U1 出口白名单)。
|
||||
func CardDisplaysFailed(status CardStatus, tone string) bool {
|
||||
if status == CardFailed {
|
||||
return true
|
||||
}
|
||||
return status == CardDone && tone == "danger"
|
||||
}
|
||||
|
||||
func stringsOr(s, fallback string) string {
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTaskSummaryRunningBodyOmitsRawTaskAndUsesHonestPlaceholder(t *testing.T) {
|
||||
v := TaskSummaryView{Focus: "核对需求与会议决定"}
|
||||
body := v.RunningBody(TaskSummaryStatusQueued, [][2]string{{"材料键", "mk_x"}})
|
||||
if len(body) != 2 || body[0].Type != UIMarkdown || body[1].Type != UIKV {
|
||||
t.Fatalf("body shape = %+v", body)
|
||||
}
|
||||
if body[0].Markdown != TaskSummaryNoFindingsYet {
|
||||
t.Fatalf("markdown = %q, want honest placeholder", body[0].Markdown)
|
||||
}
|
||||
rawTask := "逐页解析上传材料并对照验收条件" + strings.Repeat("x", 200)
|
||||
if strings.Contains(body[0].Markdown, rawTask) {
|
||||
t.Fatal("running body must not embed raw task text")
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, kv := range body[1].KV {
|
||||
got[kv[0]] = kv[1]
|
||||
}
|
||||
for _, key := range []string{TaskSummaryKVStatus, TaskSummaryKVFocus, TaskSummaryKVFindings, TaskSummaryKVRemaining, TaskSummaryKVNextStep, "材料键"} {
|
||||
if _, ok := got[key]; !ok {
|
||||
t.Fatalf("missing kv %q in %v", key, got)
|
||||
}
|
||||
}
|
||||
if got[TaskSummaryKVStatus] != TaskSummaryStatusQueued {
|
||||
t.Fatalf("status = %q", got[TaskSummaryKVStatus])
|
||||
}
|
||||
if got[TaskSummaryKVFindings] != TaskSummaryNoFindingsYet {
|
||||
t.Fatalf("findings = %q", got[TaskSummaryKVFindings])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSummaryFinishedBodyFailureLabel(t *testing.T) {
|
||||
v := TaskSummaryView{OutcomeLine: "执行失败:boom"}
|
||||
body := v.FinishedBody(TaskSummaryStatusFailed, nil)
|
||||
if body[0].Markdown != "执行失败:boom" {
|
||||
t.Fatalf("outcome = %q", body[0].Markdown)
|
||||
}
|
||||
found := false
|
||||
for _, kv := range body[1].KV {
|
||||
if kv[0] == TaskSummaryKVStatus {
|
||||
found = true
|
||||
if kv[1] != TaskSummaryStatusFailed {
|
||||
t.Fatalf("status = %q, want %q", kv[1], TaskSummaryStatusFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("missing status kv")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSummaryViewJSONRoundTrip(t *testing.T) {
|
||||
in := TaskSummaryView{
|
||||
Title: "核对三份材料", Focus: "核对需求", FindingsNote: TaskSummaryNoFindingsYet,
|
||||
Remaining: "评审意见", NextStep: "整理冲突", OutcomeLine: "",
|
||||
}
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out TaskSummaryView
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out != in {
|
||||
t.Fatalf("round-trip = %+v, want %+v", out, in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardDisplaysFailed(t *testing.T) {
|
||||
if !CardDisplaysFailed(CardFailed, "danger") {
|
||||
t.Fatal("CardFailed should display failed")
|
||||
}
|
||||
if !CardDisplaysFailed(CardDone, "danger") {
|
||||
t.Fatal("legacy done+danger should display failed")
|
||||
}
|
||||
if CardDisplaysFailed(CardDone, "ok") {
|
||||
t.Fatal("done+ok must not display failed")
|
||||
}
|
||||
if CardDisplaysFailed(CardActive, "info") {
|
||||
t.Fatal("active must not display failed")
|
||||
}
|
||||
}
|
||||
+70
-2
@@ -59,6 +59,12 @@ export interface Usage {
|
||||
promptTokens: number; completionTokens: number; totalTokens: number;
|
||||
durationMs: number; toolCalls: number;
|
||||
model?: string;
|
||||
// N3 计费拆分(additive;旧记录缺省为 0):
|
||||
// Last* 是最近一次请求的用量,Cache* 是缓存命中累计。
|
||||
lastPromptTokens?: number;
|
||||
lastCompletionTokens?: number;
|
||||
cacheReadTokens?: number;
|
||||
cacheWriteTokens?: number;
|
||||
}
|
||||
|
||||
export interface Run {
|
||||
@@ -92,7 +98,8 @@ export type StreamEventType =
|
||||
| 'agent.spawn' | 'agent.progress' | 'agent.result'
|
||||
| 'artifact.created' | 'artifact.updated'
|
||||
| 'run.completed' | 'run.failed' | 'error'
|
||||
| 'card.created' | 'card.updated' | 'card.status' | 'card.resolved';
|
||||
| 'card.created' | 'card.updated' | 'card.status' | 'card.resolved'
|
||||
| 'surface.updated';
|
||||
|
||||
export interface StreamEvent<P = unknown> {
|
||||
seq: number; runId?: ID; conversationId: ID; ts: string; v: number;
|
||||
@@ -143,7 +150,21 @@ export interface ActivityCard {
|
||||
sourceRunId?: ID; anchorMessageId?: ID;
|
||||
kind: CardKind; title: string; status: CardStatus; tone?: string;
|
||||
body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[];
|
||||
pinned?: boolean; createdAt: string; updatedAt: string;
|
||||
pinned?: boolean; createdAt: string; updatedAt: string; work?: WorkCard;
|
||||
}
|
||||
|
||||
export interface WorkCard {
|
||||
result?: string; resultVersion: number; readVersion: number; completedAt?: string;
|
||||
decision?: WorkDecision;
|
||||
}
|
||||
export interface WorkDecision {
|
||||
runId?: ID;
|
||||
mode: 'single' | 'multiple'; state: 'open' | 'deferred' | 'dispatching' | 'submitted';
|
||||
options?: { id: string; label: string }[];
|
||||
selectedIds?: string[]; freeText?: string; respondedAt?: string;
|
||||
}
|
||||
export interface WorkCardAction {
|
||||
resultVersion: number; selectedIds?: string[]; freeText?: string;
|
||||
}
|
||||
|
||||
export interface CardPayload { card: ActivityCard }
|
||||
@@ -178,6 +199,9 @@ export type UINodeType =
|
||||
|
||||
export interface UINode {
|
||||
type: UINodeType;
|
||||
/** Surface 层节点级标注(Generative UI U2):整页唯一稳定 id 与事实来源标识,旧路径不填。 */
|
||||
id?: string;
|
||||
source?: string[];
|
||||
title?: string;
|
||||
items?: string[];
|
||||
kv?: [string, string][];
|
||||
@@ -259,6 +283,8 @@ export interface SettingDesc {
|
||||
default?: unknown; options?: string[];
|
||||
effect: 'hot' | 'new-run' | 'restart';
|
||||
secret?: boolean;
|
||||
/** P2 受管:system = 系统受管项,仅系统管理面可写(用户/项目层拒绝)。 */
|
||||
scope?: 'system';
|
||||
}
|
||||
|
||||
// ---- 插件 ----
|
||||
@@ -345,3 +371,45 @@ export interface UploadInfo {
|
||||
artifactId: ID; filename: string; mime?: string; size: number;
|
||||
parsed?: ParsedDocument;
|
||||
}
|
||||
|
||||
// ---- Generative UI Surface(提案 U2/U4;与 Go surface.go 同步)----
|
||||
|
||||
export interface SurfaceCompat {
|
||||
schemaVersion: number;
|
||||
catalogVersion: string;
|
||||
pluginVersion?: string;
|
||||
}
|
||||
|
||||
export interface SurfaceIdentity {
|
||||
surfaceId: ID;
|
||||
owner: string;
|
||||
projectId?: ID;
|
||||
conversationId?: ID;
|
||||
sourceRunId?: ID;
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
export interface SurfaceSpec {
|
||||
title: string;
|
||||
nodes: UINode[];
|
||||
}
|
||||
|
||||
export interface SurfaceSnapshot {
|
||||
identity: SurfaceIdentity;
|
||||
compat: SurfaceCompat;
|
||||
revision: number;
|
||||
baseRevision?: number;
|
||||
contextVersion?: string;
|
||||
activationEpoch: number;
|
||||
spec: SurfaceSpec;
|
||||
semanticText?: string;
|
||||
}
|
||||
|
||||
export interface SurfaceMeta {
|
||||
surfaceId: ID;
|
||||
title: string;
|
||||
pluginId: string;
|
||||
revision: number;
|
||||
conversationId?: ID;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,12 @@ const (
|
||||
)
|
||||
|
||||
type UINode struct {
|
||||
Type UINodeType `json:"type"`
|
||||
Type UINodeType `json:"type"`
|
||||
// ID / Source 是 Generative UI Surface 层的节点级标注(提案 U2):ID 整页
|
||||
// 唯一且稳定(交互与修订对位);Source 为支撑该节点事实内容的来源标识,
|
||||
// 授权校验在数据绑定层(U3)。均为可选:旧卡片/表单路径不填不受影响。
|
||||
ID string `json:"id,omitempty"`
|
||||
Source []string `json:"source,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Items []string `json:"items,omitempty"` // list
|
||||
KV [][2]string `json:"kv,omitempty"` // kv
|
||||
|
||||
Reference in New Issue
Block a user