返回 JoyAI-Echo
api.ts
1 import type {
2 ChatSummary,
3 EchoTrackingResponse,
4 WireMediaRef,
5 WireQuestionCard,
6 WorkplaceData,
7 } from "./types";
8
9 export class ApiError extends Error {
10 status: number;
11 constructor(status: number, message: string) {
12 super(message);
13 this.status = status;
14 this.name = "ApiError";
15 }
16 }
17
18 function utf8ToBase64(text: string): string {
19 const bytes = new TextEncoder().encode(text);
20 let binary = "";
21 for (const byte of bytes) {
22 binary += String.fromCharCode(byte);
23 }
24 return btoa(binary);
25 }
26
27 function nanobotBody(payload: Record<string, unknown>): HeadersInit {
28 return { "X-Nanobot-Body": utf8ToBase64(JSON.stringify(payload)) };
29 }
30
31 async function request<T>(
32 url: string,
33 token: string,
34 init?: RequestInit,
35 ): Promise<T> {
36 const res = await fetch(url, {
37 ...(init ?? {}),
38 headers: {
39 ...(init?.headers ?? {}),
40 Authorization: `Bearer ${token}`,
41 },
42 credentials: "same-origin",
43 });
44 if (!res.ok) {
45 const detail = (await res.text()).trim();
46 throw new ApiError(res.status, detail || `HTTP ${res.status}`);
47 }
48 return (await res.json()) as T;
49 }
50
51 async function workplaceRequest<T>(
52 url: string,
53 token: string,
54 init?: RequestInit,
55 ): Promise<T> {
56 const res = await fetch(url, {
57 ...(init ?? {}),
58 headers: {
59 ...(init?.headers ?? {}),
60 Authorization: `Bearer ${token}`,
61 },
62 credentials: "same-origin",
63 });
64 if (!res.ok) {
65 const detail = (await res.text()).trim();
66 throw new ApiError(res.status, detail || `HTTP ${res.status}`);
67 }
68 return (await res.json()) as T;
69 }
70
71 function splitKey(key: string): { channel: string; chatId: string } {
72 // Keys may include user-scoped prefixes, e.g. "websocket:<userId>:<chatId>".
73 // The UI routes WS messages by the raw chatId, which is always the last segment.
74 const first = key.indexOf(":");
75 if (first === -1) return { channel: "", chatId: key };
76 const last = key.lastIndexOf(":");
77 return {
78 channel: key.slice(0, first),
79 chatId: key.slice(last + 1),
80 };
81 }
82
83 export type WorkflowActionResponse = {
84 ok: boolean;
85 work_id: string;
86 scheduled: boolean;
87 workplace: WorkplaceData;
88 };
89
90 export interface SessionWireToolCall {
91 id: string;
92 type?: string;
93 function?: { name?: string; arguments?: string };
94 }
95
96 export interface SessionWireMessage {
97 role: string;
98 content: string;
99 timestamp?: string;
100 tool_calls?: SessionWireToolCall[];
101 tool_call_id?: string;
102 name?: string;
103 media_urls?: WireMediaRef[];
104 questions?: WireQuestionCard[];
105 question_batch_id?: string;
106 }
107
108 export async function listSessions(
109 token: string,
110 base: string = "",
111 ): Promise<ChatSummary[]> {
112 type Row = {
113 key: string;
114 created_at: string | null;
115 updated_at: string | null;
116 preview?: string;
117 source?: "stepwise" | null;
118 autoGenerate?: boolean | null;
119 };
120 const body = await request<{ sessions: Row[] }>(
121 `${base}/api/sessions`,
122 token,
123 );
124 return body.sessions.map((s) => ({
125 key: s.key,
126 ...splitKey(s.key),
127 createdAt: s.created_at,
128 updatedAt: s.updated_at,
129 preview: s.preview ?? "",
130 source: s.source ?? null,
131 autoGenerate: Boolean(s.autoGenerate),
132 }));
133 }
134
135 export async function fetchSessionMessages(
136 token: string,
137 key: string,
138 base: string = "",
139 ): Promise<{
140 key: string;
141 created_at: string | null;
142 updated_at: string | null;
143 messages: SessionWireMessage[];
144 }> {
145 return request(
146 `${base}/api/sessions/${encodeURIComponent(key)}/messages`,
147 token,
148 );
149 }
150
151 export async function deleteSession(
152 token: string,
153 key: string,
154 base: string = "",
155 ): Promise<boolean> {
156 const body = await request<{ deleted: boolean }>(
157 `${base}/api/sessions/${encodeURIComponent(key)}/delete`,
158 token,
159 );
160 return body.deleted;
161 }
162
163 export type GenerationSettingsResponse = {
164 ok: boolean;
165 session_key: string;
166 n_shots: number;
167 duration_sec: number;
168 width: number;
169 height: number;
170 language: string;
171 temperature: number | null;
172 top_p: number | null;
173 top_k: number | null;
174 };
175
176 export type GenerationLlmSettings = Pick<
177 GenerationSettingsResponse,
178 "temperature" | "top_p" | "top_k"
179 >;
180
181 export async function fetchGenerationSettings(
182 token: string,
183 key: string,
184 base: string = "",
185 ): Promise<GenerationSettingsResponse> {
186 return workplaceRequest(
187 `${base}/api/sessions/${encodeURIComponent(key)}/generation-settings`,
188 token,
189 );
190 }
191
192 export async function saveGenerationSettings(
193 token: string,
194 key: string,
195 durationSec?: number,
196 width?: number,
197 height?: number,
198 language?: string,
199 base: string = "",
200 ): Promise<GenerationSettingsResponse> {
201 const params = new URLSearchParams({
202 ...(durationSec === undefined ? {} : { duration_sec: String(durationSec) }),
203 ...(width === undefined ? {} : { width: String(width) }),
204 ...(height === undefined ? {} : { height: String(height) }),
205 ...(language === undefined ? {} : { language }),
206 });
207 return workplaceRequest(
208 `${base}/api/sessions/${encodeURIComponent(key)}/generation-settings/save?${params}`,
209 token,
210 );
211 }
212
213 /** Keep the first-frame image self-contained in the local application. */
214 export async function uploadFirstFrameImage(
215 file: File | Blob,
216 name = "first-frame.jpg",
217 ): Promise<{ url: string; width: number; height: number }> {
218 const payload =
219 file instanceof File
220 ? file
221 : new File([file], name, { type: file.type || "image/jpeg" });
222 const url = await new Promise<string>((resolve, reject) => {
223 const reader = new FileReader();
224 reader.onload = () => resolve(String(reader.result || ""));
225 reader.onerror = () => reject(new Error("failed to read first-frame image"));
226 reader.readAsDataURL(payload);
227 });
228 if (!url.startsWith("data:image/")) {
229 throw new Error("failed to encode first-frame image");
230 }
231 return { url, width: 0, height: 0 };
232 }
233
234 export async function fetchWorkplace(
235 token: string,
236 key: string,
237 base: string = "",
238 ): Promise<WorkplaceData> {
239 return workplaceRequest(
240 `${base}/api/workplace/${encodeURIComponent(key)}`,
241 token,
242 );
243 }
244
245 export async function updateEchoLike(
246 token: string,
247 key: string,
248 likeStatus: 1 | 2,
249 base: string = "",
250 ): Promise<EchoTrackingResponse> {
251 return workplaceRequest(
252 `${base}/api/workplace/${encodeURIComponent(key)}/echo/like?like_status=${likeStatus}`,
253 token,
254 );
255 }
256
257 export async function recordEchoDownloadPrompt(
258 token: string,
259 key: string,
260 base: string = "",
261 ): Promise<EchoTrackingResponse> {
262 return workplaceRequest(
263 `${base}/api/workplace/${encodeURIComponent(key)}/echo/download-prompt`,
264 token,
265 );
266 }
267
268 export async function acceptShot(
269 token: string,
270 key: string,
271 shotId: number,
272 base: string = "",
273 ): Promise<{ workplace: WorkplaceData }> {
274 return workplaceRequest(
275 `${base}/api/workplace/${encodeURIComponent(key)}/shots/${shotId}/accept`,
276 token,
277 );
278 }
279
280 export type MemoryReviewAction = {
281 review_id: string;
282 attempt: number;
283 memory_id?: string;
284 timestamp_sec?: number;
285 retained_memory_ids?: string[];
286 };
287
288 export async function approveMemoryReview(
289 token: string,
290 key: string,
291 shotId: number,
292 action: MemoryReviewAction,
293 base: string = "",
294 ): Promise<{ workplace: WorkplaceData }> {
295 return workplaceRequest(
296 `${base}/api/workplace/${encodeURIComponent(key)}/shots/${shotId}/memory-review/approve`,
297 token,
298 { headers: nanobotBody(action) },
299 );
300 }
301
302 export async function reselectMemoryReview(
303 token: string,
304 key: string,
305 shotId: number,
306 action: MemoryReviewAction,
307 base: string = "",
308 ): Promise<{ workplace: WorkplaceData }> {
309 return workplaceRequest(
310 `${base}/api/workplace/${encodeURIComponent(key)}/shots/${shotId}/memory-review/reselect`,
311 token,
312 { headers: nanobotBody(action) },
313 );
314 }
315
316 export async function selectMemoryReviewFrame(
317 token: string,
318 key: string,
319 shotId: number,
320 action: MemoryReviewAction,
321 base: string = "",
322 ): Promise<{ workplace: WorkplaceData }> {
323 return workplaceRequest(
324 `${base}/api/workplace/${encodeURIComponent(key)}/shots/${shotId}/memory-review/manual-select`,
325 token,
326 { headers: nanobotBody(action) },
327 );
328 }
329
330 export async function selectMemoryReviewMode(
331 token: string,
332 key: string,
333 shotId: number,
334 action: MemoryReviewAction & { selection_mode: "manual" | "vlm" },
335 base: string = "",
336 ): Promise<{ workplace: WorkplaceData }> {
337 return workplaceRequest(
338 `${base}/api/workplace/${encodeURIComponent(key)}/shots/${shotId}/memory-review/select-mode`,
339 token,
340 { headers: nanobotBody(action) },
341 );
342 }
343
344 export async function reviseShot(
345 token: string,
346 key: string,
347 shotId: number,
348 feedback: string,
349 base: string = "",
350 ): Promise<{ workplace: WorkplaceData }> {
351 return workplaceRequest(
352 `${base}/api/workplace/${encodeURIComponent(key)}/shots/${shotId}/revise?feedback=${encodeURIComponent(feedback)}`,
353 token,
354 );
355 }
356
357 export async function confirmStory(
358 token: string,
359 key: string,
360 storyMd?: string,
361 base: string = "",
362 ): Promise<WorkflowActionResponse & { action: "confirm_story" }> {
363 const trimmed = storyMd?.trim();
364 const init: RequestInit | undefined = trimmed
365 ? { headers: nanobotBody({ story_md: trimmed }) }
366 : undefined;
367 return workplaceRequest(
368 `${base}/api/workplace/${encodeURIComponent(key)}/workflow/confirm-story`,
369 token,
370 init,
371 );
372 }
373
374 export async function startGeneration(
375 token: string,
376 key: string,
377 base: string = "",
378 ): Promise<WorkflowActionResponse & { action: "start_generation" }> {
379 return workplaceRequest(
380 `${base}/api/workplace/${encodeURIComponent(key)}/workflow/start-generation`,
381 token,
382 );
383 }
384
385 export async function startAutoGenerate(
386 token: string,
387 key: string,
388 base: string = "",
389 ): Promise<WorkflowActionResponse & { action: "auto_generate" }> {
390 return workplaceRequest(
391 `${base}/api/workplace/${encodeURIComponent(key)}/workflow/auto-generate`,
392 token,
393 );
394 }
395
396 export type ReferenceImagePayload = {
397 url: string;
398 name?: string;
399 width?: number;
400 height?: number;
401 };
402
403 export async function putReferenceImage(
404 token: string,
405 key: string,
406 image: ReferenceImagePayload,
407 base: string = "",
408 ): Promise<{ ok?: boolean; workplace?: WorkplaceData }> {
409 // websockets HTTP 只接受 GET;动作写在 path 上,body 走 X-Nanobot-Body。
410 return workplaceRequest(
411 `${base}/api/workplace/${encodeURIComponent(key)}/reference-image/save`,
412 token,
413 {
414 headers: nanobotBody({
415 url: image.url,
416 name: image.name ?? "",
417 width: image.width ?? 0,
418 height: image.height ?? 0,
419 }),
420 },
421 );
422 }
423
424 export async function deleteReferenceImage(
425 token: string,
426 key: string,
427 base: string = "",
428 ): Promise<{ ok?: boolean; workplace?: WorkplaceData }> {
429 return workplaceRequest(
430 `${base}/api/workplace/${encodeURIComponent(key)}/reference-image/delete`,
431 token,
432 );
433 }
434
435 export async function abortGeneration(
436 token: string,
437 key: string,
438 base: string = "",
439 ): Promise<WorkflowActionResponse & { action: "abort_generation" }> {
440 return workplaceRequest(
441 `${base}/api/workplace/${encodeURIComponent(key)}/workflow/abort-generation`,
442 token,
443 );
444 }
445
446 export async function generateAll(
447 token: string,
448 key: string,
449 base: string = "",
450 ): Promise<{
451 ok: boolean;
452 action: "generate_all";
453 work_id: string;
454 submitted_shot_ids: number[];
455 workplace: WorkplaceData;
456 }> {
457 return workplaceRequest(
458 `${base}/api/workplace/${encodeURIComponent(key)}/workflow/generate-all`,
459 token,
460 );
461 }
462
463 export async function acceptAllShots(
464 token: string,
465 key: string,
466 base: string = "",
467 ): Promise<{
468 ok: boolean;
469 work_id: string;
470 accepted_shot_ids: number[];
471 workplace: WorkplaceData;
472 }> {
473 return workplaceRequest(
474 `${base}/api/workplace/${encodeURIComponent(key)}/shots/accept-all`,
475 token,
476 );
477 }
478
479 const ACCEPTABLE_SHOT_STATUSES = new Set(["generated", "review_pass"]);
480
481 export function mockAcceptAllShots(workplace: WorkplaceData): {
482 ok: boolean;
483 work_id: string;
484 accepted_shot_ids: number[];
485 workplace: WorkplaceData;
486 } {
487 const acceptedShotIds: number[] = [];
488 const newShots = (workplace.shots ?? []).map((shot) => {
489 if (!ACCEPTABLE_SHOT_STATUSES.has(shot.status)) {
490 return shot;
491 }
492 acceptedShotIds.push(shot.shot_id);
493 return {
494 ...shot,
495 status: "approved",
496 accepted: true,
497 last_review: "accepted",
498 review_notes: "",
499 };
500 });
501
502 return {
503 ok: true,
504 work_id: workplace.work_id ?? "",
505 accepted_shot_ids: acceptedShotIds,
506 workplace: {
507 ...workplace,
508 shots: newShots,
509 },
510 };
511 }
512
513 export type GenerateShotReferenceImage = {
514 url: string;
515 name?: string;
516 width?: number;
517 height?: number;
518 };
519
520 export async function generateShot(
521 token: string,
522 key: string,
523 shotId: number,
524 referenceImage?: GenerateShotReferenceImage | null,
525 base: string = "",
526 ): Promise<{
527 ok: boolean;
528 action: "generate_shot";
529 work_id: string;
530 shot_id: number;
531 workplace: WorkplaceData;
532 }> {
533 const init: RequestInit | undefined = referenceImage?.url
534 ? {
535 headers: nanobotBody({
536 reference_image_url: referenceImage.url,
537 reference_image_name: referenceImage.name ?? "",
538 reference_image_width: referenceImage.width ?? 0,
539 reference_image_height: referenceImage.height ?? 0,
540 }),
541 }
542 : undefined;
543 return workplaceRequest(
544 `${base}/api/workplace/${encodeURIComponent(key)}/shots/${shotId}/generate`,
545 token,
546 init,
547 );
548 }
549
550 /** 设置 Shot 首尾衔接模式 */
551 export async function setShotContinuousMode(
552 token: string,
553 key: string,
554 shotId: number,
555 enabled: boolean,
556 base: string = "",
557 ): Promise<{
558 ok: boolean;
559 action: string;
560 workplace: WorkplaceData;
561 }> {
562 return workplaceRequest(
563 `${base}/api/workplace/${encodeURIComponent(key)}/shots/${shotId}/continuous-mode?enabled=${enabled}`,
564 token,
565 );
566 }
567
568 /** 使用尾帧连续生成(I2V) */
569 export async function continuousGenerateShot(
570 token: string,
571 key: string,
572 shotId: number,
573 base: string = "",
574 ): Promise<{
575 ok: boolean;
576 action: "continuous_generate";
577 work_id: string;
578 shot_id: number;
579 workplace: WorkplaceData;
580 }> {
581 return workplaceRequest(
582 `${base}/api/workplace/${encodeURIComponent(key)}/shots/${shotId}/continuous-generate`,
583 token,
584 );
585 }
586
587 export async function updateShotDuration(
588 token: string,
589 key: string,
590 shotId: number,
591 durationSec: number,
592 base: string = "",
593 ): Promise<{
594 ok: boolean;
595 work_id: string;
596 shot_id: number;
597 duration_sec: number;
598 workplace: WorkplaceData;
599 }> {
600 return workplaceRequest(
601 `${base}/api/workplace/${encodeURIComponent(key)}/shots/${shotId}/duration?duration_sec=${durationSec}`,
602 token,
603 );
604 }
605
606 /** Placeholder until backend implements shots/{id}/save. */
607 export async function saveShotPrompt(
608 token: string,
609 key: string,
610 shotId: number,
611 summary: string,
612 base: string = "",
613 ): Promise<{ ok: boolean; workplace: WorkplaceData }> {
614 return workplaceRequest(
615 `${base}/api/workplace/${encodeURIComponent(key)}/shots/${shotId}/save?summary=${encodeURIComponent(summary)}`,
616 token,
617 );
618 }
619
620 export async function startMerge(
621 token: string,
622 key: string,
623 base: string = "",
624 ): Promise<WorkflowActionResponse & { action: "start_merge" }> {
625 return workplaceRequest(
626 `${base}/api/workplace/${encodeURIComponent(key)}/workflow/start-merge`,
627 token,
628 );
629 }
630
631 export async function regenerate(
632 token: string,
633 key: string,
634 base: string = "",
635 ): Promise<{
636 ok: boolean;
637 action: "regenerate";
638 work_id: string;
639 workplace: WorkplaceData;
640 }> {
641 return workplaceRequest(
642 `${base}/api/workplace/${encodeURIComponent(key)}/workflow/regenerate`,
643 token,
644 );
645 }
646
647 export type SplitShotPayload =
648 | { cursor_pos: number }
649 | { before_text: string; after_text: string };
650
651 export async function splitShot(
652 token: string,
653 key: string,
654 shotId: number,
655 payload: SplitShotPayload,
656 base: string = "",
657 ): Promise<{
658 ok: boolean;
659 work_id: string;
660 split_shot_id: number;
661 new_shot_id: number;
662 workplace: WorkplaceData;
663 }> {
664 return workplaceRequest(
665 `${base}/api/workplace/${encodeURIComponent(key)}/shots/${shotId}/split-shot`,
666 token,
667 { headers: nanobotBody(payload) },
668 );
669 }
670
671 export async function mergeShotUp(
672 token: string,
673 key: string,
674 shotId: number,
675 mergedText?: string,
676 base: string = "",
677 ): Promise<{
678 ok: boolean;
679 work_id: string;
680 merged_shot_id: number;
681 into_shot_id: number;
682 workplace: WorkplaceData;
683 }> {
684 const trimmed = mergedText?.trim();
685 const init: RequestInit | undefined = trimmed
686 ? { headers: nanobotBody({ merged_text: trimmed }) }
687 : undefined;
688 return workplaceRequest(
689 `${base}/api/workplace/${encodeURIComponent(key)}/shots/${shotId}/merge-up`,
690 token,
691 init,
692 );
693 }
694
695 export async function deleteShot(
696 token: string,
697 key: string,
698 shotId: number,
699 base: string = "",
700 ): Promise<{
701 ok: boolean;
702 work_id: string;
703 removed_shot_id: number;
704 workplace: WorkplaceData;
705 }> {
706 return workplaceRequest(
707 `${base}/api/workplace/${encodeURIComponent(key)}/shots/${shotId}/remove-shot`,
708 token,
709 );
710 }
711
712 export function mockDeleteShot(
713 workplace: WorkplaceData,
714 shotId: number,
715 ): {
716 ok: boolean;
717 work_id: string;
718 removed_shot_id: number;
719 workplace: WorkplaceData;
720 } {
721 const beats = workplace.story_profile?.beats ?? [];
722 if (beats.length <= 1) {
723 throw new ApiError(400, "cannot delete the last beat");
724 }
725
726 const deleteIndex = beats.findIndex(
727 (beat) => Number(beat?.shot_id) === shotId,
728 );
729 if (deleteIndex < 0) {
730 throw new ApiError(404, "shot not found");
731 }
732
733 const remainingBeats = beats.filter((_, index) => index !== deleteIndex);
734 const normalizedBeats = remainingBeats.map((beat, index) => ({
735 shot_id: index + 1,
736 summary: String(beat?.summary ?? "").trim(),
737 }));
738
739 const sourceShots = workplace.shots ?? [];
740 const newShots = normalizedBeats.map((beat, index) => {
741 const sourceIndex = index >= deleteIndex ? index + 1 : index;
742 const sourceShot = sourceShots[sourceIndex];
743 const shotKey = `shot_${String(beat.shot_id).padStart(3, "0")}`;
744 if (sourceShot) {
745 return {
746 ...sourceShot,
747 shot_id: beat.shot_id,
748 shot_key: shotKey,
749 summary: beat.summary,
750 };
751 }
752 return {
753 shot_id: beat.shot_id,
754 shot_key: shotKey,
755 status: "planned",
756 summary: beat.summary,
757 cut: true,
758 video: null,
759 has_video: false,
760 has_actions: false,
761 accepted: false,
762 last_review: null,
763 review_notes: "",
764 updated_at: workplace.updated_at ?? null,
765 timeline: {
766 start_seconds: 0,
767 end_seconds: 4,
768 duration_seconds: 4,
769 label: "00:00 - 00:04",
770 },
771 };
772 });
773
774 return {
775 ok: true,
776 work_id: workplace.work_id ?? "",
777 removed_shot_id: shotId,
778 workplace: {
779 ...workplace,
780 story_profile: workplace.story_profile
781 ? { ...workplace.story_profile, beats: normalizedBeats }
782 : { beats: normalizedBeats },
783 shots: newShots,
784 },
785 };
786 }
787
787 lines TYPESCRIPT