Skip to content

Commit 8601a83

Browse files
committed
feat(sdk): 呈现 hints 便捷层——SetFieldHint/SetFieldWidget 全语言对齐(F14)
- Go builder:SetFieldHint/SetFieldWidget(空 schema 建骨架、重复 覆盖、x_ 归一 x-、非法键入 builder errors),8 例单测 - JS:setFieldHint/setFieldWidget(不可变返回),5 例单测 - Python:set_field_hint/set_field_widget(支持 str schema 合并), 6 例单测 - SDK_FEATURE_MATRIX L2 新增能力行(Go/Python/JS ✅); docs/sdks/{go,python,js} 同步示例;F 系列状态行更新为全部完成
1 parent 7dc1173 commit 8601a83

11 files changed

Lines changed: 494 additions & 7 deletions

File tree

docs/sdks/go/index.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,20 @@ title: Go SDK
1515
- 与 monorepo 中的 `proto/**` 保持演进一致
1616
- 支持函数描述符、会话管理与错误处理
1717
- 适合服务端高并发集成
18+
- 呈现 hints 便捷层(x-ui-* 契约,见 [呈现 Hints 契约](/architecture/presentation-hints)):
19+
20+
```go
21+
metadata, _ := function.NewMetadataBuilder().
22+
SetID("player.ban").
23+
SetInputSchema(`{"type":"object","properties":{"id":{"type":"string"}}}`).
24+
SetFieldWidget("id", "Select").
25+
SetFieldHint("id", "x-options-source", map[string]interface{}{
26+
"functionId": "player.list",
27+
"labelPath": "/items/*/name",
28+
"valuePath": "/items/*/id",
29+
}).
30+
Build()
31+
```
1832

1933
## 安装
2034

docs/sdks/js/index.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,23 @@ TypeScript 优先的 Node.js SDK,用于连接 Croupier Agent、注册函数并
1717
- 提供函数描述符与处理器注册能力
1818
- 面向 monorepo 统一协议演进
1919

20+
- 呈现 hints 便捷层(x-ui-* 契约,见 [呈现 Hints 契约](/architecture/presentation-hints)):
21+
22+
```ts
23+
import { setFieldWidget, setFieldHint } from "croupier-sdk";
24+
25+
let desc = setFieldWidget(
26+
{ id: "player.ban", version: "1.0.0" },
27+
"id",
28+
"Select",
29+
);
30+
desc = setFieldHint(desc, "id", "x-options-source", {
31+
functionId: "player.list",
32+
labelPath: "/items/*/name",
33+
valuePath: "/items/*/id",
34+
});
35+
```
36+
2037
## 安装
2138

2239
```bash

docs/sdks/python/index.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,19 @@ Python SDK 是 Croupier 的官方 Python 客户端,面向函数注册、调用
1616
- 基于单连接会话模型与 Agent 通信
1717
- 内置心跳、重连和基础类型注解
1818

19+
- 呈现 hints 便捷层(x-ui-* 契约,见 [呈现 Hints 契约](/architecture/presentation-hints)):
20+
21+
```python
22+
from croupier import set_field_widget, set_field_hint
23+
24+
desc = set_field_widget(FunctionDescriptor(id="player.ban"), "id", "Select")
25+
desc = set_field_hint(desc, "id", "x-options-source", {
26+
"functionId": "player.list",
27+
"labelPath": "/items/*/name",
28+
"valuePath": "/items/*/id",
29+
})
30+
```
31+
1932
## 安装
2033

2134
```bash

