@lark-base-open/base-database-cli 1.1.0 → 1.3.7

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.
Files changed (222) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/LICENSE +6 -0
  3. package/README.md +55 -31
  4. package/dist/ai-functions.d.ts +42 -0
  5. package/dist/ai-functions.d.ts.map +1 -0
  6. package/dist/ai-functions.js +327 -0
  7. package/dist/ai-functions.js.map +1 -0
  8. package/dist/archive.d.ts +21 -0
  9. package/dist/archive.d.ts.map +1 -1
  10. package/dist/archive.js +65 -10
  11. package/dist/archive.js.map +1 -1
  12. package/dist/auth.d.ts +8 -1
  13. package/dist/auth.d.ts.map +1 -1
  14. package/dist/auth.js +60 -6
  15. package/dist/auth.js.map +1 -1
  16. package/dist/check.d.ts +74 -0
  17. package/dist/check.d.ts.map +1 -0
  18. package/dist/check.js +283 -0
  19. package/dist/check.js.map +1 -0
  20. package/dist/dependency-policy.d.ts +10 -0
  21. package/dist/dependency-policy.d.ts.map +1 -0
  22. package/dist/dependency-policy.js +162 -0
  23. package/dist/dependency-policy.js.map +1 -0
  24. package/dist/deploy.d.ts +1 -0
  25. package/dist/deploy.d.ts.map +1 -1
  26. package/dist/deploy.js +122 -175
  27. package/dist/deploy.js.map +1 -1
  28. package/dist/environment-variables.d.ts +52 -0
  29. package/dist/environment-variables.d.ts.map +1 -0
  30. package/dist/environment-variables.js +563 -0
  31. package/dist/environment-variables.js.map +1 -0
  32. package/dist/environment.d.ts +1 -1
  33. package/dist/environment.js +1 -1
  34. package/dist/environment.js.map +1 -1
  35. package/dist/function-input-types.d.ts +10 -0
  36. package/dist/function-input-types.d.ts.map +1 -0
  37. package/dist/function-input-types.js +160 -0
  38. package/dist/function-input-types.js.map +1 -0
  39. package/dist/function-schema.d.ts.map +1 -1
  40. package/dist/function-schema.js +89 -36
  41. package/dist/function-schema.js.map +1 -1
  42. package/dist/init-field-retry.d.ts +17 -0
  43. package/dist/init-field-retry.d.ts.map +1 -0
  44. package/dist/init-field-retry.js +127 -0
  45. package/dist/init-field-retry.js.map +1 -0
  46. package/dist/init-journal.d.ts +98 -0
  47. package/dist/init-journal.d.ts.map +1 -0
  48. package/dist/init-journal.js +492 -0
  49. package/dist/init-journal.js.map +1 -0
  50. package/dist/init-schema.d.ts +8 -0
  51. package/dist/init-schema.d.ts.map +1 -0
  52. package/dist/init-schema.js +423 -0
  53. package/dist/init-schema.js.map +1 -0
  54. package/dist/init.d.ts +14 -0
  55. package/dist/init.d.ts.map +1 -1
  56. package/dist/init.js +886 -243
  57. package/dist/init.js.map +1 -1
  58. package/dist/lark-cli-error.d.ts +46 -0
  59. package/dist/lark-cli-error.d.ts.map +1 -0
  60. package/dist/lark-cli-error.js +171 -0
  61. package/dist/lark-cli-error.js.map +1 -0
  62. package/dist/lark-cli.d.ts +2 -1
  63. package/dist/lark-cli.d.ts.map +1 -1
  64. package/dist/lark-cli.js +292 -29
  65. package/dist/lark-cli.js.map +1 -1
  66. package/dist/mutation-schema.d.ts +146 -0
  67. package/dist/mutation-schema.d.ts.map +1 -0
  68. package/dist/mutation-schema.js +153 -0
  69. package/dist/mutation-schema.js.map +1 -0
  70. package/dist/mutation.d.ts +10 -0
  71. package/dist/mutation.d.ts.map +1 -0
  72. package/dist/mutation.js +131 -0
  73. package/dist/mutation.js.map +1 -0
  74. package/dist/npm.d.ts +1 -0
  75. package/dist/npm.d.ts.map +1 -1
  76. package/dist/npm.js +2 -2
  77. package/dist/npm.js.map +1 -1
  78. package/dist/preview-receipt.d.ts.map +1 -1
  79. package/dist/preview-receipt.js +15 -5
  80. package/dist/preview-receipt.js.map +1 -1
  81. package/dist/process.d.ts +3 -1
  82. package/dist/process.d.ts.map +1 -1
  83. package/dist/process.js +16 -4
  84. package/dist/process.js.map +1 -1
  85. package/dist/program.d.ts.map +1 -1
  86. package/dist/program.js +241 -42
  87. package/dist/program.js.map +1 -1
  88. package/dist/project-build.d.ts +25 -0
  89. package/dist/project-build.d.ts.map +1 -0
  90. package/dist/project-build.js +229 -0
  91. package/dist/project-build.js.map +1 -0
  92. package/dist/project-context.d.ts +344 -0
  93. package/dist/project-context.d.ts.map +1 -0
  94. package/dist/project-context.js +485 -0
  95. package/dist/project-context.js.map +1 -0
  96. package/dist/project-layout.d.ts +5 -0
  97. package/dist/project-layout.d.ts.map +1 -0
  98. package/dist/project-layout.js +24 -0
  99. package/dist/project-layout.js.map +1 -0
  100. package/dist/public-sql-error.d.ts +25 -0
  101. package/dist/public-sql-error.d.ts.map +1 -0
  102. package/dist/public-sql-error.js +81 -0
  103. package/dist/public-sql-error.js.map +1 -0
  104. package/dist/pull.d.ts.map +1 -1
  105. package/dist/pull.js +54 -15
  106. package/dist/pull.js.map +1 -1
  107. package/dist/site-readme.d.ts +3 -0
  108. package/dist/site-readme.d.ts.map +1 -0
  109. package/dist/site-readme.js +83 -0
  110. package/dist/site-readme.js.map +1 -0
  111. package/dist/workspace-fingerprint.d.ts +21 -1
  112. package/dist/workspace-fingerprint.d.ts.map +1 -1
  113. package/dist/workspace-fingerprint.js +81 -2
  114. package/dist/workspace-fingerprint.js.map +1 -1
  115. package/package.json +2 -1
  116. package/skills/lark-base-database/SKILL.md +70 -53
  117. package/skills/lark-base-database/references/ai-function.md +471 -0
  118. package/skills/lark-base-database/references/base-capability-routing.md +3 -3
  119. package/skills/lark-base-database/references/database-function-limitations.md +46 -0
  120. package/skills/lark-base-database/references/database-function.md +349 -0
  121. package/skills/lark-base-database/references/design/components/actions-fields.md +16 -0
  122. package/skills/lark-base-database/references/design/components/code/controls.css +122 -0
  123. package/skills/lark-base-database/references/design/components/code/controls.tsx +73 -0
  124. package/skills/lark-base-database/references/design/components/code/date-picker.tsx +42 -0
  125. package/skills/lark-base-database/references/design/components/code/floating.tsx +41 -0
  126. package/skills/lark-base-database/references/design/components/code/overlays.tsx +19 -0
  127. package/skills/lark-base-database/references/design/components/code/select.tsx +34 -0
  128. package/skills/lark-base-database/references/design/components/code/styles.d.ts +1 -0
  129. package/skills/lark-base-database/references/design/components/code/theme.css +49 -0
  130. package/skills/lark-base-database/references/design/components/code/usage.tsx +38 -0
  131. package/skills/lark-base-database/references/design/components/date-time-number.md +9 -0
  132. package/skills/lark-base-database/references/design/components/feedback-navigation.md +9 -0
  133. package/skills/lark-base-database/references/design/components/index.md +18 -0
  134. package/skills/lark-base-database/references/design/components/overlays.md +9 -0
  135. package/skills/lark-base-database/references/design/components/patterns/flat-business.md +7 -0
  136. package/skills/lark-base-database/references/design/components/selection.md +24 -0
  137. package/skills/lark-base-database/references/design/components.md +91 -0
  138. package/skills/lark-base-database/references/design/composition.md +90 -0
  139. package/skills/lark-base-database/references/design/control-recipes.md +107 -0
  140. package/skills/lark-base-database/references/design/examples.md +172 -0
  141. package/skills/lark-base-database/references/design/foundations.md +126 -0
  142. package/skills/lark-base-database/references/design/review.md +82 -0
  143. package/skills/lark-base-database/references/design/scenes/analysis-report.md +66 -0
  144. package/skills/lark-base-database/references/design/scenes/brand-showcase.md +70 -0
  145. package/skills/lark-base-database/references/design/scenes/business-operations.md +85 -0
  146. package/skills/lark-base-database/references/design/scenes/campaign-report.md +66 -0
  147. package/skills/lark-base-database/references/design/scenes/dashboard.md +77 -0
  148. package/skills/lark-base-database/references/design/scenes/festive-campaign.md +69 -0
  149. package/skills/lark-base-database/references/design/theme.md +27 -0
  150. package/skills/lark-base-database/references/design/typography.md +56 -0
  151. package/skills/lark-base-database/references/development.md +165 -32
  152. package/skills/lark-base-database/references/environment-variables.md +55 -0
  153. package/skills/lark-base-database/references/faas.md +41 -48
  154. package/skills/lark-base-database/references/file-transfer.md +25 -25
  155. package/skills/lark-base-database/references/frontend-design-guide.md +33 -77
  156. package/skills/lark-base-database/references/mutations.md +295 -0
  157. package/skills/lark-base-database/references/permission-design.md +24 -24
  158. package/skills/lark-base-database/references/release.md +54 -27
  159. package/skills/lark-base-database/references/style-guide.md +10 -67
  160. package/skills/lark-base-database/references/verification.md +17 -13
  161. package/skills/lark-base-database/references/workspace.md +71 -22
  162. package/templates/database-starter/README.md +34 -4
  163. package/templates/database-starter/database-functions/README.md +8 -0
  164. package/templates/database-starter/mutations/README.md +19 -0
  165. package/templates/database-starter/page/README.md +25 -19
  166. package/templates/database-starter/page/package-lock.json +8 -16
  167. package/templates/database-starter/page/package.json +2 -3
  168. package/templates/database-starter/page/src/App.tsx +23 -3
  169. package/templates/database-starter/page/src/components/UserSelectorEditor/index.tsx +32 -4
  170. package/templates/database-starter/page/src/components/UserSelectorPanel/index.tsx +104 -34
  171. package/templates/database-starter/page/src/components/UserSelectorPanel/user-search.test.ts +55 -11
  172. package/templates/database-starter/page/src/components/UserSelectorPanel/user-search.ts +68 -25
  173. package/templates/database-starter/page/src/components/database-runtime-ready.tsx +1 -1
  174. package/templates/database-starter/page/src/components/database-user-menu/index.tsx +4 -7
  175. package/templates/database-starter/page/src/components/feedback/page-state.tsx +40 -5
  176. package/templates/database-starter/page/src/components/floating-button/assets/edit-active.svg +3 -0
  177. package/templates/database-starter/page/src/components/floating-button/assets/edit-default.svg +3 -0
  178. package/templates/database-starter/page/src/components/floating-button/index.tsx +75 -112
  179. package/templates/database-starter/page/src/components/floating-button/utils/project-meta.test.ts +107 -37
  180. package/templates/database-starter/page/src/components/floating-button/utils/project-meta.ts +13 -50
  181. package/templates/database-starter/page/src/components/ui-language.test.tsx +101 -0
  182. package/templates/database-starter/page/src/examples/ai-function/ImageTaskOperation.tsx +202 -0
  183. package/templates/database-starter/page/src/examples/ai-function/README.md +14 -0
  184. package/templates/database-starter/page/src/examples/ai-function/TextStreamOperation.tsx +184 -0
  185. package/templates/database-starter/page/src/examples/ai-function/ai-copy.ts +173 -0
  186. package/templates/database-starter/page/src/examples/ai-function/ai-delta.test.ts +30 -0
  187. package/templates/database-starter/page/src/examples/ai-function/ai-delta.ts +26 -0
  188. package/templates/database-starter/page/src/examples/ai-function/ai-language.test.tsx +86 -0
  189. package/templates/database-starter/page/src/examples/ai-function/ai-media-panel.test.ts +201 -0
  190. package/templates/database-starter/page/src/examples/ai-function/ai-media-panel.tsx +382 -0
  191. package/templates/database-starter/page/src/lib/attachment-image.tsx +144 -0
  192. package/templates/database-starter/page/src/lib/database-function-inputs.generated.ts +4 -0
  193. package/templates/database-starter/page/src/lib/database-runtime-attachment.test.ts +180 -76
  194. package/templates/database-starter/page/src/lib/database-runtime.test.ts +130 -10
  195. package/templates/database-starter/page/src/lib/database-runtime.ts +484 -51
  196. package/templates/database-starter/page/src/lib/date-time.ts +61 -0
  197. package/templates/database-starter/page/src/lib/ui-language.ts +86 -0
  198. package/templates/database-starter/page/src/main.tsx +1 -1
  199. package/templates/database-starter/page/tsconfig.json +2 -2
  200. package/templates/database-starter/page/vite.config.ts +1 -2
  201. package/templates/database-starter/prettierignore +1 -1
  202. package/templates/database-starter/server-functions/README.md +2 -2
  203. package/templates/database-starter/server-functions/package-lock.json +4 -4
  204. package/templates/database-starter/server-functions/package.json +1 -1
  205. package/dist/agent-guide.d.ts +0 -19
  206. package/dist/agent-guide.d.ts.map +0 -1
  207. package/dist/agent-guide.js +0 -31
  208. package/dist/agent-guide.js.map +0 -1
  209. package/dist/database-skill.d.ts +0 -24
  210. package/dist/database-skill.d.ts.map +0 -1
  211. package/dist/database-skill.js +0 -151
  212. package/dist/database-skill.js.map +0 -1
  213. package/skills/lark-base-database/references/base-sql-unsupported.md +0 -42
  214. package/skills/lark-base-database/references/database-capability-skill.md +0 -40
  215. package/skills/lark-base-database/references/sql-function.md +0 -289
  216. package/templates/database-capability/SKILL.md.tmpl +0 -58
  217. package/templates/database-starter/page/src/components/floating-button/chatbot-entry.test.ts +0 -23
  218. package/templates/database-starter/page/src/components/floating-button/chatbot-entry.tsx +0 -873
  219. package/templates/database-starter/page/src/components/floating-button/local-chat-panel.test.ts +0 -25
  220. package/templates/database-starter/page/src/components/floating-button/local-chat-panel.tsx +0 -197
  221. package/templates/database-starter/page/src/components/floating-button/utils/env.ts +0 -18
  222. package/templates/database-starter/sql/README.md +0 -8
