Skip to content

Commit 769d757

Browse files
committed
feat: sora adapter
1 parent 6de0d25 commit 769d757

File tree

11 files changed

+256
-10
lines changed

11 files changed

+256
-10
lines changed

app/src/components/Markdown.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import Label from "@/components/markdown/Label.tsx";
1111
import Link from "@/components/markdown/Link.tsx";
1212
import Code, { CodeProps } from "@/components/markdown/Code.tsx";
1313
import Image from "@/components/markdown/Image.tsx";
14+
import Video from "@/components/markdown/Video.tsx";
1415

1516
type MarkdownProps = {
1617
children: string;
@@ -44,7 +45,18 @@ function MarkdownContent({
4445
return {
4546
p: Label,
4647
a: Link,
47-
img: Image,
48+
img: (props: React.ImgHTMLAttributes<HTMLImageElement>) => {
49+
if (props.alt === "video") {
50+
return (
51+
<Video
52+
src={props.src || ""}
53+
alt={props.alt}
54+
className={props.className}
55+
/>
56+
);
57+
}
58+
return <Image {...props} />;
59+
},
4860
code: (props: CodeProps) => (
4961
<Code {...props} loading={loading} codeStyle={codeStyle} />
5062
),
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import React, { useRef, useEffect, useState } from "react";
2+
import { useTranslation } from "react-i18next";
3+
import { useSelector } from "react-redux";
4+
import { getFilenameFromURL } from "@/utils/base.ts";
5+
import { AlertCircle, Copy, Eye, Link, Loader2 } from "lucide-react";
6+
import { Skeleton } from "@/components/ui/skeleton.tsx";
7+
import { cn } from "@/components/ui/lib/utils.ts";
8+
import {
9+
Dialog,
10+
DialogContent,
11+
DialogHeader,
12+
DialogTitle,
13+
DialogTrigger,
14+
} from "@/components/ui/dialog.tsx";
15+
import { useClipboard } from "@/utils/dom.ts";
16+
import { Button } from "@/components/ui/button.tsx";
17+
import { openWindow } from "@/utils/device.ts";
18+
import { RootState } from "@/store/index.ts";
19+
20+
export enum VideoState {
21+
Loading = "loading",
22+
Loaded = "loaded",
23+
Error = "error",
24+
}
25+
export type VideoStateType = (typeof VideoState)[keyof typeof VideoState];
26+
27+
type VideoProps = React.VideoHTMLAttributes<HTMLVideoElement> & {
28+
alt?: string;
29+
};
30+
31+
export default function Video({
32+
src,
33+
alt,
34+
className,
35+
...props
36+
}: VideoProps) {
37+
const { t } = useTranslation();
38+
const copy = useClipboard();
39+
const token = useSelector((state: RootState) => state.auth.token);
40+
41+
const filename = getFilenameFromURL(src) || "unknown";
42+
const description = alt || filename;
43+
44+
const videoRef = useRef<HTMLVideoElement>(null);
45+
const [state, setState] = useState<VideoStateType>(VideoState.Loading);
46+
const [videoUrl, setVideoUrl] = useState<string | null>(null);
47+
const blobUrlRef = useRef<string | null>(null);
48+
49+
useEffect(() => {
50+
if (!src) {
51+
setState(VideoState.Error);
52+
return;
53+
}
54+
55+
const fetchVideo = async () => {
56+
try {
57+
setState(VideoState.Loading);
58+
const headers: HeadersInit = {
59+
"Content-Type": "video/mp4",
60+
};
61+
62+
if (token) {
63+
headers["Authorization"] = token;
64+
}
65+
66+
const response = await fetch(src, {
67+
method: "GET",
68+
headers,
69+
});
70+
71+
if (!response.ok) {
72+
throw new Error(`HTTP error! status: ${response.status}`);
73+
}
74+
75+
const blob = await response.blob();
76+
const blobUrl = URL.createObjectURL(blob);
77+
78+
if (blobUrlRef.current) {
79+
URL.revokeObjectURL(blobUrlRef.current);
80+
}
81+
82+
blobUrlRef.current = blobUrl;
83+
setVideoUrl(blobUrl);
84+
setState(VideoState.Loaded);
85+
} catch (error) {
86+
console.error("[Video] Failed to load video:", error);
87+
setState(VideoState.Error);
88+
}
89+
};
90+
91+
fetchVideo();
92+
93+
return () => {
94+
if (blobUrlRef.current) {
95+
URL.revokeObjectURL(blobUrlRef.current);
96+
blobUrlRef.current = null;
97+
}
98+
};
99+
}, [src, token]);
100+
101+
const isLoading = state === VideoState.Loading;
102+
const isError = state === VideoState.Error;
103+
const isLoaded = state === VideoState.Loaded;
104+
105+
return (
106+
<Dialog>
107+
<DialogTrigger asChild>
108+
<div className={`flex flex-col items-center cursor-pointer`}>
109+
{isLoading && (
110+
<Skeleton
111+
className={`relative rounded-md w-80 h-44 mx-auto my-1 flex items-center justify-center`}
112+
>
113+
<Loader2 className={`w-6 h-6 animate-spin`} />
114+
</Skeleton>
115+
)}
116+
117+
{isError && (
118+
<div
119+
className={`flex flex-col items-center text-center border rounded-md py-6 px-8 mx-auto my-1`}
120+
>
121+
<AlertCircle className={`h-5 w-5 text-secondary mb-1`} />
122+
<span
123+
className={`text-secondary mb-0 select-none text-sm whitespace-pre-wrap`}
124+
>
125+
{t("renderer.videoLoadFailed", { src: filename })}
126+
</span>
127+
</div>
128+
)}
129+
130+
{videoUrl && (
131+
<video
132+
className={cn(
133+
className,
134+
"select-none outline-none rounded-md max-w-[20rem] max-h-[20rem]",
135+
!isLoaded && `hidden`,
136+
)}
137+
src={videoUrl}
138+
ref={videoRef}
139+
controls
140+
preload="metadata"
141+
onAbort={() => setState(VideoState.Error)}
142+
onError={() => setState(VideoState.Error)}
143+
{...props}
144+
/>
145+
)}
146+
<span
147+
className={`text-secondary text-sm mt-1 select-none max-w-[20rem] text-center truncate`}
148+
>
149+
{description}
150+
</span>
151+
</div>
152+
</DialogTrigger>
153+
<DialogContent className={`flex-dialog`} couldFullScreen>
154+
<DialogHeader>
155+
<DialogTitle className={`flex flex-row items-center`}>
156+
<Eye className={`h-4 w-4 mr-1.5 translate-y-[1px]`} />
157+
{t("renderer.viewVideo")}
158+
</DialogTitle>
159+
</DialogHeader>
160+
<div className={`flex flex-row mb-2 items-center`}>
161+
<div className={`grow`} />
162+
<Button
163+
size={`icon`}
164+
variant={`outline`}
165+
className={`ml-2`}
166+
onClick={() => copy(src || "")}
167+
>
168+
<Copy className={`h-4 w-4`} />
169+
</Button>
170+
<Button
171+
size={`icon`}
172+
variant={`outline`}
173+
className={`ml-2`}
174+
onClick={() => openWindow(src || "")}
175+
disabled={isError}
176+
>
177+
<Link className={`h-4 w-4`} />
178+
</Button>
179+
</div>
180+
<div className={`flex flex-col items-center`}>
181+
{videoUrl && (
182+
<video
183+
className={cn(className, "rounded-md select-none outline-none max-w-full max-h-[80vh]")}
184+
src={videoUrl}
185+
controls
186+
preload="auto"
187+
{...props}
188+
/>
189+
)}
190+
<span
191+
className={`text-secondary text-sm mt-2.5 text-center break-all whitespace-pre-wrap`}
192+
>
193+
<button
194+
onClick={() => copy(src || "")}
195+
className={`h-4 w-4 inline-block mr-1 outline-none translate-y-[2px]`}
196+
>
197+
<Copy className={`h-3.5 w-3.5`} />
198+
</button>
199+
{src || ''}
200+
</span>
201+
</div>
202+
</DialogContent>
203+
</Dialog>
204+
);
205+
}

app/src/conf/version.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"version": "4.24.2"
2+
"version": "4.25.0"
33
}

app/src/resources/i18n/cn.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1477,7 +1477,9 @@
14771477
"viewImage": "查看图片",
14781478
"imageLoadFailed": "图片 {{src}} 加载失败",
14791479
"base64Image": "展开图片 Base64",
1480-
"base64ImageCollapse": "收起图片 Base64"
1480+
"base64ImageCollapse": "收起图片 Base64",
1481+
"viewVideo": "查看视频",
1482+
"videoLoadFailed": "视频 {{src}} 加载失败"
14811483
},
14821484
"bar": {
14831485
"chat": "对话",

app/src/resources/i18n/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1337,7 +1337,9 @@
13371337
"viewImage": "Views image",
13381338
"imageLoadFailed": "Image {{src}} failed to load",
13391339
"base64Image": "Expand Base64 image",
1340-
"base64ImageCollapse": "Collapse Picture Base64"
1340+
"base64ImageCollapse": "Collapse Picture Base64",
1341+
"viewVideo": "View video",
1342+
"videoLoadFailed": "Video {{src}} failed to load"
13411343
},
13421344
"login-action": "Sign in to enjoy more features",
13431345
"anonymous": "please sign in here",

app/src/resources/i18n/ja.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1337,7 +1337,9 @@
13371337
"viewImage": "画像を表示",
13381338
"imageLoadFailed": "画像{{src}}の読み込みに失敗しました",
13391339
"base64Image": "Base 64イメージを展開する",
1340-
"base64ImageCollapse": "画像ベース64を折りたたむ"
1340+
"base64ImageCollapse": "画像ベース64を折りたたむ",
1341+
"viewVideo": "ビデオを見る",
1342+
"videoLoadFailed": "ビデオ{{src}}の読み込みに失敗しました"
13411343
},
13421344
"login-action": "サインインしてその他の機能をご利用ください",
13431345
"anonymous": "ログインしていません",

app/src/resources/i18n/ru.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1337,7 +1337,9 @@
13371337
"viewImage": "Показать изображение",
13381338
"imageLoadFailed": "Не удалось загрузить изображение {{src}}",
13391339
"base64Image": "Развернуть изображение Base64",
1340-
"base64ImageCollapse": "Свернуть базу изображений64"
1340+
"base64ImageCollapse": "Свернуть базу изображений64",
1341+
"viewVideo": "Смотреть",
1342+
"videoLoadFailed": "Не удалось загрузить видео {{src}}"
13411343
},
13421344
"login-action": "Войдите, чтобы пользоваться другими функциями",
13431345
"anonymous": "Не вошёл в",

