@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.
- package/CHANGELOG.md +86 -0
- package/LICENSE +6 -0
- package/README.md +55 -31
- package/dist/ai-functions.d.ts +42 -0
- package/dist/ai-functions.d.ts.map +1 -0
- package/dist/ai-functions.js +327 -0
- package/dist/ai-functions.js.map +1 -0
- package/dist/archive.d.ts +21 -0
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +65 -10
- package/dist/archive.js.map +1 -1
- package/dist/auth.d.ts +8 -1
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +60 -6
- package/dist/auth.js.map +1 -1
- package/dist/check.d.ts +74 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +283 -0
- package/dist/check.js.map +1 -0
- package/dist/dependency-policy.d.ts +10 -0
- package/dist/dependency-policy.d.ts.map +1 -0
- package/dist/dependency-policy.js +162 -0
- package/dist/dependency-policy.js.map +1 -0
- package/dist/deploy.d.ts +1 -0
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +122 -175
- package/dist/deploy.js.map +1 -1
- package/dist/environment-variables.d.ts +52 -0
- package/dist/environment-variables.d.ts.map +1 -0
- package/dist/environment-variables.js +563 -0
- package/dist/environment-variables.js.map +1 -0
- package/dist/environment.d.ts +1 -1
- package/dist/environment.js +1 -1
- package/dist/environment.js.map +1 -1
- package/dist/function-input-types.d.ts +10 -0
- package/dist/function-input-types.d.ts.map +1 -0
- package/dist/function-input-types.js +160 -0
- package/dist/function-input-types.js.map +1 -0
- package/dist/function-schema.d.ts.map +1 -1
- package/dist/function-schema.js +89 -36
- package/dist/function-schema.js.map +1 -1
- package/dist/init-field-retry.d.ts +17 -0
- package/dist/init-field-retry.d.ts.map +1 -0
- package/dist/init-field-retry.js +127 -0
- package/dist/init-field-retry.js.map +1 -0
- package/dist/init-journal.d.ts +98 -0
- package/dist/init-journal.d.ts.map +1 -0
- package/dist/init-journal.js +492 -0
- package/dist/init-journal.js.map +1 -0
- package/dist/init-schema.d.ts +8 -0
- package/dist/init-schema.d.ts.map +1 -0
- package/dist/init-schema.js +423 -0
- package/dist/init-schema.js.map +1 -0
- package/dist/init.d.ts +14 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +886 -243
- package/dist/init.js.map +1 -1
- package/dist/lark-cli-error.d.ts +46 -0
- package/dist/lark-cli-error.d.ts.map +1 -0
- package/dist/lark-cli-error.js +171 -0
- package/dist/lark-cli-error.js.map +1 -0
- package/dist/lark-cli.d.ts +2 -1
- package/dist/lark-cli.d.ts.map +1 -1
- package/dist/lark-cli.js +292 -29
- package/dist/lark-cli.js.map +1 -1
- package/dist/mutation-schema.d.ts +146 -0
- package/dist/mutation-schema.d.ts.map +1 -0
- package/dist/mutation-schema.js +153 -0
- package/dist/mutation-schema.js.map +1 -0
- package/dist/mutation.d.ts +10 -0
- package/dist/mutation.d.ts.map +1 -0
- package/dist/mutation.js +131 -0
- package/dist/mutation.js.map +1 -0
- package/dist/npm.d.ts +1 -0
- package/dist/npm.d.ts.map +1 -1
- package/dist/npm.js +2 -2
- package/dist/npm.js.map +1 -1
- package/dist/preview-receipt.d.ts.map +1 -1
- package/dist/preview-receipt.js +15 -5
- package/dist/preview-receipt.js.map +1 -1
- package/dist/process.d.ts +3 -1
- package/dist/process.d.ts.map +1 -1
- package/dist/process.js +16 -4
- package/dist/process.js.map +1 -1
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +241 -42
- package/dist/program.js.map +1 -1
- package/dist/project-build.d.ts +25 -0
- package/dist/project-build.d.ts.map +1 -0
- package/dist/project-build.js +229 -0
- package/dist/project-build.js.map +1 -0
- package/dist/project-context.d.ts +344 -0
- package/dist/project-context.d.ts.map +1 -0
- package/dist/project-context.js +485 -0
- package/dist/project-context.js.map +1 -0
- package/dist/project-layout.d.ts +5 -0
- package/dist/project-layout.d.ts.map +1 -0
- package/dist/project-layout.js +24 -0
- package/dist/project-layout.js.map +1 -0
- package/dist/public-sql-error.d.ts +25 -0
- package/dist/public-sql-error.d.ts.map +1 -0
- package/dist/public-sql-error.js +81 -0
- package/dist/public-sql-error.js.map +1 -0
- package/dist/pull.d.ts.map +1 -1
- package/dist/pull.js +54 -15
- package/dist/pull.js.map +1 -1
- package/dist/site-readme.d.ts +3 -0
- package/dist/site-readme.d.ts.map +1 -0
- package/dist/site-readme.js +83 -0
- package/dist/site-readme.js.map +1 -0
- package/dist/workspace-fingerprint.d.ts +21 -1
- package/dist/workspace-fingerprint.d.ts.map +1 -1
- package/dist/workspace-fingerprint.js +81 -2
- package/dist/workspace-fingerprint.js.map +1 -1
- package/package.json +2 -1
- package/skills/lark-base-database/SKILL.md +70 -53
- package/skills/lark-base-database/references/ai-function.md +471 -0
- package/skills/lark-base-database/references/base-capability-routing.md +3 -3
- package/skills/lark-base-database/references/database-function-limitations.md +46 -0
- package/skills/lark-base-database/references/database-function.md +349 -0
- package/skills/lark-base-database/references/design/components/actions-fields.md +16 -0
- package/skills/lark-base-database/references/design/components/code/controls.css +122 -0
- package/skills/lark-base-database/references/design/components/code/controls.tsx +73 -0
- package/skills/lark-base-database/references/design/components/code/date-picker.tsx +42 -0
- package/skills/lark-base-database/references/design/components/code/floating.tsx +41 -0
- package/skills/lark-base-database/references/design/components/code/overlays.tsx +19 -0
- package/skills/lark-base-database/references/design/components/code/select.tsx +34 -0
- package/skills/lark-base-database/references/design/components/code/styles.d.ts +1 -0
- package/skills/lark-base-database/references/design/components/code/theme.css +49 -0
- package/skills/lark-base-database/references/design/components/code/usage.tsx +38 -0
- package/skills/lark-base-database/references/design/components/date-time-number.md +9 -0
- package/skills/lark-base-database/references/design/components/feedback-navigation.md +9 -0
- package/skills/lark-base-database/references/design/components/index.md +18 -0
- package/skills/lark-base-database/references/design/components/overlays.md +9 -0
- package/skills/lark-base-database/references/design/components/patterns/flat-business.md +7 -0
- package/skills/lark-base-database/references/design/components/selection.md +24 -0
- package/skills/lark-base-database/references/design/components.md +91 -0
- package/skills/lark-base-database/references/design/composition.md +90 -0
- package/skills/lark-base-database/references/design/control-recipes.md +107 -0
- package/skills/lark-base-database/references/design/examples.md +172 -0
- package/skills/lark-base-database/references/design/foundations.md +126 -0
- package/skills/lark-base-database/references/design/review.md +82 -0
- package/skills/lark-base-database/references/design/scenes/analysis-report.md +66 -0
- package/skills/lark-base-database/references/design/scenes/brand-showcase.md +70 -0
- package/skills/lark-base-database/references/design/scenes/business-operations.md +85 -0
- package/skills/lark-base-database/references/design/scenes/campaign-report.md +66 -0
- package/skills/lark-base-database/references/design/scenes/dashboard.md +77 -0
- package/skills/lark-base-database/references/design/scenes/festive-campaign.md +69 -0
- package/skills/lark-base-database/references/design/theme.md +27 -0
- package/skills/lark-base-database/references/design/typography.md +56 -0
- package/skills/lark-base-database/references/development.md +165 -32
- package/skills/lark-base-database/references/environment-variables.md +55 -0
- package/skills/lark-base-database/references/faas.md +41 -48
- package/skills/lark-base-database/references/file-transfer.md +25 -25
- package/skills/lark-base-database/references/frontend-design-guide.md +33 -77
- package/skills/lark-base-database/references/mutations.md +295 -0
- package/skills/lark-base-database/references/permission-design.md +24 -24
- package/skills/lark-base-database/references/release.md +54 -27
- package/skills/lark-base-database/references/style-guide.md +10 -67
- package/skills/lark-base-database/references/verification.md +17 -13
- package/skills/lark-base-database/references/workspace.md +71 -22
- package/templates/database-starter/README.md +34 -4
- package/templates/database-starter/database-functions/README.md +8 -0
- package/templates/database-starter/mutations/README.md +19 -0
- package/templates/database-starter/page/README.md +25 -19
- package/templates/database-starter/page/package-lock.json +8 -16
- package/templates/database-starter/page/package.json +2 -3
- package/templates/database-starter/page/src/App.tsx +23 -3
- package/templates/database-starter/page/src/components/UserSelectorEditor/index.tsx +32 -4
- package/templates/database-starter/page/src/components/UserSelectorPanel/index.tsx +104 -34
- package/templates/database-starter/page/src/components/UserSelectorPanel/user-search.test.ts +55 -11
- package/templates/database-starter/page/src/components/UserSelectorPanel/user-search.ts +68 -25
- package/templates/database-starter/page/src/components/database-runtime-ready.tsx +1 -1
- package/templates/database-starter/page/src/components/database-user-menu/index.tsx +4 -7
- package/templates/database-starter/page/src/components/feedback/page-state.tsx +40 -5
- package/templates/database-starter/page/src/components/floating-button/assets/edit-active.svg +3 -0
- package/templates/database-starter/page/src/components/floating-button/assets/edit-default.svg +3 -0
- package/templates/database-starter/page/src/components/floating-button/index.tsx +75 -112
- package/templates/database-starter/page/src/components/floating-button/utils/project-meta.test.ts +107 -37
- package/templates/database-starter/page/src/components/floating-button/utils/project-meta.ts +13 -50
- package/templates/database-starter/page/src/components/ui-language.test.tsx +101 -0
- package/templates/database-starter/page/src/examples/ai-function/ImageTaskOperation.tsx +202 -0
- package/templates/database-starter/page/src/examples/ai-function/README.md +14 -0
- package/templates/database-starter/page/src/examples/ai-function/TextStreamOperation.tsx +184 -0
- package/templates/database-starter/page/src/examples/ai-function/ai-copy.ts +173 -0
- package/templates/database-starter/page/src/examples/ai-function/ai-delta.test.ts +30 -0
- package/templates/database-starter/page/src/examples/ai-function/ai-delta.ts +26 -0
- package/templates/database-starter/page/src/examples/ai-function/ai-language.test.tsx +86 -0
- package/templates/database-starter/page/src/examples/ai-function/ai-media-panel.test.ts +201 -0
- package/templates/database-starter/page/src/examples/ai-function/ai-media-panel.tsx +382 -0
- package/templates/database-starter/page/src/lib/attachment-image.tsx +144 -0
- package/templates/database-starter/page/src/lib/database-function-inputs.generated.ts +4 -0
- package/templates/database-starter/page/src/lib/database-runtime-attachment.test.ts +180 -76
- package/templates/database-starter/page/src/lib/database-runtime.test.ts +130 -10
- package/templates/database-starter/page/src/lib/database-runtime.ts +484 -51
- package/templates/database-starter/page/src/lib/date-time.ts +61 -0
- package/templates/database-starter/page/src/lib/ui-language.ts +86 -0
- package/templates/database-starter/page/src/main.tsx +1 -1
- package/templates/database-starter/page/tsconfig.json +2 -2
- package/templates/database-starter/page/vite.config.ts +1 -2
- package/templates/database-starter/prettierignore +1 -1
- package/templates/database-starter/server-functions/README.md +2 -2
- package/templates/database-starter/server-functions/package-lock.json +4 -4
- package/templates/database-starter/server-functions/package.json +1 -1
- package/dist/agent-guide.d.ts +0 -19
- package/dist/agent-guide.d.ts.map +0 -1
- package/dist/agent-guide.js +0 -31
- package/dist/agent-guide.js.map +0 -1
- package/dist/database-skill.d.ts +0 -24
- package/dist/database-skill.d.ts.map +0 -1
- package/dist/database-skill.js +0 -151
- package/dist/database-skill.js.map +0 -1
- package/skills/lark-base-database/references/base-sql-unsupported.md +0 -42
- package/skills/lark-base-database/references/database-capability-skill.md +0 -40
- package/skills/lark-base-database/references/sql-function.md +0 -289
- package/templates/database-capability/SKILL.md.tmpl +0 -58
- package/templates/database-starter/page/src/components/floating-button/chatbot-entry.test.ts +0 -23
- package/templates/database-starter/page/src/components/floating-button/chatbot-entry.tsx +0 -873
- package/templates/database-starter/page/src/components/floating-button/local-chat-panel.test.ts +0 -25
- package/templates/database-starter/page/src/components/floating-button/local-chat-panel.tsx +0 -197
- package/templates/database-starter/page/src/components/floating-button/utils/env.ts +0 -18
- 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
|
+
}
|