sdks/SDK_FEATURE_MATRIX.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
| 能力 | Go | Python | Java | JS/TS | C++ | C# |
4747
| ---------------------------------------------------------------------- | ------------------- | --------------------------- | ---- | -------------------- | --- | ------------------------ |
4848
| Descriptor v2 字段(builder/构造器) |||||||
49+
| 呈现 hints 便捷层(`SetFieldHint`/`SetFieldWidget` 等价,x-ui-* 契约) | ✅ builder 方法 |`set_field_hint()` ||`setFieldHint()` |||
4950
| OpenAPI 注册 helper(`RegisterFromOpenAPI` 等价) |||||||
5051
| JSON Schema 入站 payload 校验 | ❌ 依赖已声明未接线 | ❌ jsonschema 仅 Invoker 侧 || ❌ Ajv 仅 Invoker 侧 ||`JsonSchemaValidator` |
5152
| 控制面 manifest 上传(`control_addr``RegisterCapabilitiesRequest`|||||||

sdks/go/function/builder.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
package function
33

44
import (
5+
"encoding/json"
56
"fmt"
7+
"strings"
68
)
79

810
// MetadataBuilder builds FunctionMetadata using the builder pattern.
@@ -123,6 +125,86 @@ func (b *MetadataBuilder) SetBehavior(behavior *FunctionBehavior) *MetadataBuild
123125
return b
124126
}
125127

