howone 0.2.3 → 0.2.6
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/templates/vite/.howone/skills/howone/01-architect/01-app-generation.md +8 -7
- package/templates/vite/.howone/skills/howone/01-architect/02-manifest-codegen.md +121 -436
- package/templates/vite/.howone/skills/howone/03-ai-capabilities/04-workflow-operations.md +13 -4
- package/templates/vite/.howone/skills/howone/04-app-sdk/01-client-setup.md +94 -261
- package/templates/vite/.howone/skills/howone/04-app-sdk/02-entity-operations.md +85 -465
- package/templates/vite/.howone/skills/howone/04-app-sdk/03-auth.md +11 -7
- package/templates/vite/.howone/skills/howone/04-app-sdk/04-react-integration.md +84 -137
- package/templates/vite/.howone/skills/howone/04-app-sdk/05-file-upload.md +66 -273
- package/templates/vite/.howone/skills/howone/04-app-sdk/06-raw-http.md +72 -249
- package/templates/vite/.howone/skills/howone/04-app-sdk/07-ai-action-calls.md +135 -499
- package/templates/vite/.howone/skills/howone/04-app-sdk/08-ai-manifest-handoff.md +49 -196
- package/templates/vite/.howone/skills/howone/04-app-sdk/09-extension-boundaries.md +4 -4
- package/templates/vite/.howone/skills/howone/04-app-sdk/10-workflow-execute-sse.md +94 -61
- package/templates/vite/.howone/skills/howone/04-app-sdk/11-entity-data-access-patterns.md +4 -3
- package/templates/vite/.howone/skills/howone/SKILL.md +48 -8
- package/templates/vite/.howone/skills/howone/references/common-errors.md +27 -0
- package/templates/vite/.howone/skills/howone/references/version-evidence.md +47 -0
- package/templates/vite/.howone/skills/howone/scripts/verify-project.mjs +151 -0
- package/templates/vite/package.json +1 -1
- package/templates/vite/src/App.tsx +9 -5
- package/templates/vite/src/lib/sdk.ts +7 -5
- package/templates/vite/bun.lock +0 -1478
|
@@ -1,319 +1,112 @@
|
|
|
1
1
|
# File Upload
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Use `howone.upload` for HowOne storage. Upload first, then persist the returned CDN URL in an
|
|
4
|
+
entity. Uploads require a real authenticated token unless the backend explicitly exposes a public
|
|
5
|
+
upload contract.
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
- `upload.file(file, options?)` — general-purpose single file upload with progress and abort support
|
|
7
|
-
- `upload.image(file)` — convenience wrapper for image uploads
|
|
8
|
-
- `upload.batch(options)` — upload multiple files with concurrency control
|
|
9
|
-
|
|
10
|
-
All upload methods are accessed from the `client` (or `howone`) object directly — they are **not** part of entities or AI.
|
|
11
|
-
|
|
12
|
-
---
|
|
13
|
-
|
|
14
|
-
## Types
|
|
7
|
+
## Contract
|
|
15
8
|
|
|
16
9
|
```ts
|
|
17
|
-
//
|
|
18
|
-
type UploadableFile = File | Blob | string // string = URL or base64
|
|
10
|
+
type UploadableFile = File | Blob | string // string = data URL or source URL
|
|
19
11
|
|
|
20
|
-
// ── Options ───────────────────────────────────────────────────
|
|
21
12
|
type UploadOptions = {
|
|
22
|
-
onProgress?: (percent: number) => void
|
|
23
|
-
signal?: AbortSignal
|
|
24
|
-
metadata?: Record<string,
|
|
13
|
+
onProgress?: (percent: number) => void
|
|
14
|
+
signal?: AbortSignal
|
|
15
|
+
metadata?: Record<string, unknown>
|
|
25
16
|
}
|
|
26
17
|
|
|
27
|
-
// ── Single upload result ──────────────────────────────────────
|
|
28
18
|
type UploadResponse = {
|
|
29
|
-
url: string
|
|
30
|
-
thumbnailUrl?: string
|
|
31
|
-
id?: string
|
|
32
|
-
size?: number
|
|
33
|
-
mimeType?: string
|
|
19
|
+
url: string
|
|
20
|
+
thumbnailUrl?: string
|
|
21
|
+
id?: string
|
|
22
|
+
size?: number
|
|
23
|
+
mimeType?: string
|
|
34
24
|
}
|
|
35
25
|
|
|
36
|
-
// ── Batch upload options ──────────────────────────────────────
|
|
37
26
|
type BatchUploadOptions = {
|
|
38
|
-
files:
|
|
39
|
-
concurrent?: number
|
|
27
|
+
files: UploadableFile[]
|
|
28
|
+
concurrent?: number // positive integer; default 3
|
|
40
29
|
onProgress?: (completed: number, total: number) => void
|
|
41
30
|
onFileComplete?: (result: UploadResponse | Error, index: number) => void
|
|
42
31
|
signal?: AbortSignal
|
|
43
32
|
}
|
|
44
|
-
|
|
45
|
-
// ── Batch upload result ───────────────────────────────────────
|
|
46
|
-
type BatchUploadResponse = {
|
|
47
|
-
success: UploadResponse[]
|
|
48
|
-
failed: Array<{ index: number; error: string }>
|
|
49
|
-
total: number
|
|
50
|
-
}
|
|
51
33
|
```
|
|
52
34
|
|
|
53
|
-
|
|
35
|
+
Both data-only responses and `{ data: ... }` envelopes are normalized. The response must contain
|
|
36
|
+
`publicUrl` or `url`; malformed responses throw instead of returning an undefined URL.
|
|
54
37
|
|
|
55
|
-
##
|
|
38
|
+
## Single and image upload
|
|
56
39
|
|
|
57
40
|
```ts
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// Basic upload
|
|
61
|
-
const result = await howone.upload.file(file)
|
|
62
|
-
console.log(result.url) // 'https://cdn.howone.app/...'
|
|
63
|
-
console.log(result.size) // 12345 (bytes)
|
|
64
|
-
console.log(result.mimeType) // 'image/jpeg'
|
|
65
|
-
|
|
66
|
-
// With progress callback
|
|
67
|
-
const result = await howone.upload.file(file, {
|
|
68
|
-
onProgress: (percent) => {
|
|
69
|
-
console.log(`Upload progress: ${percent}%`)
|
|
70
|
-
setProgress(percent)
|
|
71
|
-
},
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
// With cancellation
|
|
75
|
-
const controller = new AbortController()
|
|
76
|
-
const promise = howone.upload.file(file, {
|
|
77
|
-
signal: controller.signal,
|
|
41
|
+
const uploaded = await howone.upload.file(file, {
|
|
42
|
+
metadata: { category: 'cover' },
|
|
78
43
|
onProgress: setProgress,
|
|
44
|
+
signal: controller.signal,
|
|
79
45
|
})
|
|
80
46
|
|
|
81
|
-
|
|
82
|
-
setTimeout(() => controller.abort(), 5000)
|
|
83
|
-
|
|
84
|
-
const result = await promise
|
|
47
|
+
await howone.entities.Story.update(storyId, { coverUrl: uploaded.url })
|
|
85
48
|
|
|
86
|
-
//
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
entityId: story.id,
|
|
90
|
-
uploadedBy: user.id,
|
|
91
|
-
category: 'cover',
|
|
92
|
-
},
|
|
93
|
-
})
|
|
49
|
+
// image is a convenience alias and returns the same full UploadResponse shape.
|
|
50
|
+
const image = await howone.upload.image(imageFile, { onProgress: setProgress })
|
|
51
|
+
console.log(image.url, image.thumbnailUrl, image.id)
|
|
94
52
|
```
|
|
95
53
|
|
|
96
|
-
|
|
54
|
+
`onProgress` fires only when the transport reports a total byte count. Keep a separate loading
|
|
55
|
+
state so an upload without a computable total does not appear stuck.
|
|
97
56
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
57
|
+
```tsx
|
|
58
|
+
const controller = new AbortController()
|
|
59
|
+
try {
|
|
60
|
+
setUploading(true)
|
|
61
|
+
const { url } = await howone.upload.file(file, {
|
|
62
|
+
signal: controller.signal,
|
|
63
|
+
onProgress: setProgress,
|
|
64
|
+
})
|
|
65
|
+
await howone.entities.Asset.create({ url, kind: 'image' })
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (!controller.signal.aborted) setError(error instanceof Error ? error.message : String(error))
|
|
68
|
+
} finally {
|
|
69
|
+
setUploading(false)
|
|
70
|
+
}
|
|
109
71
|
```
|
|
110
72
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
## upload.batch — Multiple Files
|
|
73
|
+
## Batch upload
|
|
114
74
|
|
|
115
75
|
```ts
|
|
116
|
-
import howone from '@/lib/sdk'
|
|
117
|
-
|
|
118
|
-
const files: File[] = Array.from(fileInput.files ?? [])
|
|
119
|
-
|
|
120
76
|
const result = await howone.upload.batch({
|
|
121
|
-
files,
|
|
122
|
-
concurrent: 3,
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
console.
|
|
126
|
-
setProgress(Math.round((completed / total) * 100))
|
|
127
|
-
},
|
|
128
|
-
|
|
129
|
-
onFileComplete: (result, index) => {
|
|
130
|
-
if (result instanceof Error) {
|
|
131
|
-
console.error(`File ${index} failed:`, result.message)
|
|
132
|
-
} else {
|
|
133
|
-
console.log(`File ${index} URL:`, result.url)
|
|
134
|
-
}
|
|
77
|
+
files: Array.from(input.files ?? []),
|
|
78
|
+
concurrent: 3,
|
|
79
|
+
onProgress: (completed, total) => setProgress(Math.round((completed / total) * 100)),
|
|
80
|
+
onFileComplete: (value, index) => {
|
|
81
|
+
if (value instanceof Error) console.error('file failed', index, value.message)
|
|
135
82
|
},
|
|
136
83
|
})
|
|
137
84
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
// Collect all URLs
|
|
142
|
-
const urls = result.success.map(r => r.url)
|
|
143
|
-
|
|
144
|
-
// With cancellation
|
|
145
|
-
const controller = new AbortController()
|
|
146
|
-
const resultPromise = howone.upload.batch({
|
|
147
|
-
files,
|
|
148
|
-
signal: controller.signal,
|
|
149
|
-
onProgress: (c, t) => console.log(c, '/', t),
|
|
150
|
-
})
|
|
151
|
-
// controller.abort() to cancel
|
|
85
|
+
const urls = result.success.map((file) => file.url)
|
|
86
|
+
if (result.failed.length) showPartialFailure(result.failed)
|
|
152
87
|
```
|
|
153
88
|
|
|
154
|
-
|
|
89
|
+
`concurrent` must be a positive integer. The result is intentionally partial: successful files are
|
|
90
|
+
kept even when others fail. An aborted batch stops starting later batches and in-flight uploads
|
|
91
|
+
observe the same signal; do not assume every requested file has a corresponding failure entry.
|
|
155
92
|
|
|
156
|
-
##
|
|
93
|
+
## AI image workflow pattern
|
|
157
94
|
|
|
158
|
-
|
|
95
|
+
AI output URLs are not automatically HowOne storage records. If the product needs durable storage:
|
|
159
96
|
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
export function ImageUploader({
|
|
165
|
-
onUpload,
|
|
166
|
-
}: {
|
|
167
|
-
onUpload: (url: string) => void
|
|
168
|
-
}) {
|
|
169
|
-
const [uploading, setUploading] = useState(false)
|
|
170
|
-
const [progress, setProgress] = useState(0)
|
|
171
|
-
const [error, setError] = useState<string | null>(null)
|
|
172
|
-
const abortRef = useRef<AbortController | null>(null)
|
|
173
|
-
|
|
174
|
-
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
175
|
-
const file = e.target.files?.[0]
|
|
176
|
-
if (!file) return
|
|
177
|
-
|
|
178
|
-
setUploading(true)
|
|
179
|
-
setProgress(0)
|
|
180
|
-
setError(null)
|
|
181
|
-
abortRef.current = new AbortController()
|
|
182
|
-
|
|
183
|
-
try {
|
|
184
|
-
const result = await howone.upload.file(file, {
|
|
185
|
-
signal: abortRef.current.signal,
|
|
186
|
-
onProgress: setProgress,
|
|
187
|
-
})
|
|
188
|
-
onUpload(result.url)
|
|
189
|
-
} catch (err) {
|
|
190
|
-
if ((err as Error).name !== 'AbortError') {
|
|
191
|
-
setError(err instanceof Error ? err.message : 'Upload failed')
|
|
192
|
-
}
|
|
193
|
-
} finally {
|
|
194
|
-
setUploading(false)
|
|
195
|
-
abortRef.current = null
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function handleCancel() {
|
|
200
|
-
abortRef.current?.abort()
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
return (
|
|
204
|
-
<div>
|
|
205
|
-
<input
|
|
206
|
-
type="file"
|
|
207
|
-
accept="image/*"
|
|
208
|
-
onChange={handleChange}
|
|
209
|
-
disabled={uploading}
|
|
210
|
-
/>
|
|
211
|
-
{uploading && (
|
|
212
|
-
<div>
|
|
213
|
-
<progress value={progress} max={100} />
|
|
214
|
-
<span>{progress}%</span>
|
|
215
|
-
<button onClick={handleCancel}>Cancel</button>
|
|
216
|
-
</div>
|
|
217
|
-
)}
|
|
218
|
-
{error && <p className="error">{error}</p>}
|
|
219
|
-
</div>
|
|
220
|
-
)
|
|
221
|
-
}
|
|
222
|
-
```
|
|
223
|
-
|
|
224
|
-
### Multi-file upload with gallery preview
|
|
225
|
-
|
|
226
|
-
```tsx
|
|
227
|
-
import { useState } from 'react'
|
|
228
|
-
import howone from '@/lib/sdk'
|
|
229
|
-
|
|
230
|
-
export function MultiFileUploader() {
|
|
231
|
-
const [files, setFiles] = useState<File[]>([])
|
|
232
|
-
const [uploading, setUploading] = useState(false)
|
|
233
|
-
const [progress, setProgress] = useState({ completed: 0, total: 0 })
|
|
234
|
-
const [uploadedUrls, setUploadedUrls] = useState<string[]>([])
|
|
235
|
-
const [failedCount, setFailedCount] = useState(0)
|
|
236
|
-
|
|
237
|
-
function handleSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
|
238
|
-
setFiles(Array.from(e.target.files ?? []))
|
|
239
|
-
setUploadedUrls([])
|
|
240
|
-
setFailedCount(0)
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
async function handleUpload() {
|
|
244
|
-
if (!files.length) return
|
|
245
|
-
setUploading(true)
|
|
246
|
-
setProgress({ completed: 0, total: files.length })
|
|
247
|
-
|
|
248
|
-
const result = await howone.upload.batch({
|
|
249
|
-
files,
|
|
250
|
-
concurrent: 3,
|
|
251
|
-
onProgress: (completed, total) => setProgress({ completed, total }),
|
|
252
|
-
})
|
|
253
|
-
|
|
254
|
-
setUploadedUrls(result.success.map(r => r.url))
|
|
255
|
-
setFailedCount(result.failed.length)
|
|
256
|
-
setUploading(false)
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
return (
|
|
260
|
-
<div>
|
|
261
|
-
<input type="file" multiple onChange={handleSelect} disabled={uploading} />
|
|
262
|
-
<button onClick={handleUpload} disabled={uploading || !files.length}>
|
|
263
|
-
{uploading
|
|
264
|
-
? `Uploading ${progress.completed}/${progress.total}...`
|
|
265
|
-
: `Upload ${files.length} files`}
|
|
266
|
-
</button>
|
|
267
|
-
{failedCount > 0 && <p>{failedCount} files failed to upload</p>}
|
|
268
|
-
<div className="gallery">
|
|
269
|
-
{uploadedUrls.map((url, i) => (
|
|
270
|
-
<img key={i} src={url} alt={`Upload ${i}`} />
|
|
271
|
-
))}
|
|
272
|
-
</div>
|
|
273
|
-
</div>
|
|
274
|
-
)
|
|
275
|
-
}
|
|
276
|
-
```
|
|
277
|
-
|
|
278
|
-
### Upload and save to entity
|
|
279
|
-
|
|
280
|
-
```tsx
|
|
281
|
-
import howone, { type StoryUpdate } from '@/lib/sdk'
|
|
282
|
-
|
|
283
|
-
async function uploadCoverAndUpdate(storyId: string, coverFile: File) {
|
|
284
|
-
// 1. Upload image
|
|
285
|
-
const { url } = await howone.upload.image(coverFile)
|
|
286
|
-
|
|
287
|
-
// 2. Update entity with the uploaded URL
|
|
288
|
-
const updated = await howone.entities.Story.update(storyId, {
|
|
289
|
-
coverUrl: url,
|
|
290
|
-
})
|
|
291
|
-
|
|
292
|
-
return updated
|
|
293
|
-
}
|
|
294
|
-
```
|
|
295
|
-
|
|
296
|
-
### Upload from AI output (AI-generated image URL → storage)
|
|
297
|
-
|
|
298
|
-
```tsx
|
|
299
|
-
import howone from '@/lib/sdk'
|
|
300
|
-
|
|
301
|
-
async function saveGeneratedImage(aiImageUrl: string, storyId: string) {
|
|
302
|
-
// Upload by URL (download + re-upload to HowOne storage)
|
|
303
|
-
const { url } = await howone.upload.image(aiImageUrl)
|
|
304
|
-
|
|
305
|
-
await howone.entities.Story.update(storyId, { coverUrl: url })
|
|
306
|
-
return url
|
|
307
|
-
}
|
|
97
|
+
```ts
|
|
98
|
+
const output = await howone.ai.generateImage.run(input)
|
|
99
|
+
const stored = await howone.upload.image(output.imageUrl)
|
|
100
|
+
await howone.entities.Generation.update(generationId, { imageUrl: stored.url })
|
|
308
101
|
```
|
|
309
102
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
## Common Mistakes
|
|
103
|
+
## Common mistakes
|
|
313
104
|
|
|
314
|
-
| Mistake |
|
|
105
|
+
| Mistake | Fix |
|
|
315
106
|
|---|---|
|
|
316
|
-
|
|
|
317
|
-
|
|
|
318
|
-
|
|
|
319
|
-
|
|
|
107
|
+
| Saving a `blob:` URL | Persist the returned `UploadResponse.url`. |
|
|
108
|
+
| Assuming `upload.image` returns only `{ url }` | It returns the full upload response; destructuring `{ url }` remains valid. |
|
|
109
|
+
| Passing `metadata` with owner/system fields | Keep metadata app-owned; entity ownership is derived by the backend. |
|
|
110
|
+
| Using `concurrent: 0` | Use a positive integer. |
|
|
111
|
+
| Ignoring `failed` | Batch uploads are partial; handle both arrays. |
|
|
112
|
+
| Forgetting cleanup on unmount | Abort with a controller owned by the component. |
|