@lark-apaas/coding-steering 0.1.44-alpha.20260901133958 → 0.1.44-alpha.20260902024922
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/package.json +1 -1
- package/steering/design-html/skills/pptx-style-extract/SKILL.md +1 -1
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +1 -21
- package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +0 -120
- package/steering/design-html/skills/pptx-style-extract/scripts/test_logo_scope.py +0 -121
- package/steering/design-html/skills/pptx-style-extract/scripts/verify_layout_assets.py +3 -24
- package/steering/nestjs-react-fullstack/skills/app-init-feasibility-guide/SKILL.md +0 -1
- package/steering/nestjs-react-fullstack/skills/client-builtins-user-service/SKILL.md +6 -16
- package/steering/nestjs-react-fullstack/skills/coding-guide/SKILL.md +34 -19
- package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +4 -9
- package/steering/nestjs-react-fullstack/skills/plugin-guide/references/plugin-coding-guide.md +9 -20
- package/steering/nestjs-react-fullstack/skills_local/client-builtins-user-service/SKILL.md +6 -5
- package/steering/nestjs-react-fullstack/skills_local/coding-guide/SKILL.md +30 -18
- package/steering/nestjs-react-fullstack/skills_local/plugin-guide/SKILL.md +2 -6
- package/steering/nestjs-react-fullstack/skills_local/plugin-guide/references/plugin-coding-guide.md +9 -20
package/package.json
CHANGED
|
@@ -130,7 +130,7 @@ Read `<pack_dir>/design.md` first, especially `## Usage`, `## Hard Rules`, color
|
|
|
130
130
|
When generating a deck:
|
|
131
131
|
|
|
132
132
|
1. Call `copy_starter_component` with `kind: "deck-stage.js"`.
|
|
133
|
-
2. Build `<deck-stage width="<canvas width>" height="<canvas height>">` using the `canvas` declared in `layouts.md` — source decks are not always 16:9, and a default-sized stage shifts every coordinate on the page. Each slide is one static `<section data-pptx-layout="<chosen archetype>">`.
|
|
133
|
+
2. Build `<deck-stage width="<canvas width>" height="<canvas height>">` using the `canvas` declared in `layouts.md` — source decks are not always 16:9, and a default-sized stage shifts every coordinate on the page. Each slide is one static `<section data-pptx-layout="<chosen archetype>">`.
|
|
134
134
|
3. Inline CSS variables from `design.md` into the HTML `<style>` block using a `--ppt-*` prefix.
|
|
135
135
|
4. **位移动画用独立的 `translate` 属性**:`@keyframes fadeUp { from{opacity:0; translate:0 24px} to{opacity:1; translate:0 0} }`。`transform` 是单一属性,动画里碰它会覆盖掉元素原有的那条(`left:50%; transform:translateX(-50%)` 的居中就此丢失);`translate` / `rotate` / `scale` 各自独立,与已有 `transform` 叠加。
|
|
136
136
|
5. Map `layouts.md` slots to absolute-positioned elements inside each section: expand `box: [x,y,w,h]` mechanically to `left/top/width/height`, then apply the slot's `css` declaration string unchanged. `box` owns geometry; `css` owns all rendering style, including the template's text padding, typography, alignment, line height, letter spacing, and rotation. Do not reinterpret PPTX fields or replace slot CSS with your own type scale. A slot with `asset` is a fixed image element: use that exact asset at that box on that archetype only; do not omit or replace it. Do not reflow any of this as generic web grids.
|
|
@@ -1243,21 +1243,7 @@ def layouts_from_template(d, shapes, cW, cH):
|
|
|
1243
1243
|
sample_pages_of_layout = defaultdict(list)
|
|
1244
1244
|
for slide_part, layout_part in lay_of_slide.items():
|
|
1245
1245
|
sample_pages_of_layout[layout_part].append(slide_no(slide_part))
|
|
1246
|
-
sampled_theme_counts = Counter(
|
|
1247
|
-
theme_of_master.get(master_of.get(layout_part))
|
|
1248
|
-
for layout_part in lay_of_slide.values()
|
|
1249
|
-
)
|
|
1250
|
-
sampled_theme_counts.pop(None, None)
|
|
1251
1246
|
default_theme = topo.get('default')
|
|
1252
|
-
if sampled_theme_counts:
|
|
1253
|
-
highest = max(sampled_theme_counts.values())
|
|
1254
|
-
leaders = sorted(
|
|
1255
|
-
theme for theme, count in sampled_theme_counts.items()
|
|
1256
|
-
if count == highest
|
|
1257
|
-
)
|
|
1258
|
-
if default_theme not in leaders:
|
|
1259
|
-
default_theme = leaders[0]
|
|
1260
|
-
topo['default'] = default_theme
|
|
1261
1247
|
multi = len(topo.get('themes') or []) > 1
|
|
1262
1248
|
|
|
1263
1249
|
rows = []
|
|
@@ -2408,9 +2394,7 @@ def emit_manifest(d, assets, vision_groups, ldir, archetypes=()):
|
|
|
2408
2394
|
' TODO: 一句话说清这套模板的视觉性格(底色 / 主色 / 字形 / 版面骨架),给消费模型定调。']
|
|
2409
2395
|
themes = d['theme_topology'].get('themes') or ['single']
|
|
2410
2396
|
if themes != ['single'] and len(themes) > 1:
|
|
2411
|
-
|
|
2412
|
-
L += ['themes: [%s]' % ', '.join(themes),
|
|
2413
|
-
'default-theme: %s' % default_theme]
|
|
2397
|
+
L += ['themes: [%s]' % ', '.join(themes), 'default-theme: %s' % themes[0]]
|
|
2414
2398
|
if assets:
|
|
2415
2399
|
L.append('assets:')
|
|
2416
2400
|
for a in assets:
|
|
@@ -2937,8 +2921,6 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
|
|
|
2937
2921
|
L.append(' %s:' % a['name'])
|
|
2938
2922
|
if a.get('role'):
|
|
2939
2923
|
L.append(' role: %s' % a['role'])
|
|
2940
|
-
if a.get('theme'):
|
|
2941
|
-
L.append(' themes: [%s]' % a['theme'])
|
|
2942
2924
|
if a['bg']:
|
|
2943
2925
|
L.append(' background: %s' % a['bg'])
|
|
2944
2926
|
fl = a.get('flow')
|
|
@@ -3074,8 +3056,6 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
|
|
|
3074
3056
|
'页数多于页型时,挑最接近的一个原样套用它的 slot:用不到的槽删掉,'
|
|
3075
3057
|
'内容比槽多就按同类槽的间距等距加,**坐标一律沿用该页型给的那套,不要自己另起网格**。'
|
|
3076
3058
|
'每个生成页面的 `<section>` 都写 `data-pptx-layout="<页型名>"`,'
|
|
3077
|
-
'多主题包默认只选兼容 `default-theme` 的页型;确需切换时,该页同时写 '
|
|
3078
|
-
'`data-pptx-theme="<主题名>"`,且主题必须属于该页型的 `themes`。'
|
|
3079
3059
|
'交付前据此核验该页型绑定的背景与图片资产均已使用,且没有跨页型误用。' % sidecar,
|
|
3080
3060
|
'3. **按页型给的形态落元素** —— 页型给 `flow` 就用流式,给 `slots` 就用绝对,'
|
|
3081
3061
|
'两者只会出现一个。'
|
|
@@ -15,7 +15,6 @@ from draft import (asset_vision_contexts, bound_visual_candidates, # noqa: E402
|
|
|
15
15
|
cover_background_media,
|
|
16
16
|
emit_asset_vision_groups, emit_layouts, emit_manifest,
|
|
17
17
|
fullscreen_overlay_media,
|
|
18
|
-
layouts_from_template,
|
|
19
18
|
needs_asset_judgment, preserve_image_bearing_groups,
|
|
20
19
|
select_asset_vision_groups, slide_image_marks, slot_style,
|
|
21
20
|
visual_slot_candidates)
|
|
@@ -25,125 +24,6 @@ from package import (FONTSIZE_RE, Fail, apply_asset_decisions, # noqa: E402
|
|
|
25
24
|
|
|
26
25
|
|
|
27
26
|
class LayoutCssTest(unittest.TestCase):
|
|
28
|
-
def test_template_layouts_use_sampled_theme_as_default_and_keep_theme_scope(self):
|
|
29
|
-
dark_master = 'ppt/slideMasters/slideMaster1.xml'
|
|
30
|
-
light_master = 'ppt/slideMasters/slideMaster2.xml'
|
|
31
|
-
dark_layout = 'ppt/slideLayouts/slideLayout1.xml'
|
|
32
|
-
light_layout = 'ppt/slideLayouts/slideLayout2.xml'
|
|
33
|
-
data = {
|
|
34
|
-
'layouts': [
|
|
35
|
-
{'part': dark_layout, 'name': 'Dark content', 'used_by_slides': 2},
|
|
36
|
-
{'part': light_layout, 'name': 'Light content', 'used_by_slides': 0},
|
|
37
|
-
],
|
|
38
|
-
'slides': [],
|
|
39
|
-
'theme_topology': {
|
|
40
|
-
'themes': ['light', 'dark'],
|
|
41
|
-
'default': 'light',
|
|
42
|
-
'per_master': [
|
|
43
|
-
{'master': dark_master, 'theme_label': 'dark'},
|
|
44
|
-
{'master': light_master, 'theme_label': 'light'},
|
|
45
|
-
],
|
|
46
|
-
},
|
|
47
|
-
'reference_graph': {
|
|
48
|
-
'master_of_layout': {
|
|
49
|
-
dark_layout: dark_master,
|
|
50
|
-
light_layout: light_master,
|
|
51
|
-
},
|
|
52
|
-
'layout_of_slide': {
|
|
53
|
-
'ppt/slides/slide1.xml': dark_layout,
|
|
54
|
-
'ppt/slides/slide2.xml': dark_layout,
|
|
55
|
-
},
|
|
56
|
-
},
|
|
57
|
-
'background_composites': {},
|
|
58
|
-
}
|
|
59
|
-
shapes = [
|
|
60
|
-
{
|
|
61
|
-
'part': part,
|
|
62
|
-
'layer': 'layout',
|
|
63
|
-
'kind': 'sp',
|
|
64
|
-
'box': {'x': 100, 'y': 100, 'w': 800, 'h': 100},
|
|
65
|
-
'ph': {'type': 'title', 'idx': '1'},
|
|
66
|
-
'text': {'paragraphs': [{'runs': [{'text': 'Title', 'sz_px': 40}]}]},
|
|
67
|
-
}
|
|
68
|
-
for part in (dark_layout, light_layout)
|
|
69
|
-
]
|
|
70
|
-
|
|
71
|
-
archetypes = layouts_from_template(data, shapes, 1920, 1080)
|
|
72
|
-
|
|
73
|
-
self.assertEqual('dark', data['theme_topology']['default'])
|
|
74
|
-
self.assertEqual(
|
|
75
|
-
{'content': 'dark', 'content-2': 'light'},
|
|
76
|
-
{item['name']: item['theme'] for item in archetypes},
|
|
77
|
-
)
|
|
78
|
-
with tempfile.TemporaryDirectory() as output_dir:
|
|
79
|
-
emit_layouts(archetypes, output_dir)
|
|
80
|
-
emit_manifest(data, [], [], output_dir, archetypes)
|
|
81
|
-
with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
|
|
82
|
-
layouts = stream.read()
|
|
83
|
-
with open(os.path.join(output_dir, 'manifest.yaml'), encoding='utf-8') as stream:
|
|
84
|
-
manifest = stream.read()
|
|
85
|
-
self.assertIn(' themes: [dark]', layouts)
|
|
86
|
-
self.assertIn(' themes: [light]', layouts)
|
|
87
|
-
self.assertIn('default-theme: dark', manifest)
|
|
88
|
-
|
|
89
|
-
def test_template_twin_layouts_keep_topology_but_scope_retained_layout(self):
|
|
90
|
-
dark_master = 'ppt/slideMasters/slideMaster1.xml'
|
|
91
|
-
light_master = 'ppt/slideMasters/slideMaster2.xml'
|
|
92
|
-
dark_layout = 'ppt/slideLayouts/slideLayout1.xml'
|
|
93
|
-
light_layout = 'ppt/slideLayouts/slideLayout2.xml'
|
|
94
|
-
data = {
|
|
95
|
-
'layouts': [
|
|
96
|
-
{'part': dark_layout, 'name': 'Content', 'used_by_slides': 2},
|
|
97
|
-
{'part': light_layout, 'name': 'Content', 'used_by_slides': 0},
|
|
98
|
-
],
|
|
99
|
-
'slides': [],
|
|
100
|
-
'theme_topology': {
|
|
101
|
-
'themes': ['light', 'dark'],
|
|
102
|
-
'default': 'light',
|
|
103
|
-
'per_master': [
|
|
104
|
-
{'master': dark_master, 'theme_label': 'dark'},
|
|
105
|
-
{'master': light_master, 'theme_label': 'light'},
|
|
106
|
-
],
|
|
107
|
-
},
|
|
108
|
-
'reference_graph': {
|
|
109
|
-
'master_of_layout': {
|
|
110
|
-
dark_layout: dark_master,
|
|
111
|
-
light_layout: light_master,
|
|
112
|
-
},
|
|
113
|
-
'layout_of_slide': {
|
|
114
|
-
'ppt/slides/slide1.xml': dark_layout,
|
|
115
|
-
'ppt/slides/slide2.xml': dark_layout,
|
|
116
|
-
},
|
|
117
|
-
},
|
|
118
|
-
'background_composites': {},
|
|
119
|
-
}
|
|
120
|
-
shapes = [
|
|
121
|
-
{
|
|
122
|
-
'part': part,
|
|
123
|
-
'layer': 'layout',
|
|
124
|
-
'kind': 'sp',
|
|
125
|
-
'box': {'x': 100, 'y': 100, 'w': 800, 'h': 100},
|
|
126
|
-
'ph': {'type': 'title', 'idx': '1'},
|
|
127
|
-
'text': {'paragraphs': [{'runs': [{'text': 'Title', 'sz_px': 40}]}]},
|
|
128
|
-
}
|
|
129
|
-
for part in (dark_layout, light_layout)
|
|
130
|
-
]
|
|
131
|
-
|
|
132
|
-
archetypes = layouts_from_template(data, shapes, 1920, 1080)
|
|
133
|
-
|
|
134
|
-
self.assertEqual(1, len(archetypes))
|
|
135
|
-
self.assertEqual('dark', archetypes[0]['theme'])
|
|
136
|
-
with tempfile.TemporaryDirectory() as output_dir:
|
|
137
|
-
emit_layouts(archetypes, output_dir)
|
|
138
|
-
emit_manifest(data, [], [], output_dir, archetypes)
|
|
139
|
-
with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
|
|
140
|
-
layouts = stream.read()
|
|
141
|
-
with open(os.path.join(output_dir, 'manifest.yaml'), encoding='utf-8') as stream:
|
|
142
|
-
manifest = stream.read()
|
|
143
|
-
self.assertIn(' themes: [dark]', layouts)
|
|
144
|
-
self.assertIn('themes: [light, dark]', manifest)
|
|
145
|
-
self.assertIn('default-theme: dark', manifest)
|
|
146
|
-
|
|
147
27
|
def test_text_slot_emits_rendering_style_as_css_only(self):
|
|
148
28
|
shape = {
|
|
149
29
|
'text': {
|
|
@@ -128,31 +128,6 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
128
128
|
'---\n'
|
|
129
129
|
)
|
|
130
130
|
|
|
131
|
-
def write_theme_pack(self, root):
|
|
132
|
-
with open(os.path.join(root, 'design.md'), 'w', encoding='utf-8') as stream:
|
|
133
|
-
stream.write(
|
|
134
|
-
'---\n'
|
|
135
|
-
'themes: [dark, light]\n'
|
|
136
|
-
'default-theme: dark\n'
|
|
137
|
-
'layouts: layouts.md\n'
|
|
138
|
-
'---\n'
|
|
139
|
-
)
|
|
140
|
-
with open(os.path.join(root, 'layouts.md'), 'w', encoding='utf-8') as stream:
|
|
141
|
-
stream.write(
|
|
142
|
-
'---\n'
|
|
143
|
-
'canvas: 1920x1080\n'
|
|
144
|
-
'layouts:\n'
|
|
145
|
-
' dark-content:\n'
|
|
146
|
-
' role: content\n'
|
|
147
|
-
' themes: [dark]\n'
|
|
148
|
-
' slots: []\n'
|
|
149
|
-
' light-content:\n'
|
|
150
|
-
' role: content\n'
|
|
151
|
-
' themes: [light]\n'
|
|
152
|
-
' slots: []\n'
|
|
153
|
-
'---\n'
|
|
154
|
-
)
|
|
155
|
-
|
|
156
131
|
def test_logo_is_limited_to_every_archetype_with_its_slot(self):
|
|
157
132
|
with tempfile.TemporaryDirectory() as root:
|
|
158
133
|
self.write_pack(root)
|
|
@@ -225,102 +200,6 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
225
200
|
validate_layout_assets(root, html, 'assets/pptx-volcengine'),
|
|
226
201
|
)
|
|
227
202
|
|
|
228
|
-
def test_missing_deck_slides_is_rejected_even_without_fixed_assets(self):
|
|
229
|
-
with tempfile.TemporaryDirectory() as root:
|
|
230
|
-
self.write_theme_pack(root)
|
|
231
|
-
html = self.write_html(root, '<main><section>普通页面</section></main>')
|
|
232
|
-
|
|
233
|
-
self.assertEqual(
|
|
234
|
-
['没有识别到 deck-stage 的直属 slide section,无法核验模板页型'],
|
|
235
|
-
validate_layout_assets(root, html, 'assets/pptx-theme'),
|
|
236
|
-
)
|
|
237
|
-
|
|
238
|
-
def test_layout_outside_default_theme_requires_explicit_theme(self):
|
|
239
|
-
with tempfile.TemporaryDirectory() as root:
|
|
240
|
-
self.write_theme_pack(root)
|
|
241
|
-
html = self.write_html(
|
|
242
|
-
root,
|
|
243
|
-
'<deck-stage>'
|
|
244
|
-
'<section data-pptx-layout="light-content">正文</section>'
|
|
245
|
-
'</deck-stage>',
|
|
246
|
-
)
|
|
247
|
-
|
|
248
|
-
self.assertEqual(
|
|
249
|
-
['第 1 页页型 light-content 不支持当前主题 dark;'
|
|
250
|
-
'允许主题: light。若确需切换,显式声明 data-pptx-theme'],
|
|
251
|
-
validate_layout_assets(root, html, 'assets/pptx-theme'),
|
|
252
|
-
)
|
|
253
|
-
|
|
254
|
-
def test_explicit_non_default_theme_must_match_layout(self):
|
|
255
|
-
with tempfile.TemporaryDirectory() as root:
|
|
256
|
-
self.write_theme_pack(root)
|
|
257
|
-
html = self.write_html(
|
|
258
|
-
root,
|
|
259
|
-
'<deck-stage>'
|
|
260
|
-
'<section data-pptx-layout="light-content" '
|
|
261
|
-
'data-pptx-theme="light">正文</section>'
|
|
262
|
-
'</deck-stage>',
|
|
263
|
-
)
|
|
264
|
-
|
|
265
|
-
self.assertEqual(
|
|
266
|
-
[],
|
|
267
|
-
validate_layout_assets(root, html, 'assets/pptx-theme'),
|
|
268
|
-
)
|
|
269
|
-
|
|
270
|
-
def test_unknown_explicit_theme_is_rejected(self):
|
|
271
|
-
with tempfile.TemporaryDirectory() as root:
|
|
272
|
-
self.write_theme_pack(root)
|
|
273
|
-
html = self.write_html(
|
|
274
|
-
root,
|
|
275
|
-
'<deck-stage>'
|
|
276
|
-
'<section data-pptx-layout="dark-content" '
|
|
277
|
-
'data-pptx-theme="brand-new">正文</section>'
|
|
278
|
-
'</deck-stage>',
|
|
279
|
-
)
|
|
280
|
-
|
|
281
|
-
self.assertEqual(
|
|
282
|
-
['第 1 页声明了不存在的模板主题: brand-new'],
|
|
283
|
-
validate_layout_assets(root, html, 'assets/pptx-theme'),
|
|
284
|
-
)
|
|
285
|
-
|
|
286
|
-
def test_pack_without_fixed_assets_still_accepts_valid_layout(self):
|
|
287
|
-
with tempfile.TemporaryDirectory() as root:
|
|
288
|
-
self.write_theme_pack(root)
|
|
289
|
-
html = self.write_html(
|
|
290
|
-
root,
|
|
291
|
-
'<deck-stage>'
|
|
292
|
-
'<section data-pptx-layout="dark-content">正文</section>'
|
|
293
|
-
'</deck-stage>',
|
|
294
|
-
)
|
|
295
|
-
|
|
296
|
-
self.assertEqual(
|
|
297
|
-
[],
|
|
298
|
-
validate_layout_assets(root, html, 'assets/pptx-theme'),
|
|
299
|
-
)
|
|
300
|
-
|
|
301
|
-
def test_non_mapping_layout_entry_does_not_crash_theme_validation(self):
|
|
302
|
-
with tempfile.TemporaryDirectory() as root:
|
|
303
|
-
self.write_theme_pack(root)
|
|
304
|
-
with open(os.path.join(root, 'layouts.md'), 'w', encoding='utf-8') as stream:
|
|
305
|
-
stream.write(
|
|
306
|
-
'---\n'
|
|
307
|
-
'canvas: 1920x1080\n'
|
|
308
|
-
'layouts:\n'
|
|
309
|
-
' malformed: unavailable\n'
|
|
310
|
-
'---\n'
|
|
311
|
-
)
|
|
312
|
-
html = self.write_html(
|
|
313
|
-
root,
|
|
314
|
-
'<deck-stage>'
|
|
315
|
-
'<section data-pptx-layout="malformed">正文</section>'
|
|
316
|
-
'</deck-stage>',
|
|
317
|
-
)
|
|
318
|
-
|
|
319
|
-
self.assertEqual(
|
|
320
|
-
[],
|
|
321
|
-
validate_layout_assets(root, html, 'assets/pptx-theme'),
|
|
322
|
-
)
|
|
323
|
-
|
|
324
203
|
def test_logo_in_global_css_is_rejected(self):
|
|
325
204
|
with tempfile.TemporaryDirectory() as root:
|
|
326
205
|
self.write_pack(root)
|
|
@@ -207,7 +207,6 @@ class SlideAssetParser(HTMLParser):
|
|
|
207
207
|
tag == 'section' and parent and parent['tag'] == 'deck-stage')
|
|
208
208
|
slide = ({
|
|
209
209
|
'layout': attrs_map.get('data-pptx-layout'),
|
|
210
|
-
'theme': attrs_map.get('data-pptx-theme'),
|
|
211
210
|
'references': [],
|
|
212
211
|
} if slide_root else parent_slide)
|
|
213
212
|
if slide_root:
|
|
@@ -271,6 +270,8 @@ def validate_layout_assets(pack_dir, html_path, asset_prefix):
|
|
|
271
270
|
pack = Pack(pack_dir)
|
|
272
271
|
owners = layout_asset_contract(pack)
|
|
273
272
|
known_urls = asset_urls(pack, asset_prefix, owners)
|
|
273
|
+
if not known_urls:
|
|
274
|
+
return []
|
|
274
275
|
asset_urls_by_id = {
|
|
275
276
|
asset_id: url
|
|
276
277
|
for url, asset_ids in known_urls.items()
|
|
@@ -284,10 +285,6 @@ def validate_layout_assets(pack_dir, html_path, asset_prefix):
|
|
|
284
285
|
parser.close()
|
|
285
286
|
|
|
286
287
|
problems = copied_asset_problems(pack, html_path, asset_prefix, owners)
|
|
287
|
-
if not parser.slides:
|
|
288
|
-
problems.append(
|
|
289
|
-
'没有识别到 deck-stage 的直属 slide section,无法核验模板页型')
|
|
290
|
-
return problems
|
|
291
288
|
for url in parser.outside_urls:
|
|
292
289
|
for asset_id in sorted(known_urls.get(normalized_path(url), ())):
|
|
293
290
|
problems.append(
|
|
@@ -300,26 +297,8 @@ def validate_layout_assets(pack_dir, html_path, asset_prefix):
|
|
|
300
297
|
if layout not in pack.layouts:
|
|
301
298
|
problems.append('第 %d 页声明了不存在的模板页型: %s' % (number, layout))
|
|
302
299
|
continue
|
|
303
|
-
layout_entry = pack.layouts[layout][0]
|
|
304
|
-
explicit_theme = slide['theme']
|
|
305
|
-
default_theme = pack.design.data.get('default-theme')
|
|
306
|
-
active_theme = explicit_theme or default_theme
|
|
307
|
-
if explicit_theme and explicit_theme not in pack.themes:
|
|
308
|
-
problems.append(
|
|
309
|
-
'第 %d 页声明了不存在的模板主题: %s' % (number, explicit_theme))
|
|
310
|
-
continue
|
|
311
|
-
layout_themes = (
|
|
312
|
-
(layout_entry.get('themes') or [])
|
|
313
|
-
if isinstance(layout_entry, dict) else []
|
|
314
|
-
)
|
|
315
|
-
if active_theme and layout_themes and active_theme not in layout_themes:
|
|
316
|
-
problems.append(
|
|
317
|
-
'第 %d 页页型 %s 不支持当前主题 %s;允许主题: %s。'
|
|
318
|
-
'若确需切换,显式声明 data-pptx-theme'
|
|
319
|
-
% (number, layout, active_theme, '、'.join(layout_themes)))
|
|
320
|
-
continue
|
|
321
300
|
expected = layout_asset_instances(
|
|
322
|
-
|
|
301
|
+
pack.layouts[layout][0], set(pack.assets), pack.canvas)
|
|
323
302
|
actual = []
|
|
324
303
|
for reference in slide['references']:
|
|
325
304
|
referenced_ids = set()
|
|
@@ -71,7 +71,6 @@ available-agents:
|
|
|
71
71
|
| 实时多人编辑、毫秒级协同 | 弱实时刷新、提交后同步 |
|
|
72
72
|
| 原生 App、桌面端、浏览器插件 | Web 应用、响应式页面 |
|
|
73
73
|
| 服务端持久写本地文件 | 平台文件服务、数据库、临时 `/tmp` |
|
|
74
|
-
| 服务启动时下载大文件、全量同步或批量迁移 | 采用异步处理,避免阻塞服务启动 |
|
|
75
74
|
| 无凭证调用受限第三方系统 | 要求用户提供 API、凭证或授权方式 |
|
|
76
75
|
| 自建账号体系绕过平台登录 | 使用平台内置身份与权限 |
|
|
77
76
|
| 多语言 i18n、深浅色主题切换(非平台内置) | 需自行实现并计入工作量;规格中先确认是否必要 |
|
|
@@ -225,23 +225,13 @@ const MyComponent = () => {
|
|
|
225
225
|
## 用户展示与选择方案
|
|
226
226
|
|
|
227
227
|
{% if projectMeta['flags']['supportBusinessUser'] %}
|
|
228
|
-
|
|
228
|
+
用户展示 / 选择使用组件市场的妙搭业务组件,项目内默认未安装。使用前必须先调用 get_ui_component_info(["@miaoda/<name>"]) 安装并阅读返回的 docs,Props / import 路径以 docs 为准:
|
|
229
229
|
|
|
230
|
-
|
|
230
|
+
- @miaoda/user-select 用户选择组件
|
|
231
|
+
- @miaoda/department-select 部门选择组件
|
|
232
|
+
- @miaoda/user-display 用户展示组件:展示任意用户时传 `value`(用户 ID 字符串或字符串数组);**不要**传 `users`。
|
|
231
233
|
|
|
232
|
-
|
|
233
|
-
- DepartmentSelect 部门选择组件(@/components/business-ui/department-select)
|
|
234
|
-
- UserDisplay - 用户展示组件(@/components/business-ui/user-display):展示任意用户时使用 `value`(用户 ID 字符串或字符串数组,也可使用 README/源码声明的用户对象形式)或 `userId` / `user_id` 快捷字段;**不要**给这个 business-ui 组件传 `users`。
|
|
235
|
-
|
|
236
|
-
列表、详情、卡片等任意位置展示一个或多个用户 ID,都继续使用同一个 business-ui 契约:
|
|
237
|
-
|
|
238
|
-
```tsx
|
|
239
|
-
import { UserDisplay } from "@/components/business-ui/user-display";
|
|
240
|
-
|
|
241
|
-
<UserDisplay value={memberIds.map(String)} size="small" showLabel />
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
如果页面需要展示用户字段,不要退回到直接渲染、拼接或 `join` 用户 ID 文本。
|
|
234
|
+
列表、详情、卡片等任意位置展示一个或多个用户 ID,都使用 @miaoda/user-display,不要退回到直接渲染、拼接或 `join` 用户 ID 文本。
|
|
245
235
|
|
|
246
236
|
{% else %}
|
|
247
237
|
|
|
@@ -316,7 +306,7 @@ const form = useForm<z.infer<typeof formSchema>>({
|
|
|
316
306
|
- **组件路径**:`@lark-apaas/client-toolkit/components/User`
|
|
317
307
|
- **功能**:用于所有用户信息的展示场景
|
|
318
308
|
- **特性**:显示用户**头像**和**姓名**,支持多用户展示
|
|
319
|
-
- **边界**:本节仅适用于 `@lark-apaas/client-toolkit/components/User
|
|
309
|
+
- **边界**:本节仅适用于 `@lark-apaas/client-toolkit/components/User`。如果当前项目使用市场组件 @miaoda/user-display(源码位于 `client/src/components/miaoda/user-display`),必须按其 docs 使用 `value` / `userId` / `user_id`,不要把这里的 `users` 参数迁移过去。
|
|
320
310
|
|
|
321
311
|
### 属性定义
|
|
322
312
|
|
|
@@ -18,7 +18,7 @@ unavailable-agents:
|
|
|
18
18
|
- `client/src/index.tsx`
|
|
19
19
|
- `client/index.html`
|
|
20
20
|
- `client/src/app.tsx`(加路由 / Provider 可改,但**禁止删除其中的平台根组件 / Provider 包裹**,禁止用 `eslint-disable` 关掉平台校验规则)
|
|
21
|
-
- `client/src/components/
|
|
21
|
+
- `client/src/components/miaoda/*`(组件市场装机产物,装完不要改写)
|
|
22
22
|
- `server/modules/view/view.controller.ts`
|
|
23
23
|
- `vite.config.ts`
|
|
24
24
|
|
|
@@ -90,8 +90,7 @@ client/
|
|
|
90
90
|
│ │ ├── ui/ # shadcn/ui 组件[Use the components for functionality, but heavily style them.]
|
|
91
91
|
│ │ │ ├── README.md # shadcn/ui 组件库使用说明 [请务必参考该文档进行组件使用]
|
|
92
92
|
{% if projectMeta['flags']['supportBusinessUser'] %}
|
|
93
|
-
│ │ ├──
|
|
94
|
-
│ │ │ ├── README.md # 用户展示,用户头像展示,部门选择展示逻辑必须使用 [请务必参考该文档进行组件使用],禁止直接使用avatar组件等方式自行实现
|
|
93
|
+
│ │ ├── miaoda/ # 组件市场装机的妙搭业务组件(初始不存在,按需安装,见「妙搭业务组件」一节)
|
|
95
94
|
{% endif %}
|
|
96
95
|
│ │ ├── Layout.tsx # 布局包装器
|
|
97
96
|
│ ├── hooks/ # 自定义 React hooks
|
|
@@ -155,7 +154,7 @@ shared/ # 前后端共享的目录
|
|
|
155
154
|
|
|
156
155
|
## 依赖使用规范
|
|
157
156
|
|
|
158
|
-
1. **子包完整性检查**:部分库有多个子包(如 `@
|
|
157
|
+
1. **子包完整性检查**:部分库有多个子包(如 `@radix-ui/react-*` 系列每个组件都是独立子包),添加 import 后必须确认 package.json 中包含所有需要的子包
|
|
159
158
|
2. 用法不清时查看 readme,可进一步搜索或网页访问获取信息
|
|
160
159
|
|
|
161
160
|
## 文件命名约定
|
|
@@ -232,9 +231,8 @@ shared/ # 前后端共享的目录
|
|
|
232
231
|
- **三方集成**:调用第三方 API 需在后端实现,使用 @nestjs/axios
|
|
233
232
|
- **能力边界**:服务端不支持文件上传(FaaS 限制),前端用 dataloom SDK 上传,服务端仅保存元信息
|
|
234
233
|
- **环境判断**:`process.env.NODE_ENV === "production"` 表示生产环境
|
|
235
|
-
-
|
|
234
|
+
- **文件系统**:**严禁使用 `fs` 写入非 `/tmp` 路径**(无状态 FaaS 容器)
|
|
236
235
|
- **服务端运行时资源文件**(字体 / 证书 / 模板 / wasm 等需在运行时读取的非代码文件,CRITICAL — 发布后静默失效根因):① 必须在 `nest-cli.json` 的 `assets` 中声明(如 `{"include": "assets/<dir>/**/*", "outDir": "dist/server"}`),否则不会进 `dist/` 构建产物;② 路径用 `__dirname` 相对**编译产物**定位(如 `path.join(__dirname, "../assets/...")`),**禁止 `process.cwd()` 相对源码路径**——dev 跑源码能命中、发布跑 `dist/` 会落空;③ 资源缺失或加载失败必须 **fail-loud**(抛错或明确错误日志),禁止 `catch` 后静默返回残缺产物
|
|
237
|
-
- **启动生命周期**:`constructor`、`onModuleInit` 和 `onApplicationBootstrap` 应快速完成,避免在其中下载大文件、全量同步或批量迁移;仅将钩子声明为 `async` 仍会被等待,无法缩短启动时间
|
|
238
236
|
|
|
239
237
|
## 日志约定
|
|
240
238
|
|
|
@@ -565,11 +563,13 @@ async createArticle(@Req() req: Request, @Body() dto: CreateArticleDto) {
|
|
|
565
563
|
- **图标**: Lucide React(唯一图标库,禁止 Emoji 和其他图标库)
|
|
566
564
|
- **表格/表单/图表**: 见下方"组件 Skill 召回规则",开发前必须先调用对应 Skill
|
|
567
565
|
{% if projectMeta['flags']['supportBusinessUser'] %}
|
|
568
|
-
- **用户**:
|
|
566
|
+
- **用户**: 用户信息展示/选择必须使用组件市场的妙搭业务组件(见「妙搭业务组件」一节,先安装后使用),禁止手写选人/头像组件、禁止直接展示 userId
|
|
569
567
|
{% endif %}
|
|
570
568
|
{% if projectMeta['flags']['supportTiptapAndStreamdown'] %}
|
|
571
569
|
- **富文本**: `business-ui/tiptap-editor`(阅读 README.md)
|
|
572
570
|
- **Markdown 渲染**: `components/ui/streamdown`(内置 prose 排版)
|
|
571
|
+
{% else %}
|
|
572
|
+
- **Markdown 渲染**: `components/ui/markdown`(react-markdown + remark-gfm,内置 prose 排版)
|
|
573
573
|
{% endif %}
|
|
574
574
|
|
|
575
575
|
## API 请求
|
|
@@ -601,14 +601,32 @@ import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBac
|
|
|
601
601
|
| 组件 | 来源 | 用途 |
|
|
602
602
|
|------|------|------|
|
|
603
603
|
| Table | `@lark-apaas/client-toolkit/antd-table` | 数据表格,**先调 `/table-skill`** |
|
|
604
|
-
{% if projectMeta['flags']['supportBusinessUser'] %}
|
|
605
|
-
| UserSelect/UserDisplay/UserProfile/DepartmentSelect | `business-ui/*` | 用户/部门选择展示 |
|
|
606
|
-
{% endif %}
|
|
607
604
|
{% if projectMeta['flags']['supportTiptapAndStreamdown'] %}
|
|
608
605
|
| TiptapEditorComplete | `business-ui/tiptap-editor` | 富文本编辑器 |
|
|
609
606
|
| Streamdown | `components/ui/streamdown` | Markdown/流式渲染 |
|
|
607
|
+
{% else %}
|
|
608
|
+
| Markdown | `components/ui/markdown` | Markdown 渲染(react-markdown + remark-gfm) |
|
|
610
609
|
{% endif %}
|
|
611
610
|
|
|
611
|
+
### 妙搭业务组件(组件市场,强制使用)
|
|
612
|
+
|
|
613
|
+
企业用户 / 组织类组件已上架组件市场,项目内**默认未安装**。命中以下场景时禁止手写等价组件,必须先安装后使用:
|
|
614
|
+
|
|
615
|
+
| 组件 ID | 用途 |
|
|
616
|
+
|---------|------|
|
|
617
|
+
| @miaoda/user-select | 用户选择器(搜索 / 单多选 / 外部联系人)——选人、成员、指派、审批人等场景 |
|
|
618
|
+
| @miaoda/department-select | 部门选择器——组织架构场景 |
|
|
619
|
+
| @miaoda/chat-select | 群组(群聊)选择器 |
|
|
620
|
+
| @miaoda/user-display | 用户头像 + 姓名展示(传用户 ID 自动拉取资料)——任何展示用户的场景 |
|
|
621
|
+
| @miaoda/user-profile | 用户详情卡(hover 弹出完整资料) |
|
|
622
|
+
|
|
623
|
+
**强制流程**:
|
|
624
|
+
|
|
625
|
+
1. 编码前调用 get_ui_component_info(["@miaoda/user-select", ...]) 一次性安装本轮所需的全部组件(依赖会自动递归安装)
|
|
626
|
+
2. Props / import 路径 / Provider 前置 100% 以工具返回的 docs 为准,禁止凭记忆猜测
|
|
627
|
+
3. 组件源码安装在 client/src/components/miaoda/,不要移动或改写
|
|
628
|
+
4. 工具返回 Component not found 时,告知用户该组件未上架,不要手写等价实现
|
|
629
|
+
|
|
612
630
|
### 组件 Skill 召回规则(强制执行)
|
|
613
631
|
|
|
614
632
|
| 场景 | Skill | 说明 |
|
|
@@ -737,7 +755,7 @@ return <h1>{data?.title || '未知标题'}</h1>;
|
|
|
737
755
|
|
|
738
756
|
- **展示**:必须用 `@client/src/components/ui/image` 组件(非原生 `<img>`),响应式设 `sizes`,固定宽设 `width`
|
|
739
757
|
{% if projectMeta['flags']['supportBusinessUser'] %}
|
|
740
|
-
-
|
|
758
|
+
- 用户头像用市场组件 @miaoda/user-display(见「妙搭业务组件」一节)
|
|
741
759
|
{% endif %}
|
|
742
760
|
|
|
743
761
|
### 图片资源优先级
|
|
@@ -770,15 +788,12 @@ return <h1>{data?.title || '未知标题'}</h1>;
|
|
|
770
788
|
| 验证 | zod |
|
|
771
789
|
| 工具函数 | lodash |
|
|
772
790
|
| 样式 | clsx |
|
|
773
|
-
|
|
|
774
|
-
| PDF 导出 | jspdf + html2canvas(**仅前端实现,禁止服务端实现**) |
|
|
775
|
-
| 文件上传 | react-dropzone |
|
|
776
|
-
| 二维码 | qrcode.react |
|
|
791
|
+
| 文件上传 | 原生 `<input type="file">`,用 shadcn 组件封装样式 |
|
|
777
792
|
| 用户反馈 | sonner |
|
|
778
|
-
|
|
|
779
|
-
|
|
|
780
|
-
|
|
781
|
-
|
|
793
|
+
| 数字动画 | `requestAnimationFrame` 手写,或 framer-motion 的 `animate` |
|
|
794
|
+
| Base64 | 原生 `atob`/`btoa`(Unicode 内容先经 `TextEncoder`/`TextDecoder`);服务端用 `Buffer` |
|
|
795
|
+
|
|
796
|
+
> Excel 解析、PDF 导出、二维码、拖拽、3D 场景等能力已随依赖瘦身不再预装(xlsx、jspdf、html2canvas、qrcode.react、@dnd-kit、react-dropzone、react-countup、js-base64、cobe 已从模板移除)。确有此类需求时,先在 package.json 显式添加所需依赖并安装,再编写代码。
|
|
782
797
|
|
|
783
798
|
## 滚动分页最佳实践
|
|
784
799
|
|
|
@@ -20,7 +20,6 @@ gate-tools:
|
|
|
20
20
|
| 获取运行时投影 | 调用 `get_plugin_ai_json(pluginInstanceId)` |
|
|
21
21
|
| Client 侧调用 | `capabilityClient.load(id).call(actionKey, input)`(流式用 `callStream`) |
|
|
22
22
|
| Server 侧调用(仅兜底) | `capabilityService.load(id).call(actionKey, input)` |
|
|
23
|
-
| 长耗时 AI 结果 | 大体量/多字段/多份/多语言/文件或多模态串联等结构信号命中时,优先前端 `callStream` 渐进展示;需保存则流式结束后复用 CRUD 落库;仅在 Client 侧无法满足时使用后端任务记录 + 状态查询 + 结果读取;禁止单个 HTTP 请求等待完整结果后才返回 |
|
|
24
23
|
| capabilityClient 导入 | `import { capabilityClient } from '@lark-apaas/client-toolkit'` |
|
|
25
24
|
| CapabilityService 导入 | `import { CapabilityService } from '@lark-apaas/fullstack-nestjs-core';` |
|
|
26
25
|
|
|
@@ -71,7 +70,6 @@ gate-tools:
|
|
|
71
70
|
- 必须先调 `get_plugin_ai_json(pluginInstanceId)`,再产出 **Schema 摘录卡**(格式见 `references/plugin-coding-guide.md`);摘录卡字段缺失禁止编码,`output.fields` 必须完整列出且每个输出字段在代码中被消费(持久化或展示)
|
|
72
71
|
- 按 `actions[].key` 选 actionKey;严格按 `inputSchema` 构造入参(`type: array` 字段必须传数组)、按 `outputSchema` 解析出参——流式 chunk 是**对象**(按字段解构如 `chunk.content`,禁止当字符串拼接),非流式同理按字段名读取;**务必阅读并遵循 `readme`**
|
|
73
72
|
- 调用侧:优先 Client(`unary` → `call()`,`stream` → `callStream()`);触发器/定时任务、敏感凭证、强事务、结果需落库 → Server 侧
|
|
74
|
-
- 高耗时 AI capability 闸门:若运行时投影或功能设计显示输出规模大、输出字段多、需要多份结果、多语言/长文本、文件或多模态输入后继续生成、多个 capability 串联、或结果需后续查看/落库,禁止把完整生成放进一个同步 HTTP 请求等待;能由前端承接时优先 `callStream` 渐进展示,需保存则流式结束后复用 CRUD 落库;仅在 Client 侧无法满足时后端创建任务记录后快速返回、由前端短轮询状态/结果,或拆成多个独立小调用
|
|
75
73
|
4. **代码放置**:Client(默认,用户交互触发)→ `client/` 组件/hooks;Server(兜底)→ `server/` Service。
|
|
76
74
|
5. **真实调用冒烟(完成前必须)**:至少成功调用一次 `call()` 或 `callStream()`(按 outputSchema 读 chunk);失败日志含最小字段(字段清单见 `references/plugin-coding-guide.md`)。无冒烟结果不得宣告完成。
|
|
77
75
|
|
|
@@ -138,9 +136,6 @@ const structured = await capabilityClient
|
|
|
138
136
|
├── 输出包含多个独立字段(标题+正文+评分等)
|
|
139
137
|
│ → 拆成多个独立插件并行调用(⭐ 优先)或用 ai-text-to-json
|
|
140
138
|
│ → ⛔ 禁止用 ai-text-generate + 正则/split 解析多字段
|
|
141
|
-
├── 输出规模大(多份结果、多语言、长正文、多章节或批量对象)
|
|
142
|
-
│ → 优先拆分为多个独立生成单元;需要汇总持久化时使用任务状态模型承接
|
|
143
|
-
│ → ⛔ 禁止一个后端 HTTP 请求同步等待所有生成结果后才响应
|
|
144
139
|
└── 输出为单一文本(仅展示,不需解析)→ ai-text-generate
|
|
145
140
|
```
|
|
146
141
|
|
|
@@ -208,9 +203,9 @@ const structured = await capabilityClient
|
|
|
208
203
|
|
|
209
204
|
1. **禁止静默吞异常**:每个 `catch` 至少满足其一——向用户展示错误(toast/页面状态),或触发补偿机制(重试/降级/记录待处理列表)
|
|
210
205
|
2. **异步操作必须有终态**:不阻塞主流程的插件调用须在 DB 维护状态(pending → success/failed),前端必须展示 failed,不能永远 loading
|
|
211
|
-
3.
|
|
212
|
-
4.
|
|
213
|
-
5.
|
|
206
|
+
3. **通知类插件失败必须有补偿**:如 `send-feishu-message` 失败,至少记录"待发送"列表或 UI 提示"通知发送失败,请手动联系"
|
|
207
|
+
4. **配置完整性(load 前必查)**:`load(id)` 前确认实例已创建且 id 与代码完全匹配,否则抛 `CapabilityNotFoundError`(开发态 Top 错误);load/call 失败时停止后续请求避免放大错误;**缓存 load 结果**,同一 id 不重复 load
|
|
208
|
+
5. **线上/客户反馈的插件问题先查 runtime log 取证**:用户反馈「线上 / 已发布」的插件问题(飞书消息发不出 / 插件不生效 / capability 调用「用户收不到」)时,**先查线上 runtime log 再定位代码**——用 `miaoda observability log`(必要时 `trace`)看 CapabilityService 真实错误、plugin_key、action、必填参数/输入校验错误,拿到线上 ERROR/WARN 再读代码修复。**即使你已在代码里读到一个疑似原因,也不得据此直接下根因、跳过取证**——静态代码里的可疑点常不是运行时真正的失败点(线上真错误多为运行时入参为空 / 校验失败 / 授权态问题,代码静态看不出),runtime log 是这类问题下结论前的必经步。沙箱 `read_logs` 只覆盖 dev 态,**不能**作为「线上插件无错误」的证据;纯本地 dev 态插件报错仍用 `read_logs`
|
|
214
209
|
|
|
215
210
|
## 缓存与幂等性
|
|
216
211
|
|
|
@@ -245,7 +240,7 @@ const structured = await capabilityClient
|
|
|
245
240
|
|
|
246
241
|
### 通知接收人动态解析
|
|
247
242
|
|
|
248
|
-
接收人(`receiverUserList`/`receiverGroupList` 等)按角色/条件变化时,必须实时查询角色成员经 `input` 传入(角色/成员的运行时查询写法见 `authz-guide` 技能),禁止硬编码或凭经验拼装 ID。如引入缓存,必须提供显式失效手段(如角色变更时清缓存)并明示 TTL,禁止无失效手段的常驻缓存导致接收人信息过期。通知发送失败的补偿要求见上文「插件调用错误处理」第
|
|
243
|
+
接收人(`receiverUserList`/`receiverGroupList` 等)按角色/条件变化时,必须实时查询角色成员经 `input` 传入(角色/成员的运行时查询写法见 `authz-guide` 技能),禁止硬编码或凭经验拼装 ID。如引入缓存,必须提供显式失效手段(如角色变更时清缓存)并明示 TTL,禁止无失效手段的常驻缓存导致接收人信息过期。通知发送失败的补偿要求见上文「插件调用错误处理」第 3 条铁律,不重复展开。
|
|
249
244
|
|
|
250
245
|
## 飞书深链 URL 规范
|
|
251
246
|
|
package/steering/nestjs-react-fullstack/skills/plugin-guide/references/plugin-coding-guide.md
CHANGED
|
@@ -2,19 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
### 核心原则:根据场景选择调用侧
|
|
4
4
|
|
|
5
|
-
**默认优先在 Client 侧调用 capabilityClient
|
|
6
|
-
|
|
7
|
-
**高耗时 AI capability 先判调用形态**:只要输出规模大、输出字段多、需要多份结果、多语言/长文本、文件或多模态输入后继续生成、多个 capability 串联、或结果需后续查看/落库,就不得把完整 AI 结果塞进一个同步 HTTP 请求等待。能由前端承接时,优先用前端 `callStream` 渐进展示,并在流式结束后按需复用已有 CRUD 接口保存结果;只有 Client 侧无法满足(触发器/敏感凭证/强事务/必须由后端保证落库一致性等)时,才采用后端任务记录 + 状态/结果查询,避免把后台任务作为默认方案。
|
|
8
|
-
|
|
5
|
+
**默认优先在 Client 侧调用 capabilityClient;但当插件结果需要持久化到数据库时,应考虑在 Server 侧调用或确保前端调用后及时通过后端接口保存。**
|
|
9
6
|
**严禁** import { capabilityClient } from '@lark-apaas/client-capability'。
|
|
10
7
|
**唯一指定**的导入方式是 import { capabilityClient } from '@lark-apaas/client-toolkit';
|
|
11
8
|
|
|
12
9
|
| 优先级 | 场景 | 调用方式 |
|
|
13
10
|
|-------|------|---------|
|
|
14
|
-
| **首选** |
|
|
15
|
-
| **首选** |
|
|
16
|
-
|
|
|
17
|
-
| **兜底** | Client 侧无法满足且单次调用可在交互边界内完成 | `CapabilityService.load(id).call()` |
|
|
11
|
+
| **首选** | 绝大多数场景 | `capabilityClient.load(id).call()` |
|
|
12
|
+
| **首选** | 流式输出场景 | `capabilityClient.load(id).callStream()` |
|
|
13
|
+
| **兜底** | Client 侧无法满足时 | `CapabilityService.load(id).call()` |
|
|
18
14
|
|
|
19
15
|
#### 什么情况下应使用 Server 侧?
|
|
20
16
|
|
|
@@ -23,7 +19,7 @@
|
|
|
23
19
|
1. **涉及敏感凭证**:调用需要服务端私密 token/secret,不适合暴露给前端
|
|
24
20
|
2. **必须后端编排**:多个插件调用之间有强事务依赖,需要后端统一编排
|
|
25
21
|
3. **触发器/定时任务场景**:没有前端上下文,只能由后端发起
|
|
26
|
-
4.
|
|
22
|
+
4. **插件结果需要持久化**:调用结果需要保存到数据库,在 Server 侧可以在同一方法中完成调用+落库,保证数据一致性
|
|
27
23
|
|
|
28
24
|
> **提示**:如果插件结果不需要存储、仅用于即时展示(如流式生成文本、发送消息),优先在前端调用。但当结果需要保存到数据库时,不要回避使用 Server 侧。
|
|
29
25
|
|
|
@@ -46,13 +42,9 @@
|
|
|
46
42
|
|
|
47
43
|
```
|
|
48
44
|
插件结果是否需要持久化到数据库?
|
|
49
|
-
├──
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
└── 是
|
|
53
|
-
├── `outputMode=stream` 且前端可承接 → 推荐方案 A:Client 侧 `callStream()` 渐进展示,成功后通过已有 CRUD 接口保存结果
|
|
54
|
-
├── 输出较小且可在交互边界内完成、且必须由后端保证一致性 → Server 侧调用并在同一方法中落库
|
|
55
|
-
└── Client 侧无法满足且输出规模大/多字段/多份/多语言/多步骤 → 方案 B:Server 侧创建任务记录,快速返回任务状态;后台调用插件并落库;前端短轮询状态/结果
|
|
45
|
+
├── 否(一次性即时展示)→ Client 侧调用(默认)
|
|
46
|
+
└── 是 → 推荐方案 A:Server 侧调用,在 Service 中调用插件并在同一方法中落库
|
|
47
|
+
备选方案 B:Client 侧调用插件 → 成功后通过已有 CRUD 接口保存结果
|
|
56
48
|
```
|
|
57
49
|
|
|
58
50
|
| 应避免的做法 | 推荐做法 |
|
|
@@ -279,11 +271,10 @@ function MultiPluginStreamExample({ recordId }: { recordId: string }) {
|
|
|
279
271
|
2. 一个 `stream` action 的真实调用结果(chunk 按 `outputSchema` 字段读取)
|
|
280
272
|
3. 调用失败时的最小日志字段齐全
|
|
281
273
|
4. 若无法执行真实调用,必须明确写明阻塞原因,禁止直接标记“开发完成”
|
|
282
|
-
5. 若用户触发 AI 生成的请求超时、连接断开或工具返回超时,必须判定为验收失败;只有触发请求快速返回、随后能通过页面或接口读到明确完成或失败终态和结果,才允许标记通过
|
|
283
274
|
|
|
284
275
|
### Server 侧调用方式(仅兜底场景)
|
|
285
276
|
|
|
286
|
-
> 以下场景适合使用 Server
|
|
277
|
+
> 以下场景适合使用 Server 侧调用,特别是涉及数据持久化时不要回避后端。
|
|
287
278
|
|
|
288
279
|
#### 1. 何时适合用 Server 侧?
|
|
289
280
|
|
|
@@ -294,7 +285,6 @@ function MultiPluginStreamExample({ recordId }: { recordId: string }) {
|
|
|
294
285
|
| 敏感凭证调用 | 凭证不能暴露给前端 | 调用需要 admin token 的 API |
|
|
295
286
|
| 强事务编排 | 多步骤需要原子性 | 创建记录 → 发通知 → 更新状态必须全成功或全回滚 |
|
|
296
287
|
| 插件结果需持久化 | 调用结果需保存到数据库 | AI 分类/摘要结果需落库、文档解析的结构化数据需入库、图片识别结果需关联业务记录、语音转文字结果需存档等 |
|
|
297
|
-
| 高耗时 AI 生成需后续查看 | 请求不能长期占用用户交互链路 | 批量、多版本、多语言、长文本、多字段结构化生成,或文件/多模态分析后再生成内容 |
|
|
298
288
|
|
|
299
289
|
#### 2. NestJS 注入方式
|
|
300
290
|
|
|
@@ -338,7 +328,6 @@ try {
|
|
|
338
328
|
|
|
339
329
|
- PluginInstance 调用在 Server 侧通常属于 **外部依赖 / side-effect**
|
|
340
330
|
- 除非业务明确要求强一致性,**默认不应阻塞主业务流程**
|
|
341
|
-
- 已选择 Server 侧承接的高耗时 AI capability 必须有可观测状态:创建任务时记录处理进度、完成终态、失败终态、输入摘要、错误信息和结果引用;触发接口只返回任务标识与当前状态,前端通过短轮询读取进度和最终结果
|
|
342
331
|
|
|
343
332
|
推荐写法:异步触发 + catch 兜底:
|
|
344
333
|
|
|
@@ -224,13 +224,14 @@ const MyComponent = () => {
|
|
|
224
224
|
|
|
225
225
|
## 用户展示与选择方案
|
|
226
226
|
|
|
227
|
-
|
|
227
|
+
用户展示 / 选择使用组件市场的妙搭业务组件,项目内默认未安装。使用前必须先安装(有 get_ui_component_info 工具时调用它,否则 `fullstack-cli component add @miaoda/<name>`)并阅读返回的 docs,Props / import 路径以 docs 为准:
|
|
228
228
|
|
|
229
|
-
|
|
229
|
+
- @miaoda/user-select 用户选择组件
|
|
230
|
+
- @miaoda/department-select 部门选择组件
|
|
231
|
+
- @miaoda/user-display 用户展示组件:展示任意用户时传 `value`(用户 ID 字符串或字符串数组);**不要**传 `users`。
|
|
232
|
+
|
|
233
|
+
列表、详情、卡片等任意位置展示一个或多个用户 ID,都使用 @miaoda/user-display,不要退回到直接渲染、拼接或 `join` 用户 ID 文本。
|
|
230
234
|
|
|
231
|
-
- UserSelect 用户选择组件(@/components/business-ui/user-select)
|
|
232
|
-
- DepartmentSelect 部门选择组件(@/components/business-ui/department-select)
|
|
233
|
-
- UserDisplay - 用户展示组件(@/components/business-ui/user-display)
|
|
234
235
|
|
|
235
236
|
## 使用注意事项
|
|
236
237
|
|
|
@@ -64,8 +64,7 @@ client/
|
|
|
64
64
|
│ ├── components/ # 可复用的 UI 组件
|
|
65
65
|
│ │ ├── ui/ # shadcn/ui 组件[Use the components for functionality, but heavily style them.]
|
|
66
66
|
│ │ │ ├── README.md # shadcn/ui 组件库使用说明 [请务必参考该文档进行组件使用]
|
|
67
|
-
│ │ ├──
|
|
68
|
-
│ │ │ ├── README.md # 用户展示,用户头像展示,部门选择展示逻辑必须使用 [请务必参考该文档进行组件使用],禁止直接使用avatar组件等方式自行实现
|
|
67
|
+
│ │ ├── miaoda/ # 组件市场装机的妙搭业务组件(初始不存在,按需安装,见「妙搭业务组件」一节)
|
|
69
68
|
│ │ ├── Layout.tsx # 布局包装器
|
|
70
69
|
│ ├── hooks/ # 自定义 React hooks
|
|
71
70
|
│ └── utils/ # 工具函数
|
|
@@ -129,7 +128,7 @@ shared/ # 前后端共享的目录
|
|
|
129
128
|
|
|
130
129
|
1. 优先使用项目已有依赖,仅在无法实现时安装新依赖
|
|
131
130
|
2. 使用前先查看 `package.json` 确保依赖已存在
|
|
132
|
-
3. **子包完整性检查**:部分库有多个子包(如 `@
|
|
131
|
+
3. **子包完整性检查**:部分库有多个子包(如 `@radix-ui/react-*` 系列每个组件都是独立子包),添加 import 后必须确认 package.json 中包含所有需要的子包
|
|
133
132
|
4. 用法不清时查看 readme,可进一步搜索或网页访问获取信息
|
|
134
133
|
|
|
135
134
|
## 文件命名约定
|
|
@@ -448,9 +447,8 @@ NestJS 自己不读 env。直连 NestJS 端口 → header 缺失 → `req.userCo
|
|
|
448
447
|
- **图表**: ReactECharts,**开发前必须调用 `/charts-skill`**
|
|
449
448
|
- **图标**: Lucide React(唯一图标库,禁止 Emoji 和其他图标库)
|
|
450
449
|
- **表格/表单/图表**: 见下方"组件 Skill 召回规则",开发前必须先调用对应 Skill
|
|
451
|
-
- **用户**:
|
|
452
|
-
-
|
|
453
|
-
- **Markdown 渲染**: `components/ui/streamdown`(内置 prose 排版)
|
|
450
|
+
- **用户**: 用户信息展示/选择必须使用组件市场的妙搭业务组件(见「妙搭业务组件」一节,先安装后使用),禁止手写选人/头像组件、禁止直接展示 userId
|
|
451
|
+
- **Markdown 渲染**: `components/ui/markdown`(react-markdown + remark-gfm,内置 prose 排版)
|
|
454
452
|
|
|
455
453
|
## API 请求
|
|
456
454
|
|
|
@@ -482,9 +480,26 @@ import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBac
|
|
|
482
480
|
| 组件 | 来源 | 用途 |
|
|
483
481
|
| --------------------------------------------------- | --------------------------------------- | --------------------------------- |
|
|
484
482
|
| Table | `@lark-apaas/client-toolkit/antd-table` | 数据表格,**先调 `/table-skill`** |
|
|
485
|
-
|
|
|
486
|
-
|
|
487
|
-
|
|
483
|
+
| Markdown | `components/ui/markdown` | Markdown 渲染(react-markdown + remark-gfm) |
|
|
484
|
+
|
|
485
|
+
### 妙搭业务组件(组件市场,强制使用)
|
|
486
|
+
|
|
487
|
+
企业用户 / 组织类组件已上架组件市场,项目内**默认未安装**。命中以下场景时禁止手写等价组件,必须先安装后使用:
|
|
488
|
+
|
|
489
|
+
| 组件 ID | 用途 |
|
|
490
|
+
|---------|------|
|
|
491
|
+
| @miaoda/user-select | 用户选择器(搜索 / 单多选 / 外部联系人)——选人、成员、指派、审批人等场景 |
|
|
492
|
+
| @miaoda/department-select | 部门选择器——组织架构场景 |
|
|
493
|
+
| @miaoda/chat-select | 群组(群聊)选择器 |
|
|
494
|
+
| @miaoda/user-display | 用户头像 + 姓名展示(传用户 ID 自动拉取资料)——任何展示用户的场景 |
|
|
495
|
+
| @miaoda/user-profile | 用户详情卡(hover 弹出完整资料) |
|
|
496
|
+
|
|
497
|
+
**强制流程**:
|
|
498
|
+
|
|
499
|
+
1. 编码前一次性安装本轮所需的全部组件:有 get_ui_component_info 工具时调用它(componentIds 传上表 ID);无该工具时执行 `fullstack-cli component add @miaoda/<name>`
|
|
500
|
+
2. Props / import 路径 / Provider 前置 100% 以安装返回的 docs 为准,禁止凭记忆猜测
|
|
501
|
+
3. 组件源码安装在 client/src/components/miaoda/,不要移动或改写
|
|
502
|
+
4. 返回 Component not found 时,告知用户该组件未上架,不要手写等价实现
|
|
488
503
|
|
|
489
504
|
### 组件 Skill 召回规则(强制执行)
|
|
490
505
|
|
|
@@ -616,7 +631,7 @@ return <h1>{data?.title || '未知标题'}</h1>;
|
|
|
616
631
|
## 图片规范
|
|
617
632
|
|
|
618
633
|
- **展示**:必须用 `@client/src/components/ui/image` 组件(非原生 `<img>`),响应式设 `sizes`,固定宽设 `width`
|
|
619
|
-
-
|
|
634
|
+
- 用户头像用市场组件 @miaoda/user-display(见「妙搭业务组件」一节)
|
|
620
635
|
|
|
621
636
|
### 图片资源优先级
|
|
622
637
|
|
|
@@ -637,15 +652,12 @@ return <h1>{data?.title || '未知标题'}</h1>;
|
|
|
637
652
|
| 验证 | zod |
|
|
638
653
|
| 工具函数 | lodash |
|
|
639
654
|
| 样式 | clsx |
|
|
640
|
-
|
|
|
641
|
-
| PDF 导出 | jspdf + html2canvas(**仅前端实现,禁止服务端实现**) |
|
|
642
|
-
| 文件上传 | react-dropzone |
|
|
643
|
-
| 二维码 | qrcode.react |
|
|
655
|
+
| 文件上传 | 原生 `<input type="file">`,用 shadcn 组件封装样式 |
|
|
644
656
|
| 用户反馈 | sonner |
|
|
645
|
-
|
|
|
646
|
-
|
|
|
647
|
-
|
|
648
|
-
|
|
657
|
+
| 数字动画 | `requestAnimationFrame` 手写,或 framer-motion 的 `animate` |
|
|
658
|
+
| Base64 | 原生 `atob`/`btoa`(Unicode 内容先经 `TextEncoder`/`TextDecoder`);服务端用 `Buffer` |
|
|
659
|
+
|
|
660
|
+
> Excel 解析、PDF 导出、二维码、拖拽、3D 场景等能力已随依赖瘦身不再预装(xlsx、jspdf、html2canvas、qrcode.react、@dnd-kit、react-dropzone、react-countup、js-base64、cobe 已从模板移除)。确有此类需求时,先在 package.json 显式添加所需依赖并安装,再编写代码。
|
|
649
661
|
|
|
650
662
|
## 滚动分页最佳实践
|
|
651
663
|
|
|
@@ -35,7 +35,6 @@ lark-cli apps --help 2>&1 | grep -q '+plugin-install' && echo "READY" || echo "M
|
|
|
35
35
|
| Client 侧非流式调用 | `capabilityClient.load(id).call(actionKey, input)` |
|
|
36
36
|
| Client 侧流式调用 | `capabilityClient.load(id).callStream(actionKey, input)` |
|
|
37
37
|
| Server 侧调用(仅兜底) | `capabilityService.load(id).call(actionKey, input)` |
|
|
38
|
-
| 长耗时 AI 结果 | 大体量/多字段/多份/多语言/文件或多模态串联等结构信号命中时,优先前端 `callStream` 渐进展示;需保存则流式结束后复用 CRUD 落库;仅在 Client 侧无法满足时使用后端任务记录 + 状态查询 + 结果读取;禁止单个 HTTP 请求等待完整结果后才返回 |
|
|
39
38
|
| capabilityClient 导入 | `import { capabilityClient } from '@lark-apaas/client-toolkit'` |
|
|
40
39
|
| CapabilityService 导入 | `import { CapabilityService } from '@lark-apaas/fullstack-nestjs-core';` |
|
|
41
40
|
| CapabilityService 注入 | `@Inject() private readonly capabilityService: CapabilityService` |
|
|
@@ -517,9 +516,7 @@ npx @lark-apaas/miaoda-cli plugin list --id <instance_id>
|
|
|
517
516
|
2. 结果供后续功能消费
|
|
518
517
|
3. 用户再次访问时需要看到结果
|
|
519
518
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
**高耗时 AI capability 闸门**:若插件输出定义或功能设计显示输出规模大、输出字段多、需要多份结果、多语言/长文本、文件或多模态输入后继续生成、多个 capability 串联、或结果需后续查看/落库,禁止把完整生成放进一个同步 HTTP 请求等待;能由前端承接时优先 `callStream` 渐进展示,需保存则流式结束后复用 CRUD 落库;仅在 Client 侧无法满足时后端创建任务记录后快速返回、由前端短轮询状态/结果,或拆成多个独立小调用。
|
|
519
|
+
**推荐**:Server 侧 Service 调用插件 + 同一方法落库。**备选**:Client 侧调用 → 流式结束后调已有 CRUD 接口保存。
|
|
523
520
|
|
|
524
521
|
### 生成代码
|
|
525
522
|
|
|
@@ -538,8 +535,7 @@ npx @lark-apaas/miaoda-cli plugin list --id <instance_id>
|
|
|
538
535
|
|
|
539
536
|
1. **禁止静默吞异常**:每个 `catch` 块必须向用户展示错误或触发补偿
|
|
540
537
|
2. **异步操作必须有终态**:DB 中维护状态(pending → success / failed)
|
|
541
|
-
3.
|
|
542
|
-
4. **插件失败必须有补偿**:至少记录到待处理列表或提示用户重试
|
|
538
|
+
3. **插件失败必须有补偿**:至少记录到待处理列表或提示用户重试
|
|
543
539
|
|
|
544
540
|
## 缓存与幂等性
|
|
545
541
|
|
package/steering/nestjs-react-fullstack/skills_local/plugin-guide/references/plugin-coding-guide.md
CHANGED
|
@@ -2,19 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
### 核心原则:根据场景选择调用侧
|
|
4
4
|
|
|
5
|
-
**默认优先在 Client 侧调用 capabilityClient
|
|
6
|
-
|
|
7
|
-
**高耗时 AI capability 先判调用形态**:只要输出规模大、输出字段多、需要多份结果、多语言/长文本、文件或多模态输入后继续生成、多个 capability 串联、或结果需后续查看/落库,就不得把完整 AI 结果塞进一个同步 HTTP 请求等待。能由前端承接时,优先用前端 `callStream` 渐进展示,并在流式结束后按需复用已有 CRUD 接口保存结果;只有 Client 侧无法满足(触发器/敏感凭证/强事务/必须由后端保证落库一致性等)时,才采用后端任务记录 + 状态/结果查询,避免把后台任务作为默认方案。
|
|
8
|
-
|
|
5
|
+
**默认优先在 Client 侧调用 capabilityClient;但当插件结果需要持久化到数据库时,应考虑在 Server 侧调用或确保前端调用后及时通过后端接口保存。**
|
|
9
6
|
**严禁** import { capabilityClient } from '@lark-apaas/client-capability'。
|
|
10
7
|
**唯一指定**的导入方式是 import { capabilityClient } from '@lark-apaas/client-toolkit';
|
|
11
8
|
|
|
12
9
|
| 优先级 | 场景 | 调用方式 |
|
|
13
10
|
|-------|------|---------|
|
|
14
|
-
| **首选** |
|
|
15
|
-
| **首选** |
|
|
16
|
-
|
|
|
17
|
-
| **兜底** | Client 侧无法满足且单次调用可在交互边界内完成 | `CapabilityService.load(id).call()` |
|
|
11
|
+
| **首选** | 绝大多数场景 | `capabilityClient.load(id).call()` |
|
|
12
|
+
| **首选** | 流式输出场景 | `capabilityClient.load(id).callStream()` |
|
|
13
|
+
| **兜底** | Client 侧无法满足时 | `CapabilityService.load(id).call()` |
|
|
18
14
|
|
|
19
15
|
#### 什么情况下应使用 Server 侧?
|
|
20
16
|
|
|
@@ -23,7 +19,7 @@
|
|
|
23
19
|
1. **涉及敏感凭证**:调用需要服务端私密 token/secret,不适合暴露给前端
|
|
24
20
|
2. **必须后端编排**:多个插件调用之间有强事务依赖,需要后端统一编排
|
|
25
21
|
3. **触发器/定时任务场景**:没有前端上下文,只能由后端发起
|
|
26
|
-
4.
|
|
22
|
+
4. **插件结果需要持久化**:调用结果需要保存到数据库,在 Server 侧可以在同一方法中完成调用+落库,保证数据一致性
|
|
27
23
|
|
|
28
24
|
> **提示**:如果插件结果不需要存储、仅用于即时展示(如流式生成文本、发送消息),优先在前端调用。但当结果需要保存到数据库时,不要回避使用 Server 侧。
|
|
29
25
|
|
|
@@ -35,13 +31,9 @@
|
|
|
35
31
|
|
|
36
32
|
```
|
|
37
33
|
插件结果是否需要持久化到数据库?
|
|
38
|
-
├── 否
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
└── 是
|
|
42
|
-
├── `outputMode=stream` 且前端可承接 → 推荐方案 A:Client 侧 `callStream()` 渐进展示,成功后通过已有 CRUD 接口保存结果
|
|
43
|
-
├── 输出较小且可在交互边界内完成、且必须由后端保证一致性 → Server 侧调用并在同一方法中落库
|
|
44
|
-
└── Client 侧无法满足且输出规模大/多字段/多份/多语言/多步骤 → 方案 B:Server 侧创建任务记录,快速返回任务状态;后台调用插件并落库;前端短轮询状态/结果
|
|
34
|
+
├── 否 → Client 侧调用(默认)
|
|
35
|
+
└── 是 → 推荐方案 A:Server 侧调用,在 Service 中调用插件并在同一方法中落库
|
|
36
|
+
备选方案 B:Client 侧调用插件 → 成功后通过已有 CRUD 接口保存结果
|
|
45
37
|
```
|
|
46
38
|
|
|
47
39
|
| 应避免的做法 | 推荐做法 |
|
|
@@ -241,11 +233,10 @@ function MultiPluginStreamExample() {
|
|
|
241
233
|
2. 一个 `stream` action 的真实调用结果(chunk 按 `outputSchema` 字段读取)
|
|
242
234
|
3. 调用失败时的最小日志字段齐全
|
|
243
235
|
4. 若无法执行真实调用,必须明确写明阻塞原因,禁止直接标记"开发完成"
|
|
244
|
-
5. 若用户触发 AI 生成的请求超时、连接断开或工具返回超时,必须判定为验收失败;只有触发请求快速返回、随后能通过页面或接口读到明确完成或失败终态和结果,才允许标记通过
|
|
245
236
|
|
|
246
237
|
### Server 侧调用方式(仅兜底场景)
|
|
247
238
|
|
|
248
|
-
> 以下场景适合使用 Server
|
|
239
|
+
> 以下场景适合使用 Server 侧调用,特别是涉及数据持久化时不要回避后端。
|
|
249
240
|
|
|
250
241
|
#### 1. 何时适合用 Server 侧?
|
|
251
242
|
|
|
@@ -256,7 +247,6 @@ function MultiPluginStreamExample() {
|
|
|
256
247
|
| 敏感凭证调用 | 凭证不能暴露给前端 | 调用需要 admin token 的 API |
|
|
257
248
|
| 强事务编排 | 多步骤需要原子性 | 创建记录 → 发通知 → 更新状态必须全成功或全回滚 |
|
|
258
249
|
| 插件结果需持久化 | 调用结果需保存到数据库 | AI 分类/摘要结果需落库、文档解析的结构化数据需入库、图片识别结果需关联业务记录、语音转文字结果需存档等 |
|
|
259
|
-
| 高耗时 AI 生成需后续查看 | 请求不能长期占用用户交互链路 | 批量、多版本、多语言、长文本、多字段结构化生成,或文件/多模态分析后再生成内容 |
|
|
260
250
|
|
|
261
251
|
#### 2. NestJS 注入方式
|
|
262
252
|
|
|
@@ -300,7 +290,6 @@ try {
|
|
|
300
290
|
|
|
301
291
|
- PluginInstance 调用在 Server 侧通常属于 **外部依赖 / side-effect**
|
|
302
292
|
- 除非业务明确要求强一致性,**默认不应阻塞主业务流程**
|
|
303
|
-
- 已选择 Server 侧承接的高耗时 AI capability 必须有可观测状态:创建任务时记录处理进度、完成终态、失败终态、输入摘要、错误信息和结果引用;触发接口只返回任务标识与当前状态,前端通过短轮询读取进度和最终结果
|
|
304
293
|
|
|
305
294
|
推荐写法:异步触发 + catch 兜底:
|
|
306
295
|
|