@@ -0,0 +1,86 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ const locale = vi.hoisted(() => ({ language: 'en' as 'en' | 'zh' | 'ja' }))
5
+ vi.mock('../../lib/ui-language', () => ({
6
+ useUiLanguage: () => locale.language,
7
+ }))
8
+
9
+ import { formatAiTaskStatus } from './ai-copy'
10
+ import { TextStreamOperation } from './TextStreamOperation'
11
+ import { ImageTaskOperation } from './ImageTaskOperation'
12
+ import { AIImagePanel, AIVideoPanel } from './ai-media-panel'
13
+
14
+ afterEach(() => {
15
+ locale.language = 'en'
16
+ })
17
+
18
+ describe('AI Function example language', () => {
19
+ it.each([
20
+ {
21
+ language: 'en' as const,
22
+ textTitle: 'Generate text',
23
+ question: 'Enter your question',
24
+ imageTitle: 'Generate an image',
25
+ imagePrompt: 'Describe the image you want to create',
26
+ videoTitle: 'Generate a video',
27
+ videoPrompt: 'Describe the video you want to create',
28
+ status: 'Task status: Running (45%)',
29
+ },
30
+ {
31
+ language: 'zh' as const,
32
+ textTitle: '生成文本',
33
+ question: '请输入问题',
34
+ imageTitle: '生成图片',
35
+ imagePrompt: '描述你想生成的图片',
36
+ videoTitle: '生成视频',
37
+ videoPrompt: '描述你想生成的视频',
38
+ status: '任务状态:运行中 (45%)',
39
+ },
40
+ {
41
+ language: 'ja' as const,
42
+ textTitle: 'テキストを生成',
43
+ question: '質問を入力してください',
44
+ imageTitle: '画像を生成',
45
+ imagePrompt: '生成したい画像を説明してください',
46
+ videoTitle: '動画を生成',
47
+ videoPrompt: '生成したい動画を説明してください',
48
+ status: 'タスクの状態:実行中 (45%)',
49
+ },
50
+ ])('renders built-in copy in $language', (expected) => {
51
+ locale.language = expected.language
52
+ const text = renderToStaticMarkup(
53
+ <TextStreamOperation functionKey="text" />,
54
+ )
55
+ const image = renderToStaticMarkup(
56
+ <ImageTaskOperation functionKey="image" />,
57
+ )
58
+ const imagePanel = renderToStaticMarkup(
59
+ <AIImagePanel functionKey="image" />,
60
+ )
61
+ const videoPanel = renderToStaticMarkup(
62
+ <AIVideoPanel functionKey="video" />,
63
+ )
64
+
65
+ expect(text).toContain(expected.textTitle)
66
+ expect(text).toContain(expected.question)
67
+ expect(image).toContain(expected.imageTitle)
68
+ expect(image).toContain(expected.imagePrompt)
69
+ expect(imagePanel).toContain(expected.imageTitle)
70
+ expect(imagePanel).toContain(expected.imagePrompt)
71
+ expect(videoPanel).toContain(expected.videoTitle)
72
+ expect(videoPanel).toContain(expected.videoPrompt)
73
+ expect(formatAiTaskStatus('running', 45, expected.language)).toBe(
74
+ expected.status,
75
+ )
76
+ })
77
+
78
+ it('preserves a title supplied by the business Page', () => {
79
+ locale.language = 'ja'
80
+ expect(
81
+ renderToStaticMarkup(
82
+ <AIVideoPanel functionKey="video" title="Project video" />,
83
+ ),
84
+ ).toContain('Project video')
85
+ })
86
+ })
@@ -0,0 +1,201 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ activateMediaTaskRequestTracker,
4
+ createMediaTaskRequestTracker,
5
+ pollMediaTaskUntilTerminal,
6
+ type MediaTaskPollUpdate,
7
+ } from './ai-media-panel'
8
+ import type { SiteAIMediaTask } from '../../lib/database-runtime'
9
+
10
+ function videoTask(taskId: string, status: string): SiteAIMediaTask {
11
+ return {
12
+ type: 'video_task',
13
+ taskId,
14
+ status: status as SiteAIMediaTask['status'],
15
+ requestId: `req-${taskId}`,
16
+ }
17
+ }
18
+
19
+ function nowClock(start: number) {
20
+ let time = start
21
+ return {
22
+ advance: (ms: number) => (time += ms),
23
+ now: () => time,
24
+ }
25
+ }
26
+
27
+ describe('pollMediaTaskUntilTerminal', () => {
28
+ it('drops updates once isCurrent becomes false (旧任务晚到)', async () => {
29
+ const updates: MediaTaskPollUpdate[] = []
30
+ let current = true
31
+ const clock = nowClock(0)
32
+ const fetchTask = vi
33
+ .fn<() => Promise<SiteAIMediaTask>>()
34
+ .mockImplementationOnce(async () => {
35
+ current = false
36
+ return videoTask('t1', 'succeeded')
37
+ })
38
+
39
+ await pollMediaTaskUntilTerminal({
40
+ fetchTask,
41
+ isCurrent: () => current,
42
+ timing: {
43
+ deadline: 60_000,
44
+ intervalMs: 10,
45
+ now: clock.now,
46
+ sleep: async (ms) => {
47
+ clock.advance(ms)
48
+ },
49
+ },
50
+ onUpdate: (update) => updates.push(update),
51
+ timeoutMessage: 'timeout',
52
+ failureMessage: 'failed',
53
+ })
54
+
55
+ expect(fetchTask).toHaveBeenCalledTimes(1)
56
+ expect(updates).toEqual([])
57
+ })
58
+
59
+ it('emits a timeout finish and stops when the deadline passes', async () => {
60
+ const updates: MediaTaskPollUpdate[] = []
61
+ const clock = nowClock(0)
62
+ const fetchTask = vi
63
+ .fn<() => Promise<SiteAIMediaTask>>()
64
+ .mockResolvedValue(videoTask('t1', 'queued'))
65
+
66
+ await pollMediaTaskUntilTerminal({
67
+ fetchTask,
68
+ isCurrent: () => true,
69
+ timing: {
70
+ deadline: 25,
71
+ intervalMs: 10,
72
+ now: clock.now,
73
+ sleep: async (ms) => {
74
+ clock.advance(ms)
75
+ },
76
+ },
77
+ onUpdate: (update) => updates.push(update),
78
+ timeoutMessage: '视频不可用(轮询超时)',
79
+ failureMessage: '视频不可用',
80
+ })
81
+
82
+ expect(updates[updates.length - 1]).toMatchObject({
83
+ finished: true,
84
+ error: '视频不可用(轮询超时)',
85
+ })
86
+ })
87
+
88
+ it('stops on succeeded/failed/cancelled terminal states', async () => {
89
+ for (const terminal of ['succeeded', 'failed', 'cancelled']) {
90
+ const updates: MediaTaskPollUpdate[] = []
91
+ const clock = nowClock(0)
92
+ const fetchTask = vi
93
+ .fn<() => Promise<SiteAIMediaTask>>()
94
+ .mockResolvedValueOnce(videoTask('t1', 'queued'))
95
+ .mockResolvedValueOnce(videoTask('t1', terminal))
96
+
97
+ await pollMediaTaskUntilTerminal({
98
+ fetchTask,
99
+ isCurrent: () => true,
100
+ timing: {
101
+ deadline: 10_000,
102
+ intervalMs: 10,
103
+ now: clock.now,
104
+ sleep: async (ms) => {
105
+ clock.advance(ms)
106
+ },
107
+ },
108
+ onUpdate: (update) => updates.push(update),
109
+ timeoutMessage: 'timeout',
110
+ failureMessage: '视频不可用',
111
+ })
112
+
113
+ expect(fetchTask).toHaveBeenCalledTimes(2)
114
+ const last = updates[updates.length - 1]
115
+ expect(last).toMatchObject({ finished: true })
116
+ if (terminal === 'succeeded') {
117
+ expect(last?.error).toBeUndefined()
118
+ } else {
119
+ expect(last?.error).toBeTruthy()
120
+ }
121
+ }
122
+ })
123
+
124
+ it('surfaces fetch errors and stops polling', async () => {
125
+ const updates: MediaTaskPollUpdate[] = []
126
+ const clock = nowClock(0)
127
+ const fetchTask = vi
128
+ .fn<() => Promise<SiteAIMediaTask>>()
129
+ .mockRejectedValueOnce(new Error('network broken'))
130
+
131
+ await pollMediaTaskUntilTerminal({
132
+ fetchTask,
133
+ isCurrent: () => true,
134
+ timing: {
135
+ deadline: 10_000,
136
+ intervalMs: 10,
137
+ now: clock.now,
138
+ sleep: async (ms) => {
139
+ clock.advance(ms)
140
+ },
141
+ },
142
+ onUpdate: (update) => updates.push(update),
143
+ timeoutMessage: 'timeout',
144
+ failureMessage: '视频不可用',
145
+ })
146
+
147
+ expect(updates).toEqual([{ finished: true, error: 'network broken' }])
148
+ })
149
+
150
+ it('preserves a server-provided task error', async () => {
151
+ const updates: MediaTaskPollUpdate[] = []
152
+ const failed = {
153
+ ...videoTask('t1', 'failed'),
154
+ errMessage: 'Server error: quota exceeded',
155
+ }
156
+
157
+ await pollMediaTaskUntilTerminal({
158
+ fetchTask: async () => failed,
159
+ isCurrent: () => true,
160
+ timing: {
161
+ deadline: 10_000,
162
+ intervalMs: 10,
163
+ now: () => 0,
164
+ sleep: async () => {},
165
+ },
166
+ onUpdate: (update) => updates.push(update),
167
+ timeoutMessage: 'Localized timeout',
168
+ failureMessage: 'Localized fallback',
169
+ })
170
+
171
+ expect(updates).toEqual([
172
+ { finished: true, error: 'Server error: quota exceeded', task: failed },
173
+ ])
174
+ })
175
+ })
176
+
177
+ describe('media task request lifecycle', () => {
178
+ it('invalidates the previous request when a new request begins', () => {
179
+ const tracker = createMediaTaskRequestTracker()
180
+ const cleanup = activateMediaTaskRequestTracker(tracker)
181
+ const firstRequestId = tracker.begin()
182
+ const secondRequestId = tracker.begin()
183
+
184
+ expect(tracker.isCurrent(firstRequestId)).toBe(false)
185
+ expect(tracker.isCurrent(secondRequestId)).toBe(true)
186
+ cleanup()
187
+ })
188
+
189
+ it('reactivates after the StrictMode effect replay and invalidates on cleanup', () => {
190
+ const tracker = createMediaTaskRequestTracker()
191
+
192
+ const firstCleanup = activateMediaTaskRequestTracker(tracker)
193
+ firstCleanup()
194
+ const secondCleanup = activateMediaTaskRequestTracker(tracker)
195
+ const requestId = tracker.begin()
196
+
197
+ expect(tracker.isCurrent(requestId)).toBe(true)
198
+ secondCleanup()
199
+ expect(tracker.isCurrent(requestId)).toBe(false)
200
+ })
201
+ })
@@ -0,0 +1,382 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { Button } from '../../components/ui/button'
3
+ import {
4
+ createSiteAIFunctionTask,
5
+ getSiteAIFunctionTask,
6
+ hasRunnableMediaTask,
7
+ type SiteAIMediaTask,
8
+ } from '../../lib/database-runtime'
9
+ import { useUiLanguage } from '../../lib/ui-language'
10
+ import { AI_FUNCTION_COPY, formatAiTaskStatus } from './ai-copy'
11
+
12
+ // AI Function 媒体示例:文生图和文生视频统一使用任务提交 + 轮询。
13
+ // functionKey 指向对应能力 AI Function;页面提交 prompt,model/尺寸/时长由配置声明。
14
+
15
+ export interface AIImagePanelProps {
16
+ functionKey: string
17
+ referenceImages?: string[]
18
+ title?: string
19
+ pollIntervalMs?: number
20
+ pollDeadlineMs?: number
21
+ }
22
+
23
+ export interface AIVideoPanelProps {
24
+ functionKey: string
25
+ title?: string
26
+ // pollIntervalMs 轮询节奏,默认 3s;服务端也有轮询上限。
27
+ pollIntervalMs?: number
28
+ // pollDeadlineMs 单次任务总轮询时限,超过后放弃并结束 loading。默认 5 分钟。
29
+ pollDeadlineMs?: number
30
+ }
31
+
32
+ const DEFAULT_POLL_INTERVAL_MS = 3000
33
+ const DEFAULT_POLL_DEADLINE_MS = 5 * 60 * 1000
34
+
35
+ export interface MediaTaskRequestTracker {
36
+ activate: () => void
37
+ begin: () => number
38
+ deactivate: () => void
39
+ isCurrent: (requestId: number) => boolean
40
+ }
41
+
42
+ export function createMediaTaskRequestTracker(): MediaTaskRequestTracker {
43
+ let active = false
44
+ let sequence = 0
45
+ return {
46
+ activate: () => {
47
+ active = true
48
+ },
49
+ begin: () => {
50
+ sequence += 1
51
+ return sequence
52
+ },
53
+ deactivate: () => {
54
+ active = false
55
+ sequence += 1
56
+ },
57
+ isCurrent: (requestId) => active && requestId === sequence,
58
+ }
59
+ }
60
+
61
+ export function activateMediaTaskRequestTracker(
62
+ tracker: MediaTaskRequestTracker,
63
+ ): () => void {
64
+ tracker.activate()
65
+ return () => tracker.deactivate()
66
+ }
67
+
68
+ export interface MediaTaskPollUpdate {
69
+ error?: string
70
+ finished: boolean
71
+ task?: SiteAIMediaTask
72
+ }
73
+
74
+ /** pollMediaTaskUntilTerminal 是可复用的轮询循环:
75
+ * - `isCurrent()` 返回 false 时立即中止(旧任务),不 emit 更新;
76
+ * - `now()` 超过 `deadline` 时按超时结束;
77
+ * - 收到 succeeded/failed/cancelled 时结束;否则继续下一轮。
78
+ * 参数 <= 3 个:通过 config 传入,便于测试与复用。 */
79
+ export async function pollMediaTaskUntilTerminal(config: {
80
+ fetchTask: () => Promise<SiteAIMediaTask>
81
+ isCurrent: () => boolean
82
+ timing: {
83
+ deadline: number
84
+ intervalMs: number
85
+ now: () => number
86
+ sleep: (ms: number) => Promise<void>
87
+ }
88
+ onUpdate: (update: MediaTaskPollUpdate) => void
89
+ timeoutMessage: string
90
+ failureMessage: string
91
+ }): Promise<void> {
92
+ const { fetchTask, isCurrent, timing, onUpdate } = config
93
+ const { deadline, intervalMs, now, sleep } = timing
94
+ while (isCurrent()) {
95
+ await sleep(intervalMs)
96
+ if (!isCurrent()) return
97
+ if (now() >= deadline) {
98
+ onUpdate({ error: config.timeoutMessage, finished: true })
99
+ return
100
+ }
101
+ try {
102
+ const next = await fetchTask()
103
+ if (!isCurrent()) return
104
+ if (next.status === 'succeeded') {
105
+ onUpdate({ finished: true, task: next })
106
+ return
107
+ }
108
+ if (next.status === 'failed' || next.status === 'cancelled') {
109
+ onUpdate({
110
+ error: next.errMessage ?? config.failureMessage,
111
+ finished: true,
112
+ task: next,
113
+ })
114
+ return
115
+ }
116
+ onUpdate({ finished: false, task: next })
117
+ } catch (cause) {
118
+ if (!isCurrent()) return
119
+ onUpdate({
120
+ error: cause instanceof Error ? cause.message : config.failureMessage,
121
+ finished: true,
122
+ })
123
+ return
124
+ }
125
+ }
126
+ }
127
+
128
+ interface MediaTaskState {
129
+ error: string
130
+ loading: boolean
131
+ task: SiteAIMediaTask | null
132
+ }
133
+
134
+ const INITIAL_STATE: MediaTaskState = { error: '', loading: false, task: null }
135
+
136
+ interface MediaTaskOptions {
137
+ capability: 'image' | 'video'
138
+ failureMessage: string
139
+ functionKey: string
140
+ pollDeadlineMs?: number
141
+ pollIntervalMs?: number
142
+ referenceImages?: string[]
143
+ timeoutMessage: string
144
+ }
145
+
146
+ interface SubmitMediaTaskOptions
147
+ extends Required<
148
+ Pick<MediaTaskOptions, 'pollDeadlineMs' | 'pollIntervalMs'>
149
+ > {
150
+ capability: MediaTaskOptions['capability']
151
+ failureMessage: string
152
+ functionKey: string
153
+ isCurrent: () => boolean
154
+ onCreated: (task: SiteAIMediaTask) => void
155
+ onUpdate: (update: MediaTaskPollUpdate) => void
156
+ prompt: string
157
+ referenceImages: string[]
158
+ timeoutMessage: string
159
+ }
160
+
161
+ function useMediaTaskRequestTracker(scope: string): MediaTaskRequestTracker {
162
+ const trackerRef = useRef<MediaTaskRequestTracker | null>(null)
163
+ if (!trackerRef.current) trackerRef.current = createMediaTaskRequestTracker()
164
+ const tracker = trackerRef.current
165
+ useEffect(() => activateMediaTaskRequestTracker(tracker), [scope, tracker])
166
+ return tracker
167
+ }
168
+
169
+ function applyPollUpdate(
170
+ current: MediaTaskState,
171
+ update: MediaTaskPollUpdate,
172
+ ): MediaTaskState {
173
+ return {
174
+ ...current,
175
+ ...(update.task ? { task: update.task } : {}),
176
+ ...(update.error ? { error: update.error } : {}),
177
+ ...(update.finished ? { loading: false } : {}),
178
+ }
179
+ }
180
+
181
+ async function submitMediaTask(options: SubmitMediaTaskOptions): Promise<void> {
182
+ const created = await createSiteAIFunctionTask({
183
+ functionKey: options.functionKey,
184
+ capability: options.capability,
185
+ input: {
186
+ prompt: options.prompt,
187
+ ...(options.capability === 'image' && options.referenceImages.length
188
+ ? { image: options.referenceImages }
189
+ : {}),
190
+ },
191
+ })
192
+ if (!options.isCurrent()) return
193
+ options.onCreated(created)
194
+ if (!hasRunnableMediaTask(created)) {
195
+ options.onUpdate({ finished: true })
196
+ return
197
+ }
198
+ await pollMediaTaskUntilTerminal({
199
+ fetchTask: () => getSiteAIFunctionTask(options.functionKey, created.taskId),
200
+ isCurrent: options.isCurrent,
201
+ timing: {
202
+ deadline: Date.now() + options.pollDeadlineMs,
203
+ intervalMs: options.pollIntervalMs,
204
+ now: () => Date.now(),
205
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
206
+ },
207
+ onUpdate: options.onUpdate,
208
+ timeoutMessage: options.timeoutMessage,
209
+ failureMessage: options.failureMessage,
210
+ })
211
+ }
212
+
213
+ function useMediaTask(options: MediaTaskOptions) {
214
+ const {
215
+ capability,
216
+ failureMessage,
217
+ functionKey,
218
+ pollDeadlineMs = DEFAULT_POLL_DEADLINE_MS,
219
+ pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
220
+ referenceImages = [],
221
+ timeoutMessage,
222
+ } = options
223
+ const [state, setState] = useState<MediaTaskState>(INITIAL_STATE)
224
+ const tracker = useMediaTaskRequestTracker(`${capability}:${functionKey}`)
225
+
226
+ const submit = async (prompt: string) => {
227
+ const value = prompt.trim()
228
+ if (!value) return
229
+ const requestId = tracker.begin()
230
+ const isCurrent = () => tracker.isCurrent(requestId)
231
+ setState({ ...INITIAL_STATE, loading: true })
232
+ try {
233
+ await submitMediaTask({
234
+ capability,
235
+ failureMessage,
236
+ functionKey,
237
+ isCurrent,
238
+ onCreated: (task) => setState((current) => ({ ...current, task })),
239
+ onUpdate: (update) =>
240
+ setState((current) => applyPollUpdate(current, update)),
241
+ pollDeadlineMs,
242
+ pollIntervalMs,
243
+ prompt: value,
244
+ referenceImages,
245
+ timeoutMessage,
246
+ })
247
+ } catch (cause) {
248
+ if (!isCurrent()) return
249
+ setState((current) => ({
250
+ ...current,
251
+ error: cause instanceof Error ? cause.message : failureMessage,
252
+ loading: false,
253
+ }))
254
+ }
255
+ }
256
+ return {
257
+ error: state.error,
258
+ loading: state.loading,
259
+ submit,
260
+ task: state.task,
261
+ }
262
+ }
263
+
264
+ /** 图片生成示例:支持纯文本、单参考图、多参考图,提交任务后轮询并展示图片资源。 */
265
+ export function AIImagePanel({
266
+ functionKey,
267
+ pollDeadlineMs,
268
+ pollIntervalMs,
269
+ referenceImages = [],
270
+ title,
271
+ }: AIImagePanelProps) {
272
+ const i18n = AI_FUNCTION_COPY[useUiLanguage()].image
273
+ const [prompt, setPrompt] = useState('')
274
+ const { error, loading, submit, task } = useMediaTask({
275
+ capability: 'image',
276
+ failureMessage: i18n.unavailable,
277
+ functionKey,
278
+ pollDeadlineMs,
279
+ pollIntervalMs,
280
+ referenceImages,
281
+ timeoutMessage: i18n.timeout,
282
+ })
283
+
284
+ const generate = async () => {
285
+ const value = prompt.trim()
286
+ if (!value || loading) return
287
+ await submit(value)
288
+ }
289
+
290
+ return (
291
+ <section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-5">
292
+ <h2 className="font-semibold text-card-foreground">
293
+ {title ?? i18n.title}
294
+ </h2>
295
+ <textarea
296
+ aria-label={i18n.description}
297
+ className="min-h-20 rounded-lg border border-border bg-background p-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
298
+ onChange={(event) => setPrompt(event.target.value)}
299
+ placeholder={i18n.descriptionPlaceholder}
300
+ value={prompt}
301
+ />
302
+ <Button disabled={loading || !prompt.trim()} onClick={generate}>
303
+ {loading ? i18n.generating : i18n.generate}
304
+ </Button>
305
+ {error ? <p className="text-sm text-danger">{error}</p> : null}
306
+ <div className="grid grid-cols-2 gap-3">
307
+ {(task?.type === 'image_task' ? (task.imageAssets ?? []) : [])
308
+ .filter((asset): asset is typeof asset & { url: string } =>
309
+ Boolean(asset.url),
310
+ )
311
+ .map((asset) => (
312
+ <img
313
+ alt={i18n.generatedImage}
314
+ className="w-full rounded-lg border border-border"
315
+ key={asset.url}
316
+ src={asset.url}
317
+ />
318
+ ))}
319
+ </div>
320
+ </section>
321
+ )
322
+ }
323
+
324
+ /** 文生视频示例:提交任务后按节奏轮询,成功后展示视频。 */
325
+ export function AIVideoPanel({
326
+ functionKey,
327
+ pollDeadlineMs,
328
+ pollIntervalMs,
329
+ title,
330
+ }: AIVideoPanelProps) {
331
+ const language = useUiLanguage()
332
+ const i18n = AI_FUNCTION_COPY[language].video
333
+ const [prompt, setPrompt] = useState('')
334
+ const { error, loading, submit, task } = useMediaTask({
335
+ capability: 'video',
336
+ failureMessage: i18n.unavailable,
337
+ functionKey,
338
+ pollDeadlineMs,
339
+ pollIntervalMs,
340
+ timeoutMessage: i18n.timeout,
341
+ })
342
+
343
+ const generate = async () => {
344
+ const value = prompt.trim()
345
+ if (!value || loading) return
346
+ await submit(value)
347
+ }
348
+
349
+ return (
350
+ <section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-5">
351
+ <h2 className="font-semibold text-card-foreground">
352
+ {title ?? i18n.title}
353
+ </h2>
354
+ <textarea
355
+ aria-label={i18n.description}
356
+ className="min-h-20 rounded-lg border border-border bg-background p-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
357
+ onChange={(event) => setPrompt(event.target.value)}
358
+ placeholder={i18n.descriptionPlaceholder}
359
+ value={prompt}
360
+ />
361
+ <Button disabled={loading || !prompt.trim()} onClick={generate}>
362
+ {loading ? i18n.processing : i18n.generate}
363
+ </Button>
364
+ {task ? (
365
+ <p className="text-sm text-muted-foreground">
366
+ {formatAiTaskStatus(task.status, task.progress, language)}
367
+ </p>
368
+ ) : null}
369
+ {error ? <p className="text-sm text-danger">{error}</p> : null}
370
+ {task?.type === 'video_task' &&
371
+ task.status === 'succeeded' &&
372
+ task.videoAsset?.url ? (
373
+ <video
374
+ aria-label={i18n.generatedVideo}
375
+ className="w-full rounded-lg border border-border"
376
+ controls
377
+ src={task.videoAsset.url}
378
+ />
379
+ ) : null}
380
+ </section>
381
+ )
382
+ }