app/src/resources/i18n/tw.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1486,7 +1486,9 @@
14861486
"viewImage": "看圖片",
14871487
"imageLoadFailed": "圖片{{src}} 載入失敗",
14881488
"base64Image": "展開圖片Base64",
1489-
"base64ImageCollapse": "收起圖片Base64"
1489+
"base64ImageCollapse": "收起圖片Base64",
1490+
"viewVideo": "查看影片",
1491+
"videoLoadFailed": "影片{{src}} 載入失敗"
14901492
},
14911493
"bar": {
14921494
"chat": "對話",

manager/chat.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,21 @@ func createChatTask(
115115
props,
116116
func(data *globals.Chunk) error {
117117
if data != nil && data.Content != "" {
118-
if strings.HasPrefix(data.Content, "{") && strings.Contains(data.Content, "\"id\"") {
118+
if strings.HasPrefix(data.Content, "{") && strings.Contains(data.Content, "\"id\"") && strings.Contains(data.Content, "\"status\"") {
119119
finalJobJson = data.Content
120+
121+
job, err := utils.UnmarshalString[RelayVideoJob](data.Content)
122+
if err == nil && job.Id != "" && job.Status == "completed" {
123+
backendUrl := channel.SystemInstance.GetBackend()
124+
videoUrl := fmt.Sprintf("%s/v1/videos/%s/content", backendUrl, job.Id)
125+
videoMarkdown := utils.GetVideoMarkdown(videoUrl, "video")
126+
127+
chunkChan <- partialChunk{Chunk: &globals.Chunk{Content: videoMarkdown}, End: false, Hit: false, Error: nil}
128+
return nil
129+
}
120130
}
121131
}
132+
// Send original content for progress updates and other messages
122133
chunkChan <- partialChunk{Chunk: data, End: false, Hit: false, Error: nil}
123134
return nil
124135
},
@@ -133,7 +144,6 @@ func createChatTask(
133144
} else {
134145
globals.Debug(fmt.Sprintf("[video] saving task_id %s to conversation %d", job.Id, instance.GetId()))
135146
instance.SetTaskID(job.Id)
136-
instance.AddMessageFromAssistant(finalJobJson)
137147
if !instance.SaveConversation(db) {
138148
globals.Warn(fmt.Sprintf("[video] failed to save conversation with task_id %s", job.Id))
139149
} else {

manager/videos.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ func VideosContentRelayAPI(c *gin.Context) {
173173
}
174174

175175
agent := utils.GetAgentFromContext(c)
176-
if agent != "api" {
176+
if agent != "api" && agent != "token" {
177177
abortWithErrorResponse(c, fmt.Errorf("access denied for invalid agent"), "authentication_error")
178178
return
179179
}

0 commit comments

Comments
 (0)