128+
// SetFieldHint 向 InputSchema 的 properties[field] 合并单个呈现 hint
129+
// (F14,x-ui-* 呈现契约的 SDK 便捷层,docs/architecture/presentation-hints.md)。
130+
// schema 为空时自动创建 object 骨架;属性不存在时创建空属性;重复设置覆盖。
131+
// hint 必须是 x-/x_ 扩展键(归一为 x- 形式),否则记入校验错误。
132+
func (b *MetadataBuilder) SetFieldHint(field, hint string, value interface{}) *MetadataBuilder {
133+
if strings.TrimSpace(field) == "" {
134+
b.errors = append(b.errors, fmt.Errorf("field key is required for SetFieldHint"))
135+
return b
136+
}
137+
normalizedHint, ok := normalizeHintKey(hint)
138+
if !ok {
139+
b.errors = append(b.errors, fmt.Errorf("hint %q must be an x- extension key (e.g. x-widget)", hint))
140+
return b
141+
}
142+
schema, err := ensureSchemaObject(b.metadata.InputSchema)
143+
if err != nil {
144+
b.errors = append(b.errors, err)
145+
return b
146+
}
147+
properties, _ := schema["properties"].(map[string]interface{})
148+
if properties == nil {
149+
properties = map[string]interface{}{}
150+
schema["properties"] = properties
151+
}
152+
property, _ := properties[field].(map[string]interface{})
153+
if property == nil {
154+
property = map[string]interface{}{}
155+
properties[field] = property
156+
}
157+
property[normalizedHint] = value
158+
out, err := json.Marshal(schema)
159+
if err != nil {
160+
b.errors = append(b.errors, fmt.Errorf("marshal input schema: %w", err))
161+
return b
162+
}
163+
b.metadata.InputSchema = string(out)
164+
return b
165+
}
166+
167+
// SetFieldWidget 等价于 SetFieldHint(field, "x-widget", widget)。
168+
func (b *MetadataBuilder) SetFieldWidget(field, widget string) *MetadataBuilder {
169+
if strings.TrimSpace(widget) == "" {
170+
b.errors = append(b.errors, fmt.Errorf("widget is required for SetFieldWidget"))
171+
return b
172+
}
173+
return b.SetFieldHint(field, "x-widget", widget)
174+
}
175+
176+
// normalizeHintKey 校验并归一 hint 键:x_/X- 变体统一为 x- 形式。
177+
func normalizeHintKey(hint string) (string, bool) {
178+
trimmed := strings.TrimSpace(hint)
179+
if trimmed == "" {
180+
return "", false
181+
}
182+
lower := strings.ToLower(trimmed)
183+
switch {
184+
case strings.HasPrefix(lower, "x_"):
185+
return "x-" + trimmed[2:], true
186+
case strings.HasPrefix(lower, "x-"):
187+
return trimmed, true
188+
default:
189+
return "", false
190+
}
191+
}
192+
193+
// ensureSchemaObject 解析既有 InputSchema(空串创建 object 骨架);
194+
// 非法 JSON 返回错误。
195+
func ensureSchemaObject(raw string) (map[string]interface{}, error) {
196+
schema := map[string]interface{}{}
197+
if strings.TrimSpace(raw) != "" {
198+
if err := json.Unmarshal([]byte(raw), &schema); err != nil {
199+
return nil, fmt.Errorf("input schema is not valid JSON: %w", err)
200+
}
201+
}
202+
if schema["type"] == nil {
203+
schema["type"] = "object"
204+
}
205+
return schema, nil
206+
}
207+
126208
// SetRisk sets the function risk.
127209
func (b *MetadataBuilder) SetRisk(risk *FunctionRisk) *MetadataBuilder {
128210
b.metadata.Risk = risk

sdks/go/function/hints_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package function
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
"testing"
7+
)
8+
9+
// F14:SetFieldHint/SetFieldWidget 向 InputSchema 注入 x-ui 呈现 hints。
10+
func hintsOf(t *testing.T, schema string) map[string]interface{} {
11+
t.Helper()
12+
var out map[string]interface{}
13+
if err := json.Unmarshal([]byte(schema), &out); err != nil {
14+
t.Fatalf("schema not valid JSON: %v", err)
15+
}
16+
return out
17+
}
18+
19+
func TestMetadataBuilder_SetFieldHint(t *testing.T) {
20+
t.Run("merge into existing schema", func(t *testing.T) {
21+
metadata, err := NewMetadataBuilder().
22+
SetID("player.ban").
23+
SetInputSchema(`{"type":"object","properties":{"id":{"type":"string","title":"玩家 ID"}}}`).
24+
SetFieldHint("id", "x-widget", "Select").
25+
SetFieldHint("id", "x-options-source", map[string]interface{}{
26+
"functionId": "player.list",
27+
"labelPath": "/items/*/name",
28+
"valuePath": "/items/*/id",
29+
}).
30+
Build()
31+
if err != nil {
32+
t.Fatalf("Build: %v", err)
33+
}
34+
schema := hintsOf(t, metadata.InputSchema)
35+
props := schema["properties"].(map[string]interface{})
36+
id := props["id"].(map[string]interface{})
37+
if id["x-widget"] != "Select" {
38+
t.Fatalf("x-widget = %v", id["x-widget"])
39+
}
40+
source := id["x-options-source"].(map[string]interface{})
41+
if source["functionId"] != "player.list" {
42+
t.Fatalf("x-options-source = %v", source)
43+
}
44+
// 既有 title 保留
45+
if id["title"] != "玩家 ID" {
46+
t.Fatalf("title lost: %v", id["title"])
47+
}
48+
})
49+
50+
t.Run("empty schema creates object skeleton", func(t *testing.T) {
51+
metadata, err := NewMetadataBuilder().
52+
SetID("f").
53+
SetFieldWidget("level", "Slider").
54+
Build()
55+
if err != nil {
56+
t.Fatalf("Build: %v", err)
57+
}
58+
schema := hintsOf(t, metadata.InputSchema)
59+
if schema["type"] != "object" {
60+
t.Fatalf("type = %v", schema["type"])
61+
}
62+
props := schema["properties"].(map[string]interface{})
63+
level := props["level"].(map[string]interface{})
64+
if level["x-widget"] != "Slider" {
65+
t.Fatalf("x-widget = %v", level["x-widget"])
66+
}
67+
})
68+
69+
t.Run("override previous hint", func(t *testing.T) {
70+
metadata, err := NewMetadataBuilder().
71+
SetID("f").
72+
SetFieldWidget("a", "Input").
73+
SetFieldWidget("a", "TextArea").
74+
Build()
75+
if err != nil {
76+
t.Fatalf("Build: %v", err)
77+
}
78+
schema := hintsOf(t, metadata.InputSchema)
79+
a := schema["properties"].(map[string]interface{})["a"].(map[string]interface{})
80+
if a["x-widget"] != "TextArea" {
81+
t.Fatalf("x-widget = %v (expected override)", a["x-widget"])
82+
}
83+
})
84+
85+
t.Run("x_ prefix normalized to x-", func(t *testing.T) {
86+
metadata, err := NewMetadataBuilder().
87+
SetID("f").
88+
SetFieldHint("a", "x_widget", "Input").
89+
Build()
90+
if err != nil {
91+
t.Fatalf("Build: %v", err)
92+
}
93+
schema := hintsOf(t, metadata.InputSchema)
94+
a := schema["properties"].(map[string]interface{})["a"].(map[string]interface{})
95+
if _, ok := a["x-widget"]; !ok {
96+
t.Fatalf("expected normalized x-widget key, got %v", a)
97+
}
98+
})
99+
100+
t.Run("invalid hint rejected", func(t *testing.T) {
101+
_, err := NewMetadataBuilder().
102+
SetID("f").
103+
SetFieldHint("a", "widget", "Input").
104+
Build()
105+
if err == nil || !strings.Contains(err.Error(), "x- extension key") {
106+
t.Fatalf("expected hint validation error, got %v", err)
107+
}
108+
})
109+
110+
t.Run("empty field rejected", func(t *testing.T) {
111+
_, err := NewMetadataBuilder().
112+
SetID("f").
113+
SetFieldHint("", "x-widget", "Input").
114+
Build()
115+
if err == nil || !strings.Contains(err.Error(), "field key is required") {
116+
t.Fatalf("expected field validation error, got %v", err)
117+
}
118+
})
119+
120+
t.Run("empty widget rejected", func(t *testing.T) {
121+
_, err := NewMetadataBuilder().
122+
SetID("f").
123+
SetFieldWidget("a", " ").
124+
Build()
125+
if err == nil || !strings.Contains(err.Error(), "widget is required") {
126+
t.Fatalf("expected widget validation error, got %v", err)
127+
}
128+
})
129+
130+
t.Run("invalid existing schema rejected", func(t *testing.T) {
131+
_, err := NewMetadataBuilder().
132+
SetID("f").
133+
SetInputSchema(`not-json`).
134+
SetFieldHint("a", "x-widget", "Input").
135+
Build()
136+
if err == nil || !strings.Contains(err.Error(), "not valid JSON") {
137+
t.Fatalf("expected schema parse error, got %v", err)
138+
}
139+
})
140+
}

