@morit/cli 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/docs/ai-response-and-timeline.md +18 -10
- package/assets/docs/components.md +18 -9
- package/assets/docs/examples-school-life.md +2 -2
- package/assets/docs/getting-started.md +1 -1
- package/assets/docs/manifest.md +14 -1
- package/assets/docs/packaging-and-testing.md +14 -4
- package/assets/docs/remote-mcp.md +1 -1
- package/assets/docs/sdk-and-mcp.md +1 -1
- package/assets/docs/tool-and-skill.md +17 -1
- package/assets/docs/ui-runtime-v2.md +71 -8
- package/assets/docs/verification.md +1 -1
- package/assets/plugin_contract.json +32 -8
- package/package.json +1 -1
- package/src/preview.js +254 -17
- package/src/workspace.js +158 -11
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Response UI와 Agent Timeline
|
|
2
2
|
|
|
3
|
-
Response UI는 capability 결과를 AI 메시지 안에서 시각화합니다.
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Response UI는 capability 결과를 AI 메시지 안에서 시각화합니다. AI는 필요한 A2UI
|
|
4
|
+
Native Tool을 선택하지만 Host는 우선 최종 텍스트 답변을 완료·저장합니다. 컴포넌트는
|
|
5
|
+
데이터 준비와 검증이 모두 성공한 뒤 해당 AI 메시지 본문 아래에 한 번에 자연스럽게
|
|
6
|
+
표시됩니다. Tool이 실패해도 답변 완료 상태는 바뀌지 않습니다.
|
|
6
7
|
|
|
7
8
|
## Response extension
|
|
8
9
|
|
|
@@ -73,9 +74,11 @@ capability를 호출합니다. 기존 `a2ui` 선언이 없는 Response extension
|
|
|
73
74
|
|
|
74
75
|
## AI 선택과 Host 검증
|
|
75
76
|
|
|
76
|
-
서버는 모든 Tool 결과에 UI를 일괄 생성하거나
|
|
77
|
-
답변
|
|
78
|
-
|
|
77
|
+
서버는 모든 Tool 결과에 UI를 일괄 생성하거나 키워드 정규식으로 컴포넌트를 강제하지 않습니다.
|
|
78
|
+
최종 답변 모델은 통합 Registry Skill의 용도와 입력 Schema를 의미적으로 비교해 Native Tool을
|
|
79
|
+
선택합니다. provider 컴포넌트에는 위치·통화·검색어만 넘기고, Host가 비동기로 실시간 데이터를
|
|
80
|
+
조회합니다. generated 컴포넌트에는 답변에 근거한 차트 값만 Schema에 맞게
|
|
81
|
+
전달합니다. Plugin 결과는 Host가 검증한 후보 데이터만 사용합니다.
|
|
79
82
|
|
|
80
83
|
선택 뒤 Host는 다음을 모두 통과한 항목만 A2UI v0.9 DataPart로 저장하고 해당 AI 메시지에 붙입니다.
|
|
81
84
|
|
|
@@ -86,23 +89,28 @@ capability를 호출합니다. 기존 `a2ui` 선언이 없는 Response extension
|
|
|
86
89
|
- Plugin 컴포넌트의 schema, A2UI version, 설치 상태, Instance 권한과 capability 연결이 유효함
|
|
87
90
|
|
|
88
91
|
빈 데이터, 실패한 Tool, 무관하거나 중복된 결과, 불확실한 선택은 UI를 만들지 않습니다. 선택 모델
|
|
89
|
-
호출이나 렌더링이 실패해도 일반 텍스트 답변은 그대로 완료됩니다.
|
|
90
|
-
|
|
92
|
+
호출이나 렌더링이 실패해도 일반 텍스트 답변은 그대로 완료됩니다. 로딩 문구,
|
|
93
|
+
skeleton, 빈 card는 렌더링하지 않고 준비가 끝난 컴포넌트만 한 번 표시합니다.
|
|
91
94
|
|
|
92
95
|
## 기본 A2UI Component Catalog
|
|
93
96
|
|
|
94
97
|
| component | 표시 조건 |
|
|
95
98
|
|---|---|
|
|
96
99
|
| `weather` | 현재 기온·상태 또는 예보가 하나 이상 유효함 |
|
|
100
|
+
| `world_clock` | 1~8개 위치의 IANA timezone과 현재 시각이 유효함 |
|
|
97
101
|
| `chart` | label과 수치가 있는 행이 2개 이상임; `bar`, `line`, `pie`, `scatter` |
|
|
98
102
|
| `image_gallery` | HTTPS 이미지와 원문 URL·출처명이 함께 있음 |
|
|
99
103
|
| `exchange_rate` | 기준 통화, 상대 통화, 유한한 환율 값이 있음 |
|
|
100
104
|
| `article_list` | 제목·출처·원문이 있는 서로 다른 기사 2개 이상 |
|
|
101
|
-
| `article_card` |
|
|
105
|
+
| `article_card` | 제목·출처·요약·본문 일부·원문이 있고, 게시 시각·이미지는 제공될 때 표시 |
|
|
102
106
|
| `file_result` | 실제 Morit item ID 또는 검증 가능한 다운로드 URL이 있음 |
|
|
103
107
|
| `image_preview` | 실제 image item 또는 검증 가능한 preview URL이 있음 |
|
|
104
108
|
| `video_preview` | 실제 video item/URL과 thumbnail URL이 함께 있음 |
|
|
105
109
|
|
|
110
|
+
`weather`, `exchange_rate`, `world_clock`, `article_list`, `article_card`, `image_gallery`는
|
|
111
|
+
provider mode이고 `chart`는 generated mode입니다. 기사와 이미지는 페이지 이동·다시 시도,
|
|
112
|
+
gallery는 반응형 grid/carousel을 지원합니다. 표는 일반 Markdown으로 답변하고 단계별
|
|
113
|
+
텍스트 slider는 catalog에서 제거되었습니다.
|
|
106
114
|
각 renderer도 같은 필수 값을 다시 확인하고, 값이 바뀌거나 손상되면 해당 컴포넌트만 fallback합니다.
|
|
107
115
|
|
|
108
116
|
## Plugin 컴포넌트의 동적 등록
|
|
@@ -134,7 +142,7 @@ Plugin Response는 Runtime v2의 전체 layout, surface, theme, image, action과
|
|
|
134
142
|
- Response 내부에 자체 채팅 입력창을 만들지 않습니다.
|
|
135
143
|
- 무한 높이 목록 대신 요약과 상세 화면 이동을 제공합니다.
|
|
136
144
|
- background refresh가 대화 scroll 위치를 바꾸지 않게 기존 높이와 데이터를 가능한 유지합니다.
|
|
137
|
-
- 렌더링 오류는 해당 Response
|
|
145
|
+
- 렌더링 오류는 해당 Response UI를 생략하고 이미 완료된 텍스트 답변을 유지합니다.
|
|
138
146
|
- accessibility 순서는 AI 본문 다음, Response 제목, 내용, 행동 순으로 유지합니다.
|
|
139
147
|
|
|
140
148
|
## Text fallback
|
|
@@ -119,16 +119,21 @@ action에는 반드시 `label`, 일반 `icon` node에는 `semantic_label`을 함
|
|
|
119
119
|
|---|---|---|
|
|
120
120
|
| `button` | 명시적인 주·보조 행동 | `label`, `icon`, `style`, `full_width`, `enabled` |
|
|
121
121
|
| `chip` | 필터·짧은 선택 | `label`, `icon`, `selected`, `enabled` |
|
|
122
|
-
| `field` |
|
|
123
|
-
| `select` |
|
|
122
|
+
| `field` | 텍스트·숫자·autocomplete 입력 | `state_key`, `label`, `placeholder`, `input_type`, `suggestions`/`suggestions_source`, `persist` |
|
|
123
|
+
| `select` | 고정·비동기·검색 선택지 | `state_key`, `label`, `options`/`options_source`, `searchable`, `persist` |
|
|
124
124
|
| `switch` | boolean 설정 | `state_key`, `label`, `persist` |
|
|
125
|
-
| `form` | 관련 입력 묶음 | `spacing`, children, submit action
|
|
125
|
+
| `form` | 관련 입력 묶음 | `spacing`, `submit_label`, children, 선택적 submit action |
|
|
126
126
|
| `dialog` | 짧고 집중된 확인·편집 | `title`, `label`, children |
|
|
127
127
|
| `sheet` | 모바일 중심의 보조 작업 | `title`, `label`, children |
|
|
128
128
|
|
|
129
129
|
`field`, `select`, `switch`의 `state_key`는 `initial_state`에 먼저 선언해야 합니다. select options는
|
|
130
|
-
1~32개의 `{ "value": ..., "label": "..." }` 객체입니다.
|
|
131
|
-
|
|
130
|
+
1~32개의 `{ "value": ..., "label": "..." }` 객체입니다. 정적과 source 옵션을 둘 다 선언할 수
|
|
131
|
+
없습니다. `required`, 문자열 길이, 숫자 범위와 `error_text`는 Host form validation에
|
|
132
|
+
사용됩니다. `enabled: false`는 이유를 주변 문구로 설명할 때만 사용합니다.
|
|
133
|
+
|
|
134
|
+
action은 `surface`, `card`, `button`, `chip`, `form`, `field`, `select`, `switch`에만 붙일 수
|
|
135
|
+
있습니다. 입력 action 직전에 Host가 IME composition과 controller를 commit하며,
|
|
136
|
+
`validate: true`는 잘못된 form의 Tool·navigation 실행을 차단합니다.
|
|
132
137
|
|
|
133
138
|
## 반복과 데이터 시각화
|
|
134
139
|
|
|
@@ -192,10 +197,14 @@ semantic label, 최소 탭 영역과 색 이외의 상태 표시는 Host Materia
|
|
|
192
197
|
## A2UI Response catalog와의 관계
|
|
193
198
|
|
|
194
199
|
위 node는 Plugin Runtime을 구성하는 primitive입니다. AI가 직접 선택하는 Response catalog는
|
|
195
|
-
`weather`, `
|
|
196
|
-
`file_result`, `image_preview`, `video_preview`와 활성
|
|
197
|
-
|
|
198
|
-
|
|
200
|
+
`weather`, `world_clock`, `chart`, `image_gallery`,
|
|
201
|
+
`exchange_rate`, `article_list`, `article_card`, `file_result`, `image_preview`, `video_preview`와 활성
|
|
202
|
+
Plugin이 등록한 `plugin.<extension-id>`로 구성됩니다. 최종 답변 모델은 같은 Registry Skill과 Native
|
|
203
|
+
Tool에서 출처(`builtin`/`plugin`), mode(`provider`/`generated`/`result`), 용도와 Schema를 확인합니다.
|
|
204
|
+
provider mode는 조회 조건만, generated mode는 답변에 근거한 표시 값만 전달하며 Host가 검증한 뒤
|
|
205
|
+
Runtime tree에 주입합니다. 특정 키워드만으로 Tool을 강제하지 않습니다.
|
|
206
|
+
구조화 표는 A2UI 전용 컴포넌트 대신 Markdown table을 사용합니다. 위 표의
|
|
207
|
+
Runtime `table` node는 플러그인 화면 호환성을 위한 primitive로 계속 지원합니다.
|
|
199
208
|
자세한 등록·fallback 규칙은 [Response UI와 Agent Timeline](ai-response-and-timeline.md)을
|
|
200
209
|
참고하세요.
|
|
201
210
|
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
|
|
33
33
|
```text
|
|
34
34
|
examples/plugins/school-life/dist/school-life-1.5.0.mplg
|
|
35
|
-
크기
|
|
36
|
-
SHA-256
|
|
35
|
+
크기 263438 bytes
|
|
36
|
+
SHA-256 dfd39c29154c50bd14b4eeed390efffa7fa5ab3c6ad9361730eea67bdb836276
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
1. Morit 플러그인 관리에서 위 `.mplg`를 선택합니다.
|
|
@@ -47,7 +47,7 @@ revision만 있으며 인증 정보는 들어가지 않습니다.
|
|
|
47
47
|
"description": "오늘 할 일을 정리합니다.",
|
|
48
48
|
"publisher": "example",
|
|
49
49
|
"version": "1.0.0",
|
|
50
|
-
"min_morit_version": "1.7.
|
|
50
|
+
"min_morit_version": "1.7.12",
|
|
51
51
|
"max_morit_version": "1.999.999",
|
|
52
52
|
"permissions": [],
|
|
53
53
|
"capabilities": [
|
package/assets/docs/manifest.md
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"privacy_policy_url": "https://example.com/privacy",
|
|
21
21
|
"publisher": "example",
|
|
22
22
|
"version": "1.0.0",
|
|
23
|
-
"min_morit_version": "1.7.
|
|
23
|
+
"min_morit_version": "1.7.12",
|
|
24
24
|
"max_morit_version": "1.999.999",
|
|
25
25
|
"permissions": ["network", "credentials"],
|
|
26
26
|
"capabilities": [
|
|
@@ -110,6 +110,16 @@ namespace, quota, migration과 표준 CRUD Tool은 [Plugin Local Storage와 AI
|
|
|
110
110
|
"description": "연결한 계정의 노트를 검색합니다.",
|
|
111
111
|
"permissions": ["network", "credentials"],
|
|
112
112
|
"timeout_seconds": 10,
|
|
113
|
+
"input_schema": {
|
|
114
|
+
"type": "object",
|
|
115
|
+
"properties": {"query": {"type": "string", "minLength": 1}},
|
|
116
|
+
"required": ["query"]
|
|
117
|
+
},
|
|
118
|
+
"output_schema": {
|
|
119
|
+
"type": "object",
|
|
120
|
+
"properties": {"items": {"type": "array", "items": {"type": "object"}}},
|
|
121
|
+
"required": ["items"]
|
|
122
|
+
},
|
|
113
123
|
"runtime": {
|
|
114
124
|
"adapter": "http_json",
|
|
115
125
|
"connector_id": "notes_api"
|
|
@@ -121,6 +131,9 @@ namespace, quota, migration과 표준 CRUD Tool은 [Plugin Local Storage와 AI
|
|
|
121
131
|
timeout은 0.1~30초입니다. capability 권한은 Manifest 권한의 부분집합이어야 합니다. `tool`과
|
|
122
132
|
`skill`은 실행 가능한 runtime adapter가 필수입니다. Search `provider`는
|
|
123
133
|
`runtime.role: "search"`와 adapter가 필요합니다.
|
|
134
|
+
`input_schema`/`output_schema`는 선택이지만 선언하면 AI Tool·UI·Host가 모두 같은
|
|
135
|
+
안전한 JSON Schema 부분집합으로 입력과 결과를 검증합니다. UI binding 정적 검사는
|
|
136
|
+
[UI Runtime v2](ui-runtime-v2.md)를 참고하세요.
|
|
124
137
|
|
|
125
138
|
## UI extension
|
|
126
139
|
|
|
@@ -75,8 +75,10 @@ npx -y @morit/cli plugin preview . --output ./dist/preview.html
|
|
|
75
75
|
python tools/morit_plugin.py preview . --output ./dist/preview.html
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
-
정보 구조와 compiled manifest, light/dark partial theme를 점검합니다.
|
|
79
|
-
|
|
78
|
+
정보 구조와 compiled manifest, light/dark partial theme를 점검합니다. Preview는 Flutter Host와
|
|
79
|
+
같은 state/binding/compound condition, form validation, autocomplete·searchable select, 순차·병렬
|
|
80
|
+
action flow, route parameter/result 규칙을 사용하며 Runtime Inspector로 변경을 확인할 수 있습니다.
|
|
81
|
+
실제 인증 Tool·native route stack·플랫폼 IME 동작은 Flutter 앱에서 최종 확인합니다.
|
|
80
82
|
|
|
81
83
|
### 3. Package reopen
|
|
82
84
|
|
|
@@ -100,6 +102,12 @@ python tools/morit_plugin.py verify ./dist/plugin.mplg
|
|
|
100
102
|
- local file 설치
|
|
101
103
|
- 활성화와 권한 허용·거부
|
|
102
104
|
- 화면 loading·empty·error·retry
|
|
105
|
+
- 한국어 IME 입력 직후 Tool arguments, focus 전환과 controller 동기화
|
|
106
|
+
- autocomplete, searchable/async select, form validation, 순차·병렬 action flow
|
|
107
|
+
- route parameters·result 반환, permission/connection/platform/screen 복합 조건
|
|
108
|
+
- 홈·플러그인 화면·AI Response UI 간 source cache 재사용과 storage mutation 자동 refresh
|
|
109
|
+
- A2UI 답변 완료 후 즉시 렌더링, 실패 생략, 로딩 UI 미표시
|
|
110
|
+
- 미디어 overlay의 영상·MP3 옵션, 실제 변환·진행률·재시도·모바일 너비
|
|
103
111
|
- 시스템/라이트/다크 전환과 기존 theme 없는 package 호환
|
|
104
112
|
- Storage CRUD·검색·즉시 UI 갱신, AI read/read_write 권한
|
|
105
113
|
- 사용자·Installation·Instance·namespace 격리와 quota/revision/generation 충돌
|
|
@@ -111,8 +119,10 @@ python tools/morit_plugin.py verify ./dist/plugin.mplg
|
|
|
111
119
|
|
|
112
120
|
### 6. 회귀 검사
|
|
113
121
|
|
|
114
|
-
SDK, Python Host, Flutter schema, Local MCP, Remote MCP가 같은 contract
|
|
115
|
-
|
|
122
|
+
SDK, Python Host, Flutter schema/runtime, Local MCP, Remote MCP, Preview가 같은 contract와 경계를
|
|
123
|
+
반환하고 같은 fixture를 허용·거부하는지 확인합니다. capability input/output schema와 UI
|
|
124
|
+
binding도 모든 validator에서 같이 실패해야 합니다. 문서 예제 JSON도 source 검증 과정에서
|
|
125
|
+
실행해 구현과 함께 변경합니다.
|
|
116
126
|
|
|
117
127
|
## 실패 시 원칙
|
|
118
128
|
|
|
@@ -32,7 +32,7 @@ claude mcp add --transport http --scope user \
|
|
|
32
32
|
|
|
33
33
|
| Tool | 목적 |
|
|
34
34
|
|---|---|
|
|
35
|
-
| `morit_sdk_contract` | SDK 1.7.
|
|
35
|
+
| `morit_sdk_contract` | SDK 1.7.12 Theme·Storage·UI/Host capability contract 전체 반환 |
|
|
36
36
|
| `morit_docs_search` | bundled 공식 문서 검색 |
|
|
37
37
|
| `morit_docs_get` | 검색 결과의 Markdown 한 파일 읽기 |
|
|
38
38
|
|
|
@@ -7,7 +7,7 @@ Morit은 두 MCP 연결을 제공합니다.
|
|
|
7
7
|
| Local `@morit/plugin-mcp` | 개발자 PC의 stdio process | 로컬 파일 직접 편집, 빠른 preview/build |
|
|
8
8
|
| Remote `https://morit-api.moring.co/mcp` | Morit Cloud Streamable HTTP | 파일시스템 없는 AI client, 조직 Project·Secret·Deployment |
|
|
9
9
|
|
|
10
|
-
두 연결은 SDK 1.7.
|
|
10
|
+
두 연결은 SDK 1.7.12의 `@morit/cli`와 Host Plugin contract를 사용합니다. 계약에는 mode별 Plugin
|
|
11
11
|
Theme, Local Storage/AI access, 표준 Storage Tool, UI binding, A2UI Response catalog와 safe
|
|
12
12
|
preview가 포함됩니다. Local source에 접근할 필요가 없으면 Remote를, 현재 저장소 파일을 직접
|
|
13
13
|
고쳐야 하면 Local을 선택합니다.
|
|
@@ -74,7 +74,7 @@ GET과 POST만 지원하며 redirect를 따라가지 않습니다. endpoint는
|
|
|
74
74
|
|
|
75
75
|
### Remote MCP
|
|
76
76
|
|
|
77
|
-
Morit Host 1.7.
|
|
77
|
+
Morit Host 1.7.12의 plugin outbound MCP adapter는 `mcp_http`입니다. 개발자용 Local/Remote Plugin
|
|
78
78
|
MCP 서버와 이름이 비슷하지만, 설치된 플러그인이 선언한 외부 MCP Tool을 Host 경계에서 호출하는
|
|
79
79
|
runtime입니다.
|
|
80
80
|
|
|
@@ -133,10 +133,26 @@ enable/disable·권한 변경·Connection 생성/수정/삭제·OAuth 완료·
|
|
|
133
133
|
"description": "연결한 학교의 오늘 시간표, 급식, 학사일정을 조회합니다.",
|
|
134
134
|
"permissions": ["network", "storage"],
|
|
135
135
|
"timeout_seconds": 10,
|
|
136
|
+
"input_schema": {
|
|
137
|
+
"type": "object",
|
|
138
|
+
"properties": {"date": {"type": "string"}},
|
|
139
|
+
"additionalProperties": false
|
|
140
|
+
},
|
|
141
|
+
"output_schema": {
|
|
142
|
+
"type": "object",
|
|
143
|
+
"properties": {"timetable": {"type": "array", "items": {"type": "object"}}},
|
|
144
|
+
"required": ["timetable"]
|
|
145
|
+
},
|
|
136
146
|
"runtime": {"adapter": "neis_school", "operation": "overview"}
|
|
137
147
|
}
|
|
138
148
|
```
|
|
139
149
|
|
|
150
|
+
`input_schema`/`output_schema`는 root `object`인 안전한 JSON Schema 부분집합입니다. AI Tool,
|
|
151
|
+
UI data source·invoke, Host 실행과 결과 binding이 같은 schema를 사용합니다. CLI/MCP validate는
|
|
152
|
+
필수 필드·타입·범위와 `data.<source>.data...` 경로를 package build 전에 검사합니다.
|
|
153
|
+
실행 시 schema와 다른 외부 응답은 정상 결과로 변환하지 않고 해당 capability 오류로
|
|
154
|
+
격리합니다.
|
|
155
|
+
|
|
140
156
|
UI `invoke`가 capability를 참조할 때도 enabled, generation, permission, timeout을 다시 확인합니다.
|
|
141
157
|
UI에 capability ID를 사용자용 라벨로 표시하지 않습니다.
|
|
142
158
|
|
|
@@ -73,7 +73,8 @@ UI Runtime v2는 서명된 `.mplg`가 화면, 상태, data source, action을 JSO
|
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
허용 config 필드는 `ui_schema`, `icon`, `description`, `placement`, `app_bar`, `navigation`,
|
|
76
|
-
`theme`, `initial_state`, `data_sources`, `view`입니다.
|
|
76
|
+
`theme`, `initial_state`, `data_sources`, `a2ui`, `view`입니다. `a2ui`는 `point: "response"`에서만
|
|
77
|
+
사용합니다.
|
|
77
78
|
|
|
78
79
|
## 트리와 크기 제한
|
|
79
80
|
|
|
@@ -150,9 +151,19 @@ theme를 생략한 기존 플러그인은 Morit의 현재 Material 3 라이트·
|
|
|
150
151
|
```
|
|
151
152
|
|
|
152
153
|
`field`, `select`, `switch`, navigation selection, `set_state`는 여기에 선언된 key만 변경합니다.
|
|
154
|
+
Host는 focus된 입력과 `TextEditingController`를 action 실행 직전에 commit하므로 조합 중인
|
|
155
|
+
한국어 IME 문자도 Tool arguments와 validation에 최신 값으로 반영됩니다.
|
|
153
156
|
`persist: true`는 Instance settings의 extension 전용 namespace에 저장합니다. credential과 민감한
|
|
154
157
|
사용자 데이터는 state에 저장하지 않습니다.
|
|
155
158
|
|
|
159
|
+
### 입력과 검증
|
|
160
|
+
|
|
161
|
+
- `field.suggestions`는 정적 autocomplete 문자열, `suggestions_source`는 `data.<source>...` 배열입니다.
|
|
162
|
+
- `select.options`는 정적 `{value,label}` 목록, `options_source`는 capability가 돌려준 비동기 목록입니다.
|
|
163
|
+
- `select.searchable: true`는 Material 3 검색 select를 사용합니다.
|
|
164
|
+
- `required`, `min_length`, `max_length`, `minimum`, `maximum`, `error_text`는 `field`/`select`/`switch`
|
|
165
|
+
검증에 사용합니다. action의 `validate: true`는 잘못된 form을 실행하지 않습니다.
|
|
166
|
+
|
|
156
167
|
## Data source와 binding
|
|
157
168
|
|
|
158
169
|
```json
|
|
@@ -185,6 +196,14 @@ arguments에 전달하고, 다른 글자와 섞으면 텍스트 template이 됩
|
|
|
185
196
|
{"label": "{{state.period}} 일정"}
|
|
186
197
|
```
|
|
187
198
|
|
|
199
|
+
`context.platform`, `context.screen.width|height|size_class`, `context.permissions.<permission>`,
|
|
200
|
+
`context.connections.<credential>`, `context.connected`, `context.loading.<source>`,
|
|
201
|
+
`context.errors.<source>`, `context.route.<parameter>`도 조건과 binding에 사용할 수 있습니다.
|
|
202
|
+
|
|
203
|
+
Capability의 `input_schema`/`output_schema`는 안전한 JSON Schema 부분집합을 사용합니다. validate는
|
|
204
|
+
data source와 invoke arguments를 input schema와, `data.<source>.data...` binding을 output schema와 정적으로
|
|
205
|
+
대조합니다. 필수 필드·타입·존재하지 않는 경로는 package build 전에 구체적인 오류로 거부됩니다.
|
|
206
|
+
|
|
188
207
|
### Local Storage binding
|
|
189
208
|
|
|
190
209
|
저장소도 같은 data source/action 흐름을 사용합니다.
|
|
@@ -276,6 +295,25 @@ form dialog sheet field select switch empty
|
|
|
276
295
|
|
|
277
296
|
`store`는 선언된 data source ID이며 성공 결과로 해당 source를 교체합니다.
|
|
278
297
|
|
|
298
|
+
### Action Flow와 Form
|
|
299
|
+
|
|
300
|
+
```json
|
|
301
|
+
{
|
|
302
|
+
"type": "flow",
|
|
303
|
+
"mode": "sequential",
|
|
304
|
+
"validate": true,
|
|
305
|
+
"continue_on_error": false,
|
|
306
|
+
"actions": [
|
|
307
|
+
{"type": "invoke", "capability": "school.save", "arguments": {"name": "{{state.name}}"}, "store": "saved"},
|
|
308
|
+
{"type": "navigate", "target": "school.details", "parameters": {"id": "{{data.saved.data.id}}"}, "result_state": "route_result"}
|
|
309
|
+
]
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
`mode`는 `sequential` 또는 `parallel`입니다. flow는 최대 4 depth, 전체 16 action, flow당
|
|
314
|
+
8 action으로 제한됩니다. 순차 flow는 앞 action의 store를 다음 action에서 즉시 읽고,
|
|
315
|
+
병렬 flow는 서로 의존하지 않는 action을 함께 실행합니다.
|
|
316
|
+
|
|
279
317
|
### State와 refresh
|
|
280
318
|
|
|
281
319
|
```json
|
|
@@ -292,13 +330,17 @@ form dialog sheet field select switch empty
|
|
|
292
330
|
{
|
|
293
331
|
"type": "navigate",
|
|
294
332
|
"target": "school_life.details",
|
|
333
|
+
"parameters": {"date": "{{state.date}}"},
|
|
334
|
+
"result_state": "details_result",
|
|
295
335
|
"transition": "platform",
|
|
296
336
|
"replace": false
|
|
297
337
|
}
|
|
298
338
|
```
|
|
299
339
|
|
|
300
340
|
`transition`은 `platform`, `fade`, `slide`, `none`입니다. `replace: true`는 현재 route를 뒤로가기
|
|
301
|
-
stack에서 교체합니다. 같은 manifest의 UI extension ID만 target이 될 수 있습니다.
|
|
341
|
+
stack에서 교체합니다. 같은 manifest의 UI extension ID만 target이 될 수 있습니다. `parameters`는
|
|
342
|
+
대상의 `context.route.<parameter>`로 전달되고 닫힌 결과는 `result_state`에 지정한
|
|
343
|
+
`initial_state` key에 반영됩니다. 대상은 `back.result`로 결과 객체를 반환합니다.
|
|
302
344
|
|
|
303
345
|
```json
|
|
304
346
|
{"type": "back"}
|
|
@@ -315,8 +357,9 @@ stack에서 교체합니다. 같은 manifest의 UI extension ID만 target이 될
|
|
|
315
357
|
}
|
|
316
358
|
```
|
|
317
359
|
|
|
318
|
-
`equals`, `not_equals`, `exists` 중 정확히 하나를 사용합니다.
|
|
319
|
-
|
|
360
|
+
단일 조건은 `equals`, `not_equals`, `exists` 중 정확히 하나를 사용합니다. `all`, `any`, `not`으로
|
|
361
|
+
조건을 재귀적으로 조합할 수 있으며 조건 depth 4, 전체 32개로 제한됩니다. 조건은
|
|
362
|
+
`state`, `data`, `item`, `context`만 읽고 임의 expression을 실행하지 않습니다.
|
|
320
363
|
|
|
321
364
|
## 로딩과 오류 복구
|
|
322
365
|
|
|
@@ -325,6 +368,18 @@ Host는 source별 실행을 격리하고 중복 실행을 합칩니다. 화면
|
|
|
325
368
|
유지하고, 첫 로드도 실패한 경우에만 해당 영역에 retry 상태를 표시합니다. package generation이나
|
|
326
369
|
화면이 바뀐 뒤 도착한 오래된 응답은 버립니다.
|
|
327
370
|
|
|
371
|
+
Host는 Instance·capability·해석된 query/arguments가 같은 결과를 Shell 수명의 공유 cache에
|
|
372
|
+
보관합니다. 홈, 플러그인 화면, AI Response UI에서 같은 source를 열면 추가 호출 없이
|
|
373
|
+
즉시 재사용하고 mutation/refresh 결과를 구독 중인 모든 surface에 자동 전파합니다.
|
|
374
|
+
알림 Tool은 같은 Instance local storage를 source of truth로 읽으며 알림 action의 storage mutation도
|
|
375
|
+
storage revision을 갱신해 열린 UI source를 자동 refresh합니다.
|
|
376
|
+
|
|
377
|
+
## Runtime Inspector
|
|
378
|
+
|
|
379
|
+
Preview와 debug Host의 `Runtime Inspector`는 현재 state, data source 결과, context, loading/error,
|
|
380
|
+
마지막 action과 검증 오류를 표시합니다. secret과 credential 값은 포함하지 않으며 배포 build의
|
|
381
|
+
일반 사용자 화면에서는 노출하지 않습니다.
|
|
382
|
+
|
|
328
383
|
## Response UI
|
|
329
384
|
|
|
330
385
|
`point: "response"`도 같은 `theme`, `app_bar`, `navigation`, data/state/node/action 계약을
|
|
@@ -337,7 +392,15 @@ catalog는 [Response UI와 Agent Timeline](ai-response-and-timeline.md)을 참
|
|
|
337
392
|
## Preview와 실제 Host
|
|
338
393
|
|
|
339
394
|
`morit plugin preview . --output ./dist/preview.html` 또는 `morit_project_preview`는 validate를 먼저
|
|
340
|
-
통과한 config를 실행 코드 없이 안전한 HTML로 렌더링합니다.
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
395
|
+
통과한 config를 실행 코드 없이 안전한 HTML로 렌더링합니다. Preview는 Flutter Host와 같은
|
|
396
|
+
binding, compound condition, state commit, form validation, autocomplete/select, 순차·병렬 flow,
|
|
397
|
+
route params/result 규칙을 적용하고 invoke/navigation을 검증 가능한 Host event로 노출합니다.
|
|
398
|
+
상단 토글로 light/dark variant와 Host 상속 결과를 비교하고 Runtime Inspector로 상태를 확인합니다.
|
|
399
|
+
실제 인증·권한·network Tool과 native route stack만 서명 package를 앱에 설치해 최종 확인합니다.
|
|
400
|
+
|
|
401
|
+
## 1.7.12 호환성
|
|
402
|
+
|
|
403
|
+
기존 UI v2 manifest는 변경 없이 동작합니다. compound condition, action flow, input 검증,
|
|
404
|
+
async options, route data, capability schema는 모두 additive입니다. 기존 단일 action과
|
|
405
|
+
`{path, equals|not_equals|exists}` 조건은 그대로 유지됩니다. 새 필드를 사용하는 package는
|
|
406
|
+
`min_morit_version: "1.7.12"`를 지정합니다.
|
|
@@ -10,7 +10,7 @@ SHA-256을 함께 남기면 나중에 같은 결과를 재현할 수 있습니
|
|
|
10
10
|
|
|
11
11
|
| 패키지 | 크기 | SHA-256 |
|
|
12
12
|
|---|---:|---|
|
|
13
|
-
| `school-life-1.5.0.mplg` |
|
|
13
|
+
| `school-life-1.5.0.mplg` | 263438 bytes | `dfd39c29154c50bd14b4eeed390efffa7fa5ab3c6ad9361730eea67bdb836276` |
|
|
14
14
|
| `notion-1.1.0.mplg` | 3044 bytes | `eba57c440b305343c5cf917259e30e50b322272cfadb0ed64d39ecfc47539c4e` |
|
|
15
15
|
|
|
16
16
|
```powershell
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"sdk_version": "1.7.
|
|
2
|
+
"sdk_version": "1.7.12",
|
|
3
3
|
"manifest_schema_versions": [1, 2],
|
|
4
4
|
"recommended_schema_version": 2,
|
|
5
5
|
"package_content_type": "application/vnd.morit.plugin+zip",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"slash_commands": {"target": "slash_commands", "kind": null}
|
|
46
46
|
},
|
|
47
47
|
"object_fields": {
|
|
48
|
-
"capability": ["id", "kind", "title", "description", "permissions", "timeout_seconds", "runtime"],
|
|
48
|
+
"capability": ["id", "kind", "title", "description", "permissions", "timeout_seconds", "runtime", "input_schema", "output_schema"],
|
|
49
49
|
"ui_extension": ["id", "point", "title", "order", "permissions", "config"],
|
|
50
50
|
"credential": ["id", "label", "description", "kind", "required", "oauth_provider", "allow_multiple"],
|
|
51
51
|
"slash_command": ["id", "command", "title", "description", "capability", "argument_template"],
|
|
@@ -116,10 +116,14 @@
|
|
|
116
116
|
"catalog_id": "https://moring.co/a2ui/catalogs/morit-ai/v1",
|
|
117
117
|
"selection": "ai_explicit_host_validated",
|
|
118
118
|
"selection_limit": 4,
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
"skill": "morit_a2ui",
|
|
120
|
+
"tool": "render_a2ui",
|
|
121
|
+
"model_selection_fields": ["candidate_id", "component", "values", "chart_type"],
|
|
122
|
+
"host_injects_tool_data": false,
|
|
123
|
+
"tool_values_must_equal_candidate": false,
|
|
124
|
+
"tool_values_must_be_candidate_grounded": true,
|
|
121
125
|
"fallback": "text_only",
|
|
122
|
-
"built_in_components": ["weather", "chart", "image_gallery", "exchange_rate", "article_list", "article_card", "file_result", "image_preview", "video_preview"],
|
|
126
|
+
"built_in_components": ["weather", "world_clock", "chart", "image_gallery", "exchange_rate", "article_list", "article_card", "file_result", "image_preview", "video_preview"],
|
|
123
127
|
"validation_gates": ["completed_or_partial", "non_empty", "component_data", "answer_relevance", "not_duplicate", "plugin_schema", "plugin_permission", "plugin_version"],
|
|
124
128
|
"plugin_component_prefix": "plugin.",
|
|
125
129
|
"plugin_metadata_fields": ["version", "description", "schema"],
|
|
@@ -278,6 +282,17 @@
|
|
|
278
282
|
"top",
|
|
279
283
|
"right",
|
|
280
284
|
"bottom"
|
|
285
|
+
,"required"
|
|
286
|
+
,"min_length"
|
|
287
|
+
,"max_length"
|
|
288
|
+
,"minimum"
|
|
289
|
+
,"maximum"
|
|
290
|
+
,"error_text"
|
|
291
|
+
,"suggestions"
|
|
292
|
+
,"suggestions_source"
|
|
293
|
+
,"searchable"
|
|
294
|
+
,"options_source"
|
|
295
|
+
,"submit_label"
|
|
281
296
|
],
|
|
282
297
|
"app_bar_fields": ["title", "subtitle", "center_title", "pinned", "leading", "actions"],
|
|
283
298
|
"navigation_fields": ["type", "items", "selected_state_key", "persist", "label_behavior", "rail_breakpoint"],
|
|
@@ -289,8 +304,11 @@
|
|
|
289
304
|
"theme_border_fields": ["color", "width", "radius"],
|
|
290
305
|
"theme_icon_fields": ["color", "size"],
|
|
291
306
|
"theme_state_fields": ["disabled_opacity", "selected_color", "focus_color"],
|
|
292
|
-
"action_fields": ["type", "capability", "query", "arguments", "store", "target", "source", "values", "persist", "transition", "replace"],
|
|
293
|
-
"actions": ["invoke", "navigate", "set_state", "refresh", "back"],
|
|
307
|
+
"action_fields": ["type", "capability", "query", "arguments", "store", "target", "source", "values", "persist", "transition", "replace", "mode", "actions", "continue_on_error", "validate", "parameters", "result_state", "result"],
|
|
308
|
+
"actions": ["invoke", "navigate", "set_state", "refresh", "back", "flow"],
|
|
309
|
+
"condition_fields": ["path", "equals", "not_equals", "exists", "all", "any", "not"],
|
|
310
|
+
"condition_context_roots": ["platform", "screen", "permissions", "connections", "connected", "loading", "errors", "route"],
|
|
311
|
+
"action_flow_modes": ["sequential", "parallel"],
|
|
294
312
|
"navigation_transitions": ["platform", "fade", "slide", "none"],
|
|
295
313
|
"chart_types": ["bar", "line", "donut", "scatter"],
|
|
296
314
|
"navigation_types": ["tabs", "bar", "rail", "drawer", "adaptive"],
|
|
@@ -408,7 +426,13 @@
|
|
|
408
426
|
"data_sources": 8,
|
|
409
427
|
"state_keys": 32,
|
|
410
428
|
"navigation_items": 8,
|
|
411
|
-
"app_bar_actions": 6
|
|
429
|
+
"app_bar_actions": 6,
|
|
430
|
+
"condition_depth": 4,
|
|
431
|
+
"condition_nodes": 32,
|
|
432
|
+
"action_flow_depth": 4,
|
|
433
|
+
"action_flow_actions": 16,
|
|
434
|
+
"actions_per_flow": 8,
|
|
435
|
+
"input_options": 32
|
|
412
436
|
}
|
|
413
437
|
},
|
|
414
438
|
"signature": {
|
package/package.json
CHANGED
package/src/preview.js
CHANGED
|
@@ -51,27 +51,53 @@ function themeVariables(config) {
|
|
|
51
51
|
return values.join(";");
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
function jsonAttribute(value) {
|
|
55
|
+
return escape(JSON.stringify(value));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function nodeAttributes(node) {
|
|
59
|
+
const attributes = [];
|
|
60
|
+
if (node.visible_when) attributes.push('data-visible="' + jsonAttribute(node.visible_when) + '"');
|
|
61
|
+
if (node.action) attributes.push('data-action="' + jsonAttribute(node.action) + '"');
|
|
62
|
+
return attributes.length ? " " + attributes.join(" ") : "";
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
function nodeHtml(node) {
|
|
55
66
|
if (!node || typeof node !== "object") return "";
|
|
56
67
|
const type = String(node.type || "surface");
|
|
57
68
|
const props = node.props && typeof node.props === "object" ? node.props : {};
|
|
58
69
|
const children = Array.isArray(node.children) ? node.children.map(nodeHtml).join("") : "";
|
|
59
70
|
const label = escape(props.label || props.title || type);
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
if (type === "
|
|
63
|
-
if (type === "
|
|
64
|
-
if (type === "
|
|
65
|
-
if (type === "
|
|
71
|
+
const attributes = nodeAttributes(node);
|
|
72
|
+
const template = (value) => ' data-template="' + escape(value ?? "") + '"';
|
|
73
|
+
if (type === "text") return '<p class="ui-text ' + escape(props.style) + '"' + attributes + template(props.text) + '>' + escape(props.text) + "</p>";
|
|
74
|
+
if (type === "icon") return '<span class="ui-icon" role="img" aria-label="' + escape(props.semantic_label || props.icon) + '"' + attributes + '>' + escape(props.icon) + "</span>";
|
|
75
|
+
if (type === "image") return '<div class="ui-image" role="img" aria-label="' + escape(props.semantic_label || "image") + '"' + attributes + '>🖼 ' + escape(props.asset || props.url || "image") + "</div>";
|
|
76
|
+
if (type === "avatar") return '<span class="ui-avatar"' + attributes + '>' + escape(props.label || "M").slice(0, 1) + "</span>";
|
|
77
|
+
if (type === "badge") return '<span class="ui-badge"' + attributes + '>' + label + children + "</span>";
|
|
66
78
|
if (type === "divider") return "<hr>";
|
|
67
79
|
if (type === "spacer") return '<span class="ui-spacer" aria-hidden="true"></span>';
|
|
68
|
-
if (type === "button") return "<button" +
|
|
69
|
-
if (type === "chip") return '<
|
|
70
|
-
if (type === "metric") return '<div class="ui-metric"><strong>' + escape(props.value) + "</strong><span>" + label + "</span></div>";
|
|
71
|
-
if (type === "progress") return '<div class="ui-progress"><span>' + label + '</span><progress value="' + Number(props.value ?? .5) + '" max="1"></progress></div>';
|
|
72
|
-
if (type === "field")
|
|
73
|
-
|
|
74
|
-
|
|
80
|
+
if (type === "button") return "<button type=\"button\"" + attributes + ">" + (props.icon ? escape(props.icon) + " " : "") + label + "</button>";
|
|
81
|
+
if (type === "chip") return '<button type="button" class="ui-chip"' + attributes + '>' + label + "</button>";
|
|
82
|
+
if (type === "metric") return '<div class="ui-metric"' + attributes + '><strong' + template(props.value) + '>' + escape(props.value) + "</strong><span>" + label + "</span></div>";
|
|
83
|
+
if (type === "progress") return '<div class="ui-progress"' + attributes + '><span>' + label + '</span><progress data-value-template="' + escape(props.value ?? .5) + '" value="' + Number(props.value ?? .5) + '" max="1"></progress></div>';
|
|
84
|
+
if (type === "field") {
|
|
85
|
+
const inputType = props.input_type === "number" ? "number" : props.input_type === "email" ? "email" : props.input_type === "url" ? "url" : "text";
|
|
86
|
+
const constraints = (props.required ? " required" : "") + (props.min_length != null ? ' minlength="' + Number(props.min_length) + '"' : "") + (props.max_length != null ? ' maxlength="' + Number(props.max_length) + '"' : "") + (props.minimum != null ? ' min="' + Number(props.minimum) + '"' : "") + (props.maximum != null ? ' max="' + Number(props.maximum) + '"' : "");
|
|
87
|
+
const suggestions = Array.isArray(props.suggestions) ? '<datalist id="list-' + escape(props.state_key) + '">' + props.suggestions.map((value) => '<option value="' + escape(value) + '"></option>').join("") + "</datalist>" : "";
|
|
88
|
+
const listId = props.suggestions || props.suggestions_source ? "list-" + props.state_key : "";
|
|
89
|
+
return '<label class="ui-field"' + attributes + '>' + label + '<input type="' + inputType + '" data-state-key="' + escape(props.state_key) + '" data-suggestions-source="' + escape(props.suggestions_source || "") + '" placeholder="' + escape(props.hint || props.placeholder) + '"' + constraints + (listId ? ' list="' + escape(listId) + '"' : "") + ">" + (suggestions || (listId ? '<datalist id="' + escape(listId) + '"></datalist>' : "")) + "</label>";
|
|
90
|
+
}
|
|
91
|
+
if (type === "select") {
|
|
92
|
+
const options = Array.isArray(props.options) ? props.options : [];
|
|
93
|
+
if (props.searchable) {
|
|
94
|
+
const listId = "select-" + props.state_key;
|
|
95
|
+
return '<label class="ui-field"' + attributes + '>' + label + '<input data-search-select="true" data-state-key="' + escape(props.state_key) + '" data-options-source="' + escape(props.options_source || "") + '" data-options="' + jsonAttribute(options) + '" list="' + escape(listId) + '" placeholder="' + escape(props.placeholder || "검색하여 선택") + '"' + (props.required ? " required" : "") + '><datalist id="' + escape(listId) + '">' + options.map((option) => '<option value="' + escape(option.label) + '"></option>').join("") + "</datalist></label>";
|
|
96
|
+
}
|
|
97
|
+
const optionHtml = options.map((option, index) => '<option value="' + index + '" data-value="' + jsonAttribute(option.value) + '">' + escape(option.label) + "</option>").join("");
|
|
98
|
+
return '<label class="ui-field"' + attributes + '>' + label + '<select data-state-key="' + escape(props.state_key) + '" data-options-source="' + escape(props.options_source || "") + '"' + (props.required ? " required" : "") + '><option value="">' + escape(props.placeholder || "선택") + "</option>" + optionHtml + "</select></label>";
|
|
99
|
+
}
|
|
100
|
+
if (type === "switch") return '<label class="ui-switch"' + attributes + '><input type="checkbox" data-state-key="' + escape(props.state_key) + '"' + (props.required ? " required" : "") + "> " + label + "</label>";
|
|
75
101
|
if (type === "empty") return '<div class="ui-empty"><strong>' + escape(props.title || "표시할 내용이 없어요.") + "</strong><p>" + escape(props.supporting) + "</p></div>";
|
|
76
102
|
if (["list", "timeline", "calendar", "chart", "table"].includes(type)) {
|
|
77
103
|
return '<section class="ui-collection"><small>' + escape(type + (props.source ? " · " + props.source : "")) + "</small>" + (children || '<p>' + escape(props.empty_text || "데이터가 연결되면 표시됩니다.") + "</p>") + "</section>";
|
|
@@ -83,7 +109,8 @@ function nodeHtml(node) {
|
|
|
83
109
|
const heading = surface && (props.title || props.label)
|
|
84
110
|
? "<h3>" + label + "</h3>" + (props.subtitle ? "<p>" + escape(props.subtitle) + "</p>" : "")
|
|
85
111
|
: "";
|
|
86
|
-
return '<
|
|
112
|
+
if (type === "form") return '<form class="' + className + '"' + attributes + ">" + heading + children + (props.submit_label ? '<button type="submit">' + escape(props.submit_label) + "</button>" : "") + "</form>";
|
|
113
|
+
return '<div class="' + className + '"' + attributes + ">" + heading + children + "</div>";
|
|
87
114
|
}
|
|
88
115
|
|
|
89
116
|
function chromeHtml(config) {
|
|
@@ -100,6 +127,214 @@ function chromeHtml(config) {
|
|
|
100
127
|
return bar + nav;
|
|
101
128
|
}
|
|
102
129
|
|
|
130
|
+
export function resolvePreviewPath(roots, path) {
|
|
131
|
+
const parts = String(path || "").split(".");
|
|
132
|
+
let value = roots[parts.shift()];
|
|
133
|
+
for (const part of parts) {
|
|
134
|
+
if (value == null) return null;
|
|
135
|
+
if (Array.isArray(value) && part === "length") value = value.length;
|
|
136
|
+
else if (Array.isArray(value) && /^\d+$/.test(part)) value = value[Number(part)];
|
|
137
|
+
else if (typeof value === "object") value = value[part];
|
|
138
|
+
else return null;
|
|
139
|
+
}
|
|
140
|
+
return value ?? null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function previewValuesEqual(left, right) {
|
|
144
|
+
if (Object.is(left, right)) return true;
|
|
145
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
146
|
+
return left.length === right.length && left.every((value, index) => previewValuesEqual(value, right[index]));
|
|
147
|
+
}
|
|
148
|
+
if (left && right && typeof left === "object" && typeof right === "object" && !Array.isArray(left) && !Array.isArray(right)) {
|
|
149
|
+
const keys = Object.keys(left);
|
|
150
|
+
return keys.length === Object.keys(right).length && keys.every((key) => Object.hasOwn(right, key) && previewValuesEqual(left[key], right[key]));
|
|
151
|
+
}
|
|
152
|
+
return typeof left === "number" && typeof right === "number" && left === right;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function resolvePreviewValue(value, roots) {
|
|
156
|
+
if (Array.isArray(value)) return value.map((item) => resolvePreviewValue(item, roots));
|
|
157
|
+
if (value && typeof value === "object") {
|
|
158
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolvePreviewValue(item, roots)]));
|
|
159
|
+
}
|
|
160
|
+
if (typeof value !== "string") return value;
|
|
161
|
+
const exact = value.match(/^\{\{((?:state|data|context|item)\.[A-Za-z0-9_.-]+)\}\}$/);
|
|
162
|
+
if (exact) return resolvePreviewPath(roots, exact[1]);
|
|
163
|
+
return value.replace(/\{\{((?:state|data|context|item)\.[A-Za-z0-9_.-]+)\}\}/g, (_, path) => String(resolvePreviewPath(roots, path) ?? ""));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function evaluatePreviewCondition(condition, roots) {
|
|
167
|
+
if (!condition) return true;
|
|
168
|
+
if (Array.isArray(condition.all)) return condition.all.every((item) => evaluatePreviewCondition(item, roots));
|
|
169
|
+
if (Array.isArray(condition.any)) return condition.any.some((item) => evaluatePreviewCondition(item, roots));
|
|
170
|
+
if (condition.not) return !evaluatePreviewCondition(condition.not, roots);
|
|
171
|
+
const value = resolvePreviewPath(roots, condition.path);
|
|
172
|
+
if (Object.hasOwn(condition, "equals")) return previewValuesEqual(value, resolvePreviewValue(condition.equals, roots));
|
|
173
|
+
if (Object.hasOwn(condition, "not_equals")) return !previewValuesEqual(value, resolvePreviewValue(condition.not_equals, roots));
|
|
174
|
+
if (Object.hasOwn(condition, "exists")) return condition.exists === (value !== null);
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function previewRuntime() {
|
|
179
|
+
const parse = (element, key) => element.dataset[key] ? JSON.parse(element.dataset[key]) : null;
|
|
180
|
+
const same = previewValuesEqual;
|
|
181
|
+
for (const article of document.querySelectorAll(".screen[data-extension]")) {
|
|
182
|
+
const config = JSON.parse(article.querySelector(".runtime-config").textContent);
|
|
183
|
+
const state = structuredClone(config.initial_state || {});
|
|
184
|
+
const data = {};
|
|
185
|
+
const context = {
|
|
186
|
+
platform: "web",
|
|
187
|
+
permissions: {}, connections: {}, connected: false,
|
|
188
|
+
loading: {}, errors: {}, route: {},
|
|
189
|
+
screen: { width: article.clientWidth, height: innerHeight, size_class: article.clientWidth < 600 ? "compact" : article.clientWidth < 840 ? "medium" : "expanded" },
|
|
190
|
+
};
|
|
191
|
+
const roots = () => ({ state, data, context, item: {} });
|
|
192
|
+
const inspector = article.querySelector(".runtime-inspector pre");
|
|
193
|
+
const log = [];
|
|
194
|
+
|
|
195
|
+
function optionValue(option) {
|
|
196
|
+
if (!option?.dataset.value) return option?.value ?? null;
|
|
197
|
+
return JSON.parse(option.dataset.value);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function update() {
|
|
201
|
+
for (const element of article.querySelectorAll("[data-visible]")) {
|
|
202
|
+
element.hidden = !evaluatePreviewCondition(parse(element, "visible"), roots());
|
|
203
|
+
}
|
|
204
|
+
for (const element of article.querySelectorAll("[data-template]")) {
|
|
205
|
+
element.textContent = String(resolvePreviewValue(element.dataset.template, roots()) ?? "");
|
|
206
|
+
}
|
|
207
|
+
for (const element of article.querySelectorAll("progress[data-value-template]")) {
|
|
208
|
+
element.value = Number(resolvePreviewValue(element.dataset.valueTemplate, roots()) ?? 0);
|
|
209
|
+
}
|
|
210
|
+
for (const input of article.querySelectorAll("input[data-suggestions-source]")) {
|
|
211
|
+
if (!input.dataset.suggestionsSource) continue;
|
|
212
|
+
const values = resolvePreviewPath(roots(), input.dataset.suggestionsSource);
|
|
213
|
+
const list = document.getElementById(input.getAttribute("list"));
|
|
214
|
+
if (list && Array.isArray(values)) list.replaceChildren(...values.filter((value) => typeof value === "string").map((value) => Object.assign(document.createElement("option"), { value })));
|
|
215
|
+
}
|
|
216
|
+
for (const select of article.querySelectorAll("select[data-options-source]")) {
|
|
217
|
+
if (!select.dataset.optionsSource) continue;
|
|
218
|
+
const values = resolvePreviewPath(roots(), select.dataset.optionsSource);
|
|
219
|
+
const placeholder = select.options[0]?.cloneNode(true) || document.createElement("option");
|
|
220
|
+
const options = Array.isArray(values) ? values.filter((value) => value && typeof value === "object" && typeof value.label === "string") : [];
|
|
221
|
+
select.replaceChildren(placeholder, ...options.map((value, index) => {
|
|
222
|
+
const option = document.createElement("option");
|
|
223
|
+
option.value = String(index);
|
|
224
|
+
option.textContent = value.label;
|
|
225
|
+
option.dataset.value = JSON.stringify(value.value);
|
|
226
|
+
return option;
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
for (const input of article.querySelectorAll("input[data-search-select]")) {
|
|
230
|
+
const values = input.dataset.optionsSource ? resolvePreviewPath(roots(), input.dataset.optionsSource) : JSON.parse(input.dataset.options || "[]");
|
|
231
|
+
input._previewOptions = Array.isArray(values) ? values.filter((value) => value && typeof value === "object" && typeof value.label === "string") : [];
|
|
232
|
+
const list = document.getElementById(input.getAttribute("list"));
|
|
233
|
+
if (list) list.replaceChildren(...input._previewOptions.map((value) => Object.assign(document.createElement("option"), { value: value.label })));
|
|
234
|
+
}
|
|
235
|
+
for (const input of article.querySelectorAll("[data-state-key]")) {
|
|
236
|
+
const value = state[input.dataset.stateKey];
|
|
237
|
+
if (input.type === "checkbox") input.checked = value === true;
|
|
238
|
+
else if (input.dataset.searchSelect) {
|
|
239
|
+
const match = (input._previewOptions || []).find((option) => same(option.value, value));
|
|
240
|
+
if (document.activeElement !== input && match && input.value !== match.label) input.value = match.label;
|
|
241
|
+
}
|
|
242
|
+
else if (input.tagName === "SELECT") {
|
|
243
|
+
const match = [...input.options].find((option) => same(optionValue(option), value));
|
|
244
|
+
if (match && input.value !== match.value) input.value = match.value;
|
|
245
|
+
} else if (document.activeElement !== input && input.value !== String(value ?? "")) input.value = String(value ?? "");
|
|
246
|
+
}
|
|
247
|
+
inspector.textContent = JSON.stringify({ state, data, context, log: log.slice(-8) }, null, 2);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function perform(action) {
|
|
251
|
+
if (!action) return false;
|
|
252
|
+
if (action.validate && ![...article.querySelectorAll("form")].every((form) => form.reportValidity())) return false;
|
|
253
|
+
if (action.type === "flow") {
|
|
254
|
+
const tasks = action.actions || [];
|
|
255
|
+
if (action.mode === "parallel") {
|
|
256
|
+
const results = await Promise.all(tasks.map(perform));
|
|
257
|
+
return action.continue_on_error || results.every(Boolean);
|
|
258
|
+
}
|
|
259
|
+
for (const child of tasks) if (!await perform(child) && !action.continue_on_error) return false;
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
if (action.type === "set_state") Object.assign(state, resolvePreviewValue(action.values || {}, roots()));
|
|
263
|
+
else if (action.type === "invoke") {
|
|
264
|
+
const detail = { capability: action.capability, query: resolvePreviewValue(action.query, roots()), arguments: resolvePreviewValue(action.arguments || {}, roots()), result: null };
|
|
265
|
+
article.dispatchEvent(new CustomEvent("morit-preview-invoke", { detail, bubbles: true }));
|
|
266
|
+
if (action.store && detail.result != null) data[action.store] = detail.result;
|
|
267
|
+
log.push({ invoke: detail.capability, arguments: detail.arguments });
|
|
268
|
+
} else if (action.type === "refresh") {
|
|
269
|
+
const detail = { source: action.source, result: null };
|
|
270
|
+
article.dispatchEvent(new CustomEvent("morit-preview-refresh", { detail, bubbles: true }));
|
|
271
|
+
if (detail.result != null) data[action.source] = detail.result;
|
|
272
|
+
log.push({ refresh: action.source });
|
|
273
|
+
} else if (action.type === "navigate") {
|
|
274
|
+
const detail = { target: action.target, parameters: resolvePreviewValue(action.parameters || {}, roots()), result: null };
|
|
275
|
+
article.dispatchEvent(new CustomEvent("morit-preview-navigate", { detail, bubbles: true }));
|
|
276
|
+
context.route = detail.parameters;
|
|
277
|
+
document.querySelector('.screen[data-extension="' + CSS.escape(action.target) + '"]')?.scrollIntoView({ behavior: "smooth" });
|
|
278
|
+
if (action.result_state && detail.result != null) state[action.result_state] = detail.result;
|
|
279
|
+
log.push({ navigate: action.target, parameters: detail.parameters });
|
|
280
|
+
} else if (action.type === "back") {
|
|
281
|
+
article.dispatchEvent(new CustomEvent("morit-preview-back", { detail: { result: resolvePreviewValue(action.result || {}, roots()) }, bubbles: true }));
|
|
282
|
+
log.push({ back: true });
|
|
283
|
+
}
|
|
284
|
+
update();
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
article.addEventListener("input", (event) => {
|
|
289
|
+
const input = event.target.closest("[data-state-key]");
|
|
290
|
+
if (!input) return;
|
|
291
|
+
if (input.dataset.searchSelect) return;
|
|
292
|
+
state[input.dataset.stateKey] = input.type === "checkbox" ? input.checked : input.type === "number" && input.value !== "" ? Number(input.value) : input.value;
|
|
293
|
+
update();
|
|
294
|
+
});
|
|
295
|
+
article.addEventListener("change", (event) => {
|
|
296
|
+
const input = event.target.closest("[data-state-key]");
|
|
297
|
+
if (!input) return;
|
|
298
|
+
if (input.dataset.searchSelect) {
|
|
299
|
+
const match = (input._previewOptions || []).find((option) => option.label === input.value);
|
|
300
|
+
if (!match) { input.setCustomValidity("목록에서 선택해 주세요."); input.reportValidity(); return; }
|
|
301
|
+
input.setCustomValidity("");
|
|
302
|
+
state[input.dataset.stateKey] = match.value;
|
|
303
|
+
} else state[input.dataset.stateKey] = input.tagName === "SELECT" ? optionValue(input.selectedOptions[0]) : input.type === "checkbox" ? input.checked : input.type === "number" && input.value !== "" ? Number(input.value) : input.value;
|
|
304
|
+
const actionHost = input.closest("[data-action]");
|
|
305
|
+
if (actionHost) perform(parse(actionHost, "action"));
|
|
306
|
+
update();
|
|
307
|
+
});
|
|
308
|
+
article.addEventListener("submit", (event) => {
|
|
309
|
+
event.preventDefault();
|
|
310
|
+
perform(parse(event.target, "action"));
|
|
311
|
+
});
|
|
312
|
+
article.addEventListener("click", (event) => {
|
|
313
|
+
const host = event.target.closest("button[data-action], .ui-surface[data-action], .ui-layout[data-action]");
|
|
314
|
+
if (host) perform(parse(host, "action"));
|
|
315
|
+
});
|
|
316
|
+
article.addEventListener("keydown", (event) => {
|
|
317
|
+
if (event.key !== "Enter" || event.isComposing) return;
|
|
318
|
+
const field = event.target.closest(".ui-field[data-action]");
|
|
319
|
+
if (field) { event.preventDefault(); perform(parse(field, "action")); }
|
|
320
|
+
});
|
|
321
|
+
new ResizeObserver(() => {
|
|
322
|
+
context.screen.width = article.clientWidth;
|
|
323
|
+
context.screen.size_class = article.clientWidth < 600 ? "compact" : article.clientWidth < 840 ? "medium" : "expanded";
|
|
324
|
+
update();
|
|
325
|
+
}).observe(article);
|
|
326
|
+
update();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const RUNTIME_SCRIPT = [
|
|
331
|
+
resolvePreviewPath.toString(),
|
|
332
|
+
previewValuesEqual.toString(),
|
|
333
|
+
resolvePreviewValue.toString(),
|
|
334
|
+
evaluatePreviewCondition.toString(),
|
|
335
|
+
"(" + previewRuntime.toString() + ")();",
|
|
336
|
+
].join("\n");
|
|
337
|
+
|
|
103
338
|
const CSS = [
|
|
104
339
|
"*{box-sizing:border-box}body{margin:0;font:15px/1.5 system-ui,sans-serif;background:#0d1211}",
|
|
105
340
|
".theme-toggle{position:fixed;right:18px;top:16px;z-index:3;padding:9px 13px;border-radius:999px;background:#fff;color:#17211f;box-shadow:0 4px 18px #0003;cursor:pointer}",
|
|
@@ -110,6 +345,7 @@ const CSS = [
|
|
|
110
345
|
".screen-meta,.app-bar,.screen-body,.ui-nav{padding:var(--space)}.screen-meta{display:flex;gap:8px;background:var(--surface-variant);color:var(--on-surface-variant)}.screen-meta span{margin-right:auto}.app-bar{display:flex;align-items:center;border-bottom:1px solid var(--outline-variant)}.app-bar div:first-child{display:grid}.app-bar small{color:var(--on-surface-variant)}.app-actions{margin-left:auto;display:flex;gap:6px}",
|
|
111
346
|
".screen-body,.ui-layout,.ui-surface,.ui-collection{display:flex;flex-direction:column;gap:var(--space)}.ui-layout.row{flex-direction:row;align-items:center}.ui-layout.wrap,.ui-nav{display:flex;flex-flow:row wrap;gap:8px}.ui-layout.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr))}.ui-surface,.ui-collection,.ui-empty{padding:var(--space);border:1px solid var(--outline-variant);border-radius:var(--radius);background:color-mix(in srgb,var(--surface),var(--primary-container) 9%)}",
|
|
112
347
|
"h3,p{margin:0}.ui-text.heading{font-size:1.25em;font-weight:750}.ui-text.caption,small{color:var(--on-surface-variant)}button,.ui-chip{border:0;border-radius:999px;padding:8px 13px;background:var(--primary-container);color:var(--on-primary-container);font:inherit}.ui-icon{font:600 .75em monospace;color:var(--primary)}.ui-image{min-height:90px;display:grid;place-items:center;border-radius:var(--radius);background:var(--surface-variant);color:var(--on-surface-variant)}.ui-avatar{display:grid;place-items:center;width:42px;height:42px;border-radius:50%;background:var(--primary);color:var(--on-primary)}.ui-badge{padding:3px 7px;border-radius:99px;background:var(--error-container);color:var(--on-error-container)}.ui-metric{display:grid;padding:12px;border-radius:var(--radius);background:var(--secondary-container);color:var(--on-secondary-container)}.ui-metric strong{font-size:1.35em}.ui-field{display:grid;gap:5px}input,select{width:100%;padding:11px;border:1px solid var(--outline);border-radius:var(--radius);background:var(--surface);color:var(--on-surface)}progress{accent-color:var(--primary);width:100%}hr{width:100%;border:0;border-top:1px solid var(--outline-variant)}.ui-spacer{min-height:12px}.ui-nav{border-top:1px solid var(--outline-variant)}",
|
|
348
|
+
"[hidden]{display:none!important}[data-action]{cursor:pointer}.runtime-inspector{margin:0 var(--space) var(--space);border-top:1px solid var(--outline-variant);padding-top:8px}.runtime-inspector pre{max-height:260px;overflow:auto;white-space:pre-wrap;font:12px/1.4 ui-monospace,monospace;color:var(--on-surface-variant)}",
|
|
113
349
|
].join("");
|
|
114
350
|
|
|
115
351
|
export function renderPreviewHtml(manifest) {
|
|
@@ -117,9 +353,10 @@ export function renderPreviewHtml(manifest) {
|
|
|
117
353
|
const screens = extensions.length
|
|
118
354
|
? extensions.map((extension) => {
|
|
119
355
|
const config = extension.config;
|
|
120
|
-
|
|
356
|
+
const safeConfig = JSON.stringify(config).replaceAll("<", "\\u003c").replaceAll(">", "\\u003e").replaceAll("&", "\\u0026");
|
|
357
|
+
return '<article class="screen" data-extension="' + escape(extension.id) + '" style="' + themeVariables(config) + '">' +
|
|
121
358
|
'<div class="screen-meta"><span>' + escape(extension.point) + "</span><strong>" + escape(extension.title) + "</strong></div>" +
|
|
122
|
-
chromeHtml(config) + '<main class="screen-body">' + nodeHtml(config.view) + "</
|
|
359
|
+
chromeHtml(config) + '<main class="screen-body">' + nodeHtml(config.view) + '</main><details class="runtime-inspector"><summary>Runtime Inspector</summary><pre></pre></details><script type="application/json" class="runtime-config">' + safeConfig + "</script></article>";
|
|
123
360
|
}).join("")
|
|
124
361
|
: '<article class="screen"><div class="ui-empty"><strong>UI extension 없음</strong><p>UI Runtime v2 화면을 추가하면 여기에 표시됩니다.</p></div></article>';
|
|
125
362
|
const title = escape(manifest.name);
|
|
@@ -127,6 +364,6 @@ export function renderPreviewHtml(manifest) {
|
|
|
127
364
|
title + " preview</title><style>" + CSS +
|
|
128
365
|
'</style></head><body><input id="preview-dark" type="checkbox"><label class="theme-toggle" for="preview-dark">☀︎ / ☾</label><div class="preview"><header class="preview-head"><small>Morit UI Runtime v2 · 안전한 계약 Preview</small><h1>' +
|
|
129
366
|
title + "</h1><p>" + escape(manifest.description || "") +
|
|
130
|
-
'</p></header><section class="screens">' + screens + "</section></div></body></html>";
|
|
367
|
+
'</p></header><section class="screens">' + screens + "</section></div><script>" + RUNTIME_SCRIPT + "</script></body></html>";
|
|
131
368
|
return Buffer.from(content, "utf8");
|
|
132
369
|
}
|
package/src/workspace.js
CHANGED
|
@@ -40,7 +40,7 @@ const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
|
40
40
|
const WINDOWS_RESERVED = /^(?:con|prn|aux|nul|clock\$|com[1-9]|lpt[1-9])(?:\.|$)/i;
|
|
41
41
|
const UI_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
42
42
|
const UI_STATE_KEY = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
43
|
-
const UI_BINDING = /^(?:state|data|item)(?:\.[A-Za-z0-9_-]+){0,12}$/;
|
|
43
|
+
const UI_BINDING = /^(?:state|data|item|context)(?:\.[A-Za-z0-9_-]+){0,12}$/;
|
|
44
44
|
const UI_TEMPLATE_BINDING = /\{\{\s*([^{}]+?)\s*\}\}/g;
|
|
45
45
|
const UI_SINGLE_TEMPLATE_BINDING = /^\{\{\s*([^{}]+?)\s*\}\}$/;
|
|
46
46
|
const UI_ASSET_PATH = /^assets\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\.(?:gif|jpe?g|png|webp)$/i;
|
|
@@ -152,7 +152,7 @@ export class LocalWorkspace {
|
|
|
152
152
|
version: "1.0.0",
|
|
153
153
|
cloud_project_id: null,
|
|
154
154
|
required_secrets: [],
|
|
155
|
-
min_morit_version: "1.7.
|
|
155
|
+
min_morit_version: "1.7.12",
|
|
156
156
|
max_morit_version: "1.999.999",
|
|
157
157
|
permissions: [],
|
|
158
158
|
capabilities: [],
|
|
@@ -813,6 +813,12 @@ function validateManifest(manifest, files) {
|
|
|
813
813
|
throw new Error(`unsupported runtime adapter: ${adapter || "missing"}`);
|
|
814
814
|
}
|
|
815
815
|
validateRuntime(capability, runtime, permissions, credentials, connectors, dependencies);
|
|
816
|
+
for (const [field, schema] of [["input_schema", capability.input_schema], ["output_schema", capability.output_schema]]) {
|
|
817
|
+
if (schema === undefined) continue;
|
|
818
|
+
assertObject(schema, `capability ${capability.id} ${field}`);
|
|
819
|
+
if (Buffer.byteLength(JSON.stringify(schema), "utf8") > 16 * 1024) throw new Error(`capability ${capability.id} ${field} is too large`);
|
|
820
|
+
validateResponseSchema(schema, 0, true);
|
|
821
|
+
}
|
|
816
822
|
if (adapter === "sandbox_python") {
|
|
817
823
|
const entrypoint = normalizeEntrypointPath(runtime.entrypoint);
|
|
818
824
|
if (!entrypoint.startsWith("src/") || !entrypoint.endsWith(".py")) throw new Error("sandbox_python entrypoint must be src/*.py");
|
|
@@ -1242,11 +1248,15 @@ function validateRuntimeReferences(capabilities, connectors, connectorIds, depen
|
|
|
1242
1248
|
function validateUiConfig(extension, capabilities, routeIds, files, hasStorage = false) {
|
|
1243
1249
|
const config = extension.config === undefined ? {} : extension.config;
|
|
1244
1250
|
const executable = new Set(capabilities.filter((value) => ["tool", "skill", "provider", "notification"].includes(value.kind)).map((value) => value.id));
|
|
1251
|
+
const capabilitySchemas = new Map(capabilities.map((value) => [value.id, {
|
|
1252
|
+
input_schema: value.input_schema,
|
|
1253
|
+
output_schema: value.output_schema,
|
|
1254
|
+
}]));
|
|
1245
1255
|
if (hasStorage) for (const capability of STORAGE_TOOLS) executable.add(capability);
|
|
1246
1256
|
if (extension.point === "response" && config.ui_schema !== 2) throw new Error("response UI requires UI Runtime v2");
|
|
1247
1257
|
if (config.ui_schema === 2) {
|
|
1248
1258
|
if (config.placement != null && !["card", "action"].includes(extension.point)) throw new Error("UI placement is only valid for home extensions");
|
|
1249
|
-
validateUiRuntimeV2(config, executable, routeIds);
|
|
1259
|
+
validateUiRuntimeV2(config, executable, routeIds, capabilitySchemas);
|
|
1250
1260
|
validateResponseComponent(config, extension.point === "response");
|
|
1251
1261
|
for (const asset of uiRuntimeAssetPaths(config.view)) {
|
|
1252
1262
|
if (!(asset in files)) throw new Error(`UI image asset is not packaged: ${asset}`);
|
|
@@ -1376,7 +1386,7 @@ function validateLegacyUiForm(form, executable, extensionId) {
|
|
|
1376
1386
|
}
|
|
1377
1387
|
}
|
|
1378
1388
|
|
|
1379
|
-
function validateUiRuntimeV2(config, executable, routes) {
|
|
1389
|
+
function validateUiRuntimeV2(config, executable, routes, capabilitySchemas = new Map()) {
|
|
1380
1390
|
rejectUnknownFields(config, new Set(contract.ui_runtime.config_fields), "UI Runtime v2");
|
|
1381
1391
|
if (config.ui_schema !== 2) throw new Error("UI Runtime version must be 2");
|
|
1382
1392
|
validateUiIcon(config.icon);
|
|
@@ -1429,6 +1439,7 @@ function validateUiRuntimeV2(config, executable, routes) {
|
|
|
1429
1439
|
validateUiNavigation(config.navigation, executable, routes, sourceIds, stateKeys);
|
|
1430
1440
|
assertObject(config.view, "UI Runtime v2 view");
|
|
1431
1441
|
validateUiNode(config.view, 0, { value: 0 }, executable, routes, sourceIds, stateKeys, null);
|
|
1442
|
+
validateUiCapabilityBindings(config, sources, capabilitySchemas);
|
|
1432
1443
|
}
|
|
1433
1444
|
|
|
1434
1445
|
function validateUiTheme(theme, variant = false) {
|
|
@@ -1600,7 +1611,7 @@ function validateUiNode(node, depth, counter, executable, routes, sources, state
|
|
|
1600
1611
|
const props = node.props === undefined ? {} : node.props;
|
|
1601
1612
|
validateUiProps(node.type, props, sources, stateKeys);
|
|
1602
1613
|
validateUiCondition(node.visible_when, stateKeys, sources);
|
|
1603
|
-
if (node.action != null && !["surface", "card", "button", "chip"].includes(node.type)) {
|
|
1614
|
+
if (node.action != null && !["surface", "card", "button", "chip", "form", "field", "select", "switch"].includes(node.type)) {
|
|
1604
1615
|
throw new Error("UI action requires an interactive component");
|
|
1605
1616
|
}
|
|
1606
1617
|
validateUiAction(node.action, executable, routes, sources, stateKeys);
|
|
@@ -1633,7 +1644,7 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
|
|
|
1633
1644
|
"border_radius", "elevation", "opacity", "clip", "enabled", "tooltip", "semantic_label", "exclude_semantics",
|
|
1634
1645
|
]);
|
|
1635
1646
|
if (nodeType === "surface") rejectUnknownFields(props, surfaceProps, "surface UI component props");
|
|
1636
|
-
for (const key of ["text", "title", "subtitle", "label", "supporting", "placeholder", "empty_text", "value", "tooltip", "semantic_label"]) {
|
|
1647
|
+
for (const key of ["text", "title", "subtitle", "label", "supporting", "placeholder", "empty_text", "value", "tooltip", "semantic_label", "error_text", "submit_label"]) {
|
|
1637
1648
|
if (props[key] !== undefined) {
|
|
1638
1649
|
optionalUiTemplate(props[key], 1200, true);
|
|
1639
1650
|
validateTemplateReferences(props[key], stateKeys, sources);
|
|
@@ -1644,6 +1655,11 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
|
|
|
1644
1655
|
}
|
|
1645
1656
|
if (props.icon !== undefined) validateUiIcon(props.icon, true);
|
|
1646
1657
|
if (props.max_lines !== undefined && (!Number.isInteger(props.max_lines) || props.max_lines < 1 || props.max_lines > 100)) throw new Error("UI numeric property max_lines is invalid");
|
|
1658
|
+
if (props.input_type !== undefined && !["text", "number", "email", "url"].includes(props.input_type)) throw new Error("UI input type is invalid");
|
|
1659
|
+
for (const key of ["min_length", "max_length"]) if (props[key] !== undefined && (!Number.isInteger(props[key]) || props[key] < 0 || props[key] > 2000)) throw new Error(`UI ${key} is invalid`);
|
|
1660
|
+
if ((props.min_length ?? 0) > (props.max_length ?? 2000)) throw new Error("UI text length constraints are invalid");
|
|
1661
|
+
for (const key of ["minimum", "maximum"]) if (props[key] !== undefined) validateUiNumber(props[key], `UI ${key}`, -1000000000, 1000000000);
|
|
1662
|
+
if ((props.minimum ?? -1000000000) > (props.maximum ?? 1000000000)) throw new Error("UI numeric constraints are invalid");
|
|
1647
1663
|
for (const key of ["columns", "size", "limit"]) {
|
|
1648
1664
|
if (props[key] !== undefined && (!Number.isInteger(props[key]) || props[key] < 0 || props[key] > 100)) throw new Error(`UI numeric property ${key} is invalid`);
|
|
1649
1665
|
}
|
|
@@ -1668,7 +1684,7 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
|
|
|
1668
1684
|
for (const key of ["left", "top", "right", "bottom"]) if (props[key] !== undefined) validateUiNumber(props[key], `UI position ${key}`, -4096, 4096);
|
|
1669
1685
|
if (props.stack_at !== undefined && (!Number.isInteger(props.stack_at) || props.stack_at < 0 || props.stack_at > 1200)) throw new Error("UI stack_at is invalid");
|
|
1670
1686
|
if (props.min_item_width !== undefined && (!Number.isInteger(props.min_item_width) || props.min_item_width < 96 || props.min_item_width > 600)) throw new Error("UI min_item_width is invalid");
|
|
1671
|
-
for (const key of ["persist", "selected", "dense", "show_legend", "full_width", "exclude_semantics", "clip", "shrink_wrap", "enabled"]) if (props[key] !== undefined && typeof props[key] !== "boolean") throw new Error(`UI boolean property ${key} is invalid`);
|
|
1687
|
+
for (const key of ["persist", "selected", "dense", "show_legend", "full_width", "exclude_semantics", "clip", "shrink_wrap", "enabled", "required", "searchable"]) if (props[key] !== undefined && typeof props[key] !== "boolean") throw new Error(`UI boolean property ${key} is invalid`);
|
|
1672
1688
|
if (props.fit !== undefined && !["contain", "cover", "fill", "fit_width", "fit_height", "none", "scale_down"].includes(props.fit)) throw new Error("UI image fit is invalid");
|
|
1673
1689
|
if (props.scroll_direction !== undefined && !["vertical", "horizontal"].includes(props.scroll_direction)) throw new Error("UI scroll direction is invalid");
|
|
1674
1690
|
if (["image", "avatar"].includes(nodeType)) validateUiImageSource(props, sources);
|
|
@@ -1688,6 +1704,11 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
|
|
|
1688
1704
|
if (nodeType === "chart" && !["bar", "line", "donut", "scatter"].includes(chartType)) throw new Error("UI chart type is invalid");
|
|
1689
1705
|
if (nodeType === "calendar" && props.state_key != null && !stateKeys.has(props.state_key)) throw new Error("UI calendar state is unknown");
|
|
1690
1706
|
if (["field", "select", "switch"].includes(nodeType) && !stateKeys.has(props.state_key)) throw new Error("UI state field is unknown");
|
|
1707
|
+
if (props.suggestions !== undefined || props.suggestions_source !== undefined) {
|
|
1708
|
+
if (nodeType !== "field" || (props.suggestions !== undefined && props.suggestions_source !== undefined)) throw new Error("UI autocomplete options are invalid");
|
|
1709
|
+
if (props.suggestions !== undefined && (!Array.isArray(props.suggestions) || props.suggestions.length < 1 || props.suggestions.length > 32 || props.suggestions.some((value) => typeof value !== "string" || !value.trim() || value.length > 120))) throw new Error("UI autocomplete options are invalid");
|
|
1710
|
+
if (props.suggestions_source !== undefined) validateUiDataBinding(props.suggestions_source, sources, "autocomplete");
|
|
1711
|
+
}
|
|
1691
1712
|
if (props.options != null) {
|
|
1692
1713
|
if (nodeType !== "select" || !Array.isArray(props.options) || props.options.length < 1 || props.options.length > 32) throw new Error("UI select options are invalid");
|
|
1693
1714
|
for (const option of props.options) {
|
|
@@ -1696,6 +1717,18 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
|
|
|
1696
1717
|
optionalUiText(option.label, 80, { required: true });
|
|
1697
1718
|
}
|
|
1698
1719
|
}
|
|
1720
|
+
if (props.options !== undefined && props.options_source !== undefined) throw new Error("UI select cannot declare two options sources");
|
|
1721
|
+
if (props.options_source !== undefined) {
|
|
1722
|
+
if (nodeType !== "select") throw new Error("UI options source requires a select");
|
|
1723
|
+
validateUiDataBinding(props.options_source, sources, "select options");
|
|
1724
|
+
}
|
|
1725
|
+
if (props.searchable !== undefined && nodeType !== "select") throw new Error("UI searchable requires a select");
|
|
1726
|
+
for (const key of ["required", "min_length", "max_length", "minimum", "maximum", "error_text"]) if (props[key] !== undefined && !["field", "select", "switch"].includes(nodeType)) throw new Error("UI validation properties require an input");
|
|
1727
|
+
if (props.submit_label !== undefined && nodeType !== "form") throw new Error("UI submit label requires a form");
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
function validateUiDataBinding(value, sources, label) {
|
|
1731
|
+
if (typeof value !== "string" || !UI_BINDING.test(value) || !value.startsWith("data.") || !sources.has(value.split(".")[1])) throw new Error(`UI ${label} source is invalid`);
|
|
1699
1732
|
}
|
|
1700
1733
|
|
|
1701
1734
|
function validateUiImageSource(props, sources) {
|
|
@@ -1737,8 +1770,21 @@ function validateUiInsets(value, label) {
|
|
|
1737
1770
|
for (const item of Object.values(value)) validateUiNumber(item, label, 0, 128);
|
|
1738
1771
|
}
|
|
1739
1772
|
|
|
1740
|
-
function validateUiCondition(condition, stateKeys, sources) {
|
|
1773
|
+
function validateUiCondition(condition, stateKeys, sources, depth = 0, counter = { value: 0 }) {
|
|
1741
1774
|
if (condition == null) return;
|
|
1775
|
+
assertObject(condition, "UI visibility condition");
|
|
1776
|
+
counter.value += 1;
|
|
1777
|
+
if (depth > 4 || counter.value > 32) throw new Error("UI visibility condition is too complex");
|
|
1778
|
+
const composites = ["all", "any", "not"].filter((key) => Object.hasOwn(condition, key));
|
|
1779
|
+
if (composites.length) {
|
|
1780
|
+
if (composites.length !== 1 || Object.keys(condition).length !== 1) throw new Error("UI visibility condition is invalid");
|
|
1781
|
+
const key = composites[0];
|
|
1782
|
+
if (key === "not") return validateUiCondition(condition.not, stateKeys, sources, depth + 1, counter);
|
|
1783
|
+
const children = condition[key];
|
|
1784
|
+
if (!Array.isArray(children) || children.length < 1 || children.length > 8) throw new Error("UI visibility condition group is invalid");
|
|
1785
|
+
for (const child of children) validateUiCondition(child, stateKeys, sources, depth + 1, counter);
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1742
1788
|
rejectUnknownFields(condition, new Set(["path", "equals", "not_equals", "exists"]), "UI visibility condition");
|
|
1743
1789
|
if (typeof condition.path !== "string" || !UI_BINDING.test(condition.path)) throw new Error("UI visibility path is invalid");
|
|
1744
1790
|
validateKnownBinding(condition.path, stateKeys, sources);
|
|
@@ -1747,13 +1793,23 @@ function validateUiCondition(condition, stateKeys, sources) {
|
|
|
1747
1793
|
if (checks[0] !== "exists") validateUiJson(condition[checks[0]], 0);
|
|
1748
1794
|
}
|
|
1749
1795
|
|
|
1750
|
-
function validateUiAction(action, executable, routes, sources, stateKeys, required = false) {
|
|
1796
|
+
function validateUiAction(action, executable, routes, sources, stateKeys, required = false, depth = 0, counter = { value: 0 }) {
|
|
1751
1797
|
if (action == null) {
|
|
1752
1798
|
if (required) throw new Error("UI action is required");
|
|
1753
1799
|
return;
|
|
1754
1800
|
}
|
|
1801
|
+
assertObject(action, "UI action");
|
|
1802
|
+
counter.value += 1;
|
|
1803
|
+
if (depth > 4 || counter.value > 16) throw new Error("UI action flow is too complex");
|
|
1755
1804
|
rejectUnknownFields(action, new Set(contract.ui_runtime.action_fields), "UI action");
|
|
1756
|
-
if (!["invoke", "navigate", "set_state", "refresh", "back"].includes(action.type)) throw new Error("UI action type is invalid");
|
|
1805
|
+
if (!["invoke", "navigate", "set_state", "refresh", "back", "flow"].includes(action.type)) throw new Error("UI action type is invalid");
|
|
1806
|
+
if (action.validate !== undefined && typeof action.validate !== "boolean") throw new Error("UI action validation flag is invalid");
|
|
1807
|
+
if (action.type === "flow") {
|
|
1808
|
+
rejectUnknownFields(action, new Set(["type", "mode", "actions", "continue_on_error", "validate"]), "UI action flow");
|
|
1809
|
+
if (!["sequential", "parallel"].includes(action.mode) || !Array.isArray(action.actions) || action.actions.length < 1 || action.actions.length > 8 || (action.continue_on_error !== undefined && typeof action.continue_on_error !== "boolean")) throw new Error("UI action flow is invalid");
|
|
1810
|
+
for (const child of action.actions) validateUiAction(child, executable, routes, sources, stateKeys, true, depth + 1, counter);
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1757
1813
|
if (action.type === "invoke") {
|
|
1758
1814
|
if (!executable.has(action.capability)) throw new Error("UI action targets a non-executable capability");
|
|
1759
1815
|
optionalUiTemplate(action.query, 2000);
|
|
@@ -1768,6 +1824,11 @@ function validateUiAction(action, executable, routes, sources, stateKeys, requir
|
|
|
1768
1824
|
if (!["platform", "fade", "slide", "none"].includes(transition)) throw new Error("UI navigation transition is invalid");
|
|
1769
1825
|
const replace = action.replace === undefined ? false : action.replace;
|
|
1770
1826
|
if (typeof replace !== "boolean") throw new Error("UI navigation replacement is invalid");
|
|
1827
|
+
const parameters = action.parameters === undefined ? {} : action.parameters;
|
|
1828
|
+
assertObject(parameters, "UI navigation parameters");
|
|
1829
|
+
validateUiBindingValue(parameters, 0, stateKeys, sources);
|
|
1830
|
+
if (action.result_state !== undefined && !stateKeys.has(action.result_state)) throw new Error("UI navigation result state is unknown");
|
|
1831
|
+
if (action.result_state !== undefined && replace) throw new Error("replacement navigation cannot return a result");
|
|
1771
1832
|
} else if (action.type === "set_state") {
|
|
1772
1833
|
assertObject(action.values, "UI state action values");
|
|
1773
1834
|
if (!Object.keys(action.values).length || Object.keys(action.values).some((key) => !stateKeys.has(key))) throw new Error("UI state action targets an unknown state key");
|
|
@@ -1776,8 +1837,21 @@ function validateUiAction(action, executable, routes, sources, stateKeys, requir
|
|
|
1776
1837
|
if (typeof persist !== "boolean") throw new Error("UI state persistence flag is invalid");
|
|
1777
1838
|
} else if (action.type === "refresh") {
|
|
1778
1839
|
if (!sources.has(action.source)) throw new Error("UI refresh source is unknown");
|
|
1779
|
-
} else
|
|
1840
|
+
} else {
|
|
1841
|
+
rejectUnknownFields(action, new Set(["type", "result"]), "back UI action");
|
|
1842
|
+
const result = action.result === undefined ? {} : action.result;
|
|
1843
|
+
assertObject(result, "UI route result");
|
|
1844
|
+
validateUiBindingValue(result, 0, stateKeys, sources);
|
|
1845
|
+
}
|
|
1780
1846
|
if (action.type !== "navigate" && (action.transition !== undefined || action.replace !== undefined)) throw new Error("navigation options require a navigate UI action");
|
|
1847
|
+
const allowed = {
|
|
1848
|
+
invoke: new Set(["type", "capability", "query", "arguments", "store", "validate"]),
|
|
1849
|
+
navigate: new Set(["type", "target", "transition", "replace", "parameters", "result_state", "validate"]),
|
|
1850
|
+
set_state: new Set(["type", "values", "persist"]),
|
|
1851
|
+
refresh: new Set(["type", "source"]),
|
|
1852
|
+
back: new Set(["type", "result"]),
|
|
1853
|
+
}[action.type];
|
|
1854
|
+
rejectUnknownFields(action, allowed, "UI action");
|
|
1781
1855
|
}
|
|
1782
1856
|
|
|
1783
1857
|
function validateUiBindingValue(value, depth, stateKeys, sources) {
|
|
@@ -1818,6 +1892,73 @@ function validateUiJson(value, depth) {
|
|
|
1818
1892
|
for (const item of Object.values(value)) validateUiJson(item, depth + 1);
|
|
1819
1893
|
}
|
|
1820
1894
|
|
|
1895
|
+
function validateUiCapabilityBindings(config, sources, capabilitySchemas) {
|
|
1896
|
+
const sourceCapabilities = new Map(sources.map((source) => [source.id, source.capability]));
|
|
1897
|
+
for (const source of sources) {
|
|
1898
|
+
const schema = capabilitySchemas.get(source.capability)?.input_schema;
|
|
1899
|
+
if (schema !== undefined && !uiSchemaAccepts(schema, source.arguments === undefined ? {} : source.arguments)) {
|
|
1900
|
+
throw new Error(`UI data source ${source.id} does not match capability input_schema`);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
function visit(value) {
|
|
1904
|
+
if (typeof value === "string") {
|
|
1905
|
+
for (const match of value.matchAll(UI_TEMPLATE_BINDING)) {
|
|
1906
|
+
const parts = match[1].trim().split(".");
|
|
1907
|
+
if (parts.length < 4 || parts[0] !== "data" || parts[2] !== "data") continue;
|
|
1908
|
+
const schema = capabilitySchemas.get(sourceCapabilities.get(parts[1]))?.output_schema;
|
|
1909
|
+
if (schema !== undefined && !uiSchemaHasPath(schema, parts.slice(3))) throw new Error("UI binding references an unknown capability output");
|
|
1910
|
+
}
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
if (Array.isArray(value)) return value.forEach(visit);
|
|
1914
|
+
if (!value || typeof value !== "object") return;
|
|
1915
|
+
if (value.type === "invoke") {
|
|
1916
|
+
const schema = capabilitySchemas.get(value.capability)?.input_schema;
|
|
1917
|
+
if (schema !== undefined && !uiSchemaAccepts(schema, value.arguments === undefined ? {} : value.arguments)) throw new Error("UI action does not match capability input_schema");
|
|
1918
|
+
}
|
|
1919
|
+
Object.values(value).forEach(visit);
|
|
1920
|
+
}
|
|
1921
|
+
visit(config);
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
function uiSchemaAccepts(schema, value) {
|
|
1925
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return false;
|
|
1926
|
+
if (typeof value === "string") {
|
|
1927
|
+
const exact = UI_SINGLE_TEMPLATE_BINDING.exec(value);
|
|
1928
|
+
if (exact) return true;
|
|
1929
|
+
if ([...value.matchAll(UI_TEMPLATE_BINDING)].length) return schema.type === "string";
|
|
1930
|
+
}
|
|
1931
|
+
if (schema.enum !== undefined && !schema.enum.some((item) => JSON.stringify(item) === JSON.stringify(value))) return false;
|
|
1932
|
+
if (schema.type === "null") return value === null;
|
|
1933
|
+
if (schema.type === "boolean") return typeof value === "boolean";
|
|
1934
|
+
if (schema.type === "integer") return Number.isInteger(value) && (schema.minimum === undefined || value >= schema.minimum) && (schema.maximum === undefined || value <= schema.maximum);
|
|
1935
|
+
if (schema.type === "number") return typeof value === "number" && Number.isFinite(value) && (schema.minimum === undefined || value >= schema.minimum) && (schema.maximum === undefined || value <= schema.maximum);
|
|
1936
|
+
if (schema.type === "string") return typeof value === "string" && value.length >= (schema.minLength ?? 0) && value.length <= (schema.maxLength ?? 2000);
|
|
1937
|
+
if (schema.type === "array") return Array.isArray(value) && value.length >= (schema.minItems ?? 0) && value.length <= (schema.maxItems ?? 64) && value.every((item) => uiSchemaAccepts(schema.items, item));
|
|
1938
|
+
if (schema.type !== "object" || !value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1939
|
+
const properties = schema.properties || {};
|
|
1940
|
+
if ((schema.required || []).some((key) => !Object.hasOwn(value, key))) return false;
|
|
1941
|
+
if (schema.additionalProperties === false && Object.keys(value).some((key) => !Object.hasOwn(properties, key))) return false;
|
|
1942
|
+
return Object.entries(value).every(([key, item]) => !Object.hasOwn(properties, key) || uiSchemaAccepts(properties[key], item));
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
function uiSchemaHasPath(schema, parts) {
|
|
1946
|
+
let current = schema;
|
|
1947
|
+
for (const part of parts) {
|
|
1948
|
+
if (!current || typeof current !== "object" || Array.isArray(current)) return false;
|
|
1949
|
+
if (current.type === "array") {
|
|
1950
|
+
current = current.items;
|
|
1951
|
+
if (part === "length") return true;
|
|
1952
|
+
if (/^\d+$/.test(part)) continue;
|
|
1953
|
+
}
|
|
1954
|
+
if (!current || current.type !== "object") return false;
|
|
1955
|
+
const properties = current.properties || {};
|
|
1956
|
+
if (!Object.hasOwn(properties, part)) return current.additionalProperties !== false;
|
|
1957
|
+
current = properties[part];
|
|
1958
|
+
}
|
|
1959
|
+
return true;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1821
1962
|
function optionalUiTemplate(value, maximum, required = false) {
|
|
1822
1963
|
if (value === undefined || value === null) {
|
|
1823
1964
|
if (required) throw new Error("UI text template is required");
|
|
@@ -1836,6 +1977,12 @@ function validateKnownBinding(path, stateKeys, sources) {
|
|
|
1836
1977
|
const parts = path.split(".");
|
|
1837
1978
|
if (parts[0] === "state" && (parts.length < 2 || !stateKeys.has(parts[1]))) throw new Error("UI state binding is unknown");
|
|
1838
1979
|
if (parts[0] === "data" && (parts.length < 2 || !sources.has(parts[1]))) throw new Error("UI data binding is unknown");
|
|
1980
|
+
if (parts[0] === "context") {
|
|
1981
|
+
const roots = new Set(["platform", "screen", "permissions", "connections", "connected", "loading", "errors", "route"]);
|
|
1982
|
+
if (parts.length < 2 || !roots.has(parts[1])) throw new Error("UI runtime context binding is unknown");
|
|
1983
|
+
if (parts[1] === "screen" && (parts.length !== 3 || !["width", "height", "size_class"].includes(parts[2]))) throw new Error("UI screen context binding is unknown");
|
|
1984
|
+
if (["loading", "errors"].includes(parts[1]) && (parts.length !== 3 || !sources.has(parts[2]))) throw new Error("UI data-source context binding is unknown");
|
|
1985
|
+
}
|
|
1839
1986
|
}
|
|
1840
1987
|
|
|
1841
1988
|
function optionalUiText(value, maximum, { required = false, identifier = false } = {}) {
|