sdks/js/src/hints.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* F14 验收测试:setFieldHint/setFieldWidget 便捷层
3+
*/
4+
import { setFieldHint, setFieldWidget, type FunctionDescriptor } from "./index";
5+
6+
function base(): FunctionDescriptor {
7+
return { id: "player.ban", version: "1.0.0" };
8+
}
9+
10+
describe("F14: setFieldHint", () => {
11+
test("空 schema 自动创建 object 骨架并合并 hint", () => {
12+
const descriptor = setFieldWidget(base(), "id", "Select");
13+
const schema = descriptor.inputSchema as Record<string, unknown>;
14+
expect(schema.type).toBe("object");
15+
const props = schema.properties as Record<string, Record<string, unknown>>;
16+
expect(props.id["x-widget"]).toBe("Select");
17+
});
18+
19+
test("保留既有字段属性,重复设置覆盖", () => {
20+
const descriptor = setFieldWidget(
21+
{
22+
...base(),
23+
inputSchema: {
24+
type: "object",
25+
properties: { id: { type: "string", title: "玩家 ID", "x-widget": "Input" } },
26+
},
27+
},
28+
"id",
29+
"TreeSelect",
30+
);
31+
const props = (descriptor.inputSchema as Record<string, unknown>).properties as Record<
32+
string,
33+
Record<string, unknown>
34+
>;
35+
expect(props.id.title).toBe("玩家 ID");
36+
expect(props.id["x-widget"]).toBe("TreeSelect");
37+
});
38+
39+
test("x-options-source 完整对象", () => {
40+
const descriptor = setFieldHint(base(), "id", "x-options-source", {
41+
functionId: "player.list",
42+
labelPath: "/items/*/name",
43+
valuePath: "/items/*/id",
44+
});
45+
const props = (descriptor.inputSchema as Record<string, unknown>).properties as Record<
46+
string,
47+
Record<string, unknown>
48+
>;
49+
expect(props.id["x-options-source"]).toEqual({
50+
functionId: "player.list",
51+
labelPath: "/items/*/name",
52+
valuePath: "/items/*/id",
53+
});
54+
});
55+
56+
test("非 x- hint 拒绝", () => {
57+
expect(() => setFieldHint(base(), "a", "widget", "Input")).toThrow(/x- extension key/);
58+
});
59+
60+
test("空 field 拒绝", () => {
61+
expect(() => setFieldHint(base(), " ", "x-widget", "Input")).toThrow(/field key is required/);
62+
});
63+
});

0 commit comments

Comments
 (0)