chore: something changed, commit before reorg

This commit is contained in:
Daniil
2026-04-27 23:28:28 +03:00
parent 46f34bdcac
commit 20928e9a60
16 changed files with 1967 additions and 1262 deletions
+477 -8
View File
@@ -200,6 +200,40 @@ export interface paths {
patch: operations["patch_project_api_projects__project_id___patch"];
trace?: never;
};
"/api/projects/{project_id}/workspace": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Project Workspace */
get: operations["get_project_workspace_api_projects__project_id__workspace_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project_id}/workflow/actions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Dispatch Project Workflow Action */
post: operations["dispatch_project_workflow_action_api_projects__project_id__workflow_actions_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/files/upload/": {
parameters: {
query?: never;
@@ -987,6 +1021,19 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** ActiveJobState */
ActiveJobState: {
/**
* Job Id
* Format: uuid
*/
job_id: string;
/**
* Job Type
* @enum {string}
*/
job_type: "MEDIA_PROBE" | "SILENCE_REMOVE" | "SILENCE_DETECT" | "SILENCE_APPLY" | "MEDIA_CONVERT" | "TRANSCRIPTION_GENERATE" | "CAPTIONS_GENERATE" | "FRAME_EXTRACT";
};
/** ArtifactMediaFileCreate */
ArtifactMediaFileCreate: {
/** Project Id */
@@ -1286,6 +1333,43 @@ export interface components {
/** Result */
result: string;
};
/** CaptionsState */
CaptionsState: {
/** @default IDLE */
status: components["schemas"]["CaptionsWorkflowStatus"];
/** Preset Id */
preset_id?: string | null;
/** Style Config */
style_config?: {
[key: string]: unknown;
} | null;
/** Render Job Id */
render_job_id?: string | null;
/** Output File Id */
output_file_id?: string | null;
};
/**
* CaptionsWorkflowStatus
* @enum {string}
*/
CaptionsWorkflowStatus: "IDLE" | "CONFIGURED" | "PROCESSING" | "COMPLETED";
/** ConfirmVerifyAction */
ConfirmVerifyAction: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "CONFIRM_VERIFY";
/** Revision */
revision: number;
};
/** CutRegionState */
CutRegionState: {
/** Start Ms */
start_ms: number;
/** End Ms */
end_ms: number;
};
/** DispositionSchema */
DispositionSchema: {
/** Default */
@@ -1653,6 +1737,16 @@ export interface components {
/** Words */
words: components["schemas"]["WordNode"][];
};
/** MarkTranscriptionReviewedAction */
MarkTranscriptionReviewedAction: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "MARK_TRANSCRIPTION_REVIEWED";
/** Revision */
revision: number;
};
/**
* MediaConvertRequest
* @description Request to convert media file to different format.
@@ -1909,10 +2003,6 @@ export interface components {
* @enum {string}
*/
status: "DRAFT" | "PROCESSING" | "DONE" | "FAILED";
/** Workspace State */
workspace_state: {
[key: string]: unknown;
} | null;
/** Is Active */
is_active: boolean;
/**
@@ -1938,10 +2028,71 @@ export interface components {
folder?: string | null;
/** Status */
status?: ("DRAFT" | "PROCESSING" | "DONE" | "FAILED") | null;
/** Workspace State */
workspace_state?: {
[key: string]: unknown;
} | null;
};
/** ProjectWorkspaceRead */
ProjectWorkspaceRead: {
/**
* Project Id
* Format: uuid
*/
project_id: string;
/** Revision */
revision: number;
/** Version */
version: number;
phase: components["schemas"]["WorkflowPhase"];
/**
* Current Screen
* @enum {string}
*/
current_screen: "upload" | "verify" | "silence-settings" | "processing" | "fragments" | "silence-apply-processing" | "transcription-settings" | "transcription-processing" | "subtitle-revision" | "caption-settings" | "caption-processing" | "caption-result";
active_job: components["schemas"]["ActiveJobState"] | null;
/** Source File Id */
source_file_id: string | null;
workspace_view: components["schemas"]["WorkspaceViewState"];
silence: components["schemas"]["SilenceState"];
transcription: components["schemas"]["TranscriptionState"];
captions: components["schemas"]["CaptionsState"];
};
/** ReopenCaptionConfigAction */
ReopenCaptionConfigAction: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "REOPEN_CAPTION_CONFIG";
/** Revision */
revision: number;
};
/** ReopenSilenceReviewAction */
ReopenSilenceReviewAction: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "REOPEN_SILENCE_REVIEW";
/** Revision */
revision: number;
};
/** ReopenTranscriptionConfigAction */
ReopenTranscriptionConfigAction: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "REOPEN_TRANSCRIPTION_CONFIG";
/** Revision */
revision: number;
};
/** ResetSourceFileAction */
ResetSourceFileAction: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "RESET_SOURCE_FILE";
/** Revision */
revision: number;
};
/** SaluteSpeechParams */
SaluteSpeechParams: {
@@ -1979,6 +2130,71 @@ export interface components {
/** Lines */
lines: components["schemas"]["LineNode-Output"][];
};
/** SelectCaptionPresetAction */
SelectCaptionPresetAction: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "SELECT_CAPTION_PRESET";
/** Revision */
revision: number;
/** Preset Id */
preset_id?: string | null;
/** Style Config */
style_config?: {
[key: string]: unknown;
} | null;
};
/** SetSilenceCutsAction */
SetSilenceCutsAction: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "SET_SILENCE_CUTS";
/** Revision */
revision: number;
/** Cuts */
cuts: components["schemas"]["CutRegionState"][];
};
/** SetSilenceSettingsAction */
SetSilenceSettingsAction: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "SET_SILENCE_SETTINGS";
/** Revision */
revision: number;
settings?: components["schemas"]["SilenceSettingsState"];
};
/** SetSourceFileAction */
SetSourceFileAction: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "SET_SOURCE_FILE";
/** Revision */
revision: number;
/**
* File Id
* Format: uuid
*/
file_id: string;
};
/** SetWorkspaceViewAction */
SetWorkspaceViewAction: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "SET_WORKSPACE_VIEW";
/** Revision */
revision: number;
workspace_view: components["schemas"]["WorkspaceViewState"];
};
/**
* SilenceApplyRequest
* @description Request to apply silence cuts to media file.
@@ -2085,6 +2301,143 @@ export interface components {
*/
padding_ms: number;
};
/** SilenceSettingsState */
SilenceSettingsState: {
/**
* Min Silence Duration Ms
* @default 200
*/
min_silence_duration_ms: number;
/**
* Silence Threshold Db
* @default 16
*/
silence_threshold_db: number;
/**
* Padding Ms
* @default 100
*/
padding_ms: number;
};
/** SilenceState */
SilenceState: {
/** @default IDLE */
status: components["schemas"]["SilenceWorkflowStatus"];
settings?: components["schemas"]["SilenceSettingsState"];
/** Detect Job Id */
detect_job_id?: string | null;
/** Detected Segments */
detected_segments?: components["schemas"]["CutRegionState"][];
/** Reviewed Cuts */
reviewed_cuts?: components["schemas"]["CutRegionState"][];
/** Duration Ms */
duration_ms?: number | null;
/** Applied Output File Id */
applied_output_file_id?: string | null;
};
/**
* SilenceWorkflowStatus
* @enum {string}
*/
SilenceWorkflowStatus: "IDLE" | "CONFIGURED" | "DETECTING" | "REVIEWING" | "APPLYING" | "COMPLETED" | "SKIPPED";
/** SkipSilenceApplyAction */
SkipSilenceApplyAction: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "SKIP_SILENCE_APPLY";
/** Revision */
revision: number;
};
/** StartCaptionRenderAction */
StartCaptionRenderAction: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "START_CAPTION_RENDER";
/** Revision */
revision: number;
/**
* Folder
* @default output_files
*/
folder: string;
};
/** StartMediaConvertAction */
StartMediaConvertAction: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "START_MEDIA_CONVERT";
/** Revision */
revision: number;
/**
* Output Format
* @default mp4
*/
output_format: string;
/**
* Out Folder
* @default output_files
*/
out_folder: string;
};
/** StartSilenceApplyAction */
StartSilenceApplyAction: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "START_SILENCE_APPLY";
/** Revision */
revision: number;
/** Cuts */
cuts?: components["schemas"]["CutRegionState"][] | null;
/**
* Out Folder
* @default output_files
*/
out_folder: string;
/** Output Name */
output_name?: string | null;
};
/** StartSilenceDetectAction */
StartSilenceDetectAction: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "START_SILENCE_DETECT";
/** Revision */
revision: number;
};
/** StartTranscriptionAction */
StartTranscriptionAction: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "START_TRANSCRIPTION";
/** Revision */
revision: number;
/**
* Engine
* @default whisper
* @enum {string}
*/
engine: "whisper" | "google" | "salutespeech";
/** Language */
language?: string | null;
/**
* Model
* @default base
*/
model: string;
request?: components["schemas"]["TranscriptionRequestState"] | null;
};
/** StreamSchema */
StreamSchema: {
/** Index */
@@ -2324,6 +2677,39 @@ export interface components {
*/
updated_at: string;
};
/** TranscriptionRequestState */
TranscriptionRequestState: {
/**
* Engine
* @default whisper
* @enum {string}
*/
engine: "whisper" | "google" | "salutespeech";
/** Language */
language?: string | null;
/**
* Model
* @default base
*/
model: string;
};
/** TranscriptionState */
TranscriptionState: {
/** @default IDLE */
status: components["schemas"]["TranscriptionWorkflowStatus"];
request?: components["schemas"]["TranscriptionRequestState"];
/** Job Id */
job_id?: string | null;
/** Artifact Id */
artifact_id?: string | null;
/** Transcription Id */
transcription_id?: string | null;
/**
* Reviewed
* @default false
*/
reviewed: boolean;
};
/** TranscriptionUpdate */
TranscriptionUpdate: {
/** Document */
@@ -2335,6 +2721,11 @@ export interface components {
[key: string]: unknown;
} | null;
};
/**
* TranscriptionWorkflowStatus
* @enum {string}
*/
TranscriptionWorkflowStatus: "IDLE" | "PROCESSING" | "REVIEWING" | "COMPLETED";
/** UserCreate */
UserCreate: {
/** Username */
@@ -2542,6 +2933,18 @@ export interface components {
structure_tags: components["schemas"]["Tag"][];
time: components["schemas"]["TimeRange"];
};
/**
* WorkflowPhase
* @enum {string}
*/
WorkflowPhase: "INGEST" | "VERIFY" | "SILENCE" | "TRANSCRIPTION" | "CAPTIONS" | "DONE";
/** WorkspaceViewState */
WorkspaceViewState: {
/** Used File Ids */
used_file_ids?: string[];
/** Selected File Id */
selected_file_id?: string | null;
};
};
responses: never;
parameters: never;
@@ -3055,6 +3458,72 @@ export interface operations {
};
};
};
get_project_workspace_api_projects__project_id__workspace_get: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectWorkspaceRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
dispatch_project_workflow_action_api_projects__project_id__workflow_actions_post: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SetSourceFileAction"] | components["schemas"]["ResetSourceFileAction"] | components["schemas"]["StartMediaConvertAction"] | components["schemas"]["ConfirmVerifyAction"] | components["schemas"]["SetSilenceSettingsAction"] | components["schemas"]["StartSilenceDetectAction"] | components["schemas"]["SetSilenceCutsAction"] | components["schemas"]["SkipSilenceApplyAction"] | components["schemas"]["StartSilenceApplyAction"] | components["schemas"]["ReopenSilenceReviewAction"] | components["schemas"]["StartTranscriptionAction"] | components["schemas"]["ReopenTranscriptionConfigAction"] | components["schemas"]["MarkTranscriptionReviewedAction"] | components["schemas"]["SelectCaptionPresetAction"] | components["schemas"]["StartCaptionRenderAction"] | components["schemas"]["ReopenCaptionConfigAction"] | components["schemas"]["SetWorkspaceViewAction"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectWorkspaceRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
upload_file_api_files_upload__post: {
parameters: {
query?: never;
+246
View File
@@ -0,0 +1,246 @@
"use client"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { ACCESS_TOKEN_REGEXP, API_URL } from "@shared/lib/constants"
export type WorkflowPhase =
| "INGEST"
| "VERIFY"
| "SILENCE"
| "TRANSCRIPTION"
| "CAPTIONS"
| "DONE"
export type WorkflowScreen =
| "upload"
| "verify"
| "silence-settings"
| "processing"
| "fragments"
| "silence-apply-processing"
| "transcription-settings"
| "transcription-processing"
| "subtitle-revision"
| "caption-settings"
| "caption-processing"
| "caption-result"
export interface SilenceSettingsPayload {
min_silence_duration_ms: number
silence_threshold_db: number
padding_ms: number
}
export interface WorkflowCutRegionPayload {
start_ms: number
end_ms: number
}
export interface WorkflowActiveJob {
job_id: string
job_type: string
}
export interface WorkflowWorkspaceView {
used_file_ids: string[]
selected_file_id: string | null
}
export interface WorkflowSilenceState {
status: string | null
settings: SilenceSettingsPayload | null
detect_job_id: string | null
detected_segments: WorkflowCutRegionPayload[]
reviewed_cuts: WorkflowCutRegionPayload[]
duration_ms: number | null
applied_output_file_id: string | null
}
export interface WorkflowTranscriptionRequest {
engine: "whisper" | "google" | "salutespeech"
language?: string
model: string
}
export interface WorkflowTranscriptionState {
status: string | null
job_id: string | null
request: WorkflowTranscriptionRequest | null
artifact_id: string | null
transcription_id: string | null
reviewed: boolean
}
export interface WorkflowCaptionsState {
status: string | null
preset_id: string | null
style_config: Record<string, unknown> | null
render_job_id: string | null
output_file_id: string | null
}
export interface ProjectWorkspaceRead {
revision: number
phase: WorkflowPhase
current_screen: WorkflowScreen
active_job: WorkflowActiveJob | null
source_file_id: string | null
workspace_view: WorkflowWorkspaceView
silence: WorkflowSilenceState
transcription: WorkflowTranscriptionState
captions: WorkflowCaptionsState
}
type WorkflowActionBase<TActionType extends string> = {
type: TActionType
revision: number
}
export type WorkflowActionRequest =
| (WorkflowActionBase<"SET_SOURCE_FILE"> & {
file_id: string
})
| WorkflowActionBase<"RESET_SOURCE_FILE">
| (WorkflowActionBase<"START_MEDIA_CONVERT"> & {
output_format?: "mp4"
})
| WorkflowActionBase<"CONFIRM_VERIFY">
| (WorkflowActionBase<"SET_SILENCE_SETTINGS"> & {
settings: SilenceSettingsPayload
})
| WorkflowActionBase<"START_SILENCE_DETECT">
| (WorkflowActionBase<"SET_SILENCE_CUTS"> & {
cuts: WorkflowCutRegionPayload[]
})
| WorkflowActionBase<"SKIP_SILENCE_APPLY">
| (WorkflowActionBase<"START_SILENCE_APPLY"> & {
cuts: WorkflowCutRegionPayload[]
})
| WorkflowActionBase<"REOPEN_SILENCE_REVIEW">
| (WorkflowActionBase<"START_TRANSCRIPTION"> & {
request: WorkflowTranscriptionRequest
})
| WorkflowActionBase<"REOPEN_TRANSCRIPTION_CONFIG">
| WorkflowActionBase<"MARK_TRANSCRIPTION_REVIEWED">
| (WorkflowActionBase<"SELECT_CAPTION_PRESET"> & {
preset_id: string | null
})
| WorkflowActionBase<"START_CAPTION_RENDER">
| WorkflowActionBase<"REOPEN_CAPTION_CONFIG">
| (WorkflowActionBase<"SET_WORKSPACE_VIEW"> & {
workspace_view: WorkflowWorkspaceView
})
class WorkflowApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.name = "WorkflowApiError"
this.status = status
}
}
function getBaseApiUrl(): string {
if (API_URL?.length) return API_URL
if (typeof window !== "undefined") return window.location.origin
return ""
}
function getAccessToken(): string | null {
if (typeof document === "undefined") return null
const token = document.cookie.replace(ACCESS_TOKEN_REGEXP, "$1")
return token.length ? token : null
}
async function requestJson<TResponse>(
path: string,
init?: RequestInit,
): Promise<TResponse> {
const token = getAccessToken()
const response = await fetch(`${getBaseApiUrl()}${path}`, {
credentials: "include",
...init,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init?.headers ?? {}),
},
})
if (!response.ok) {
const message = response.statusText || "Workflow request failed"
throw new WorkflowApiError(response.status, message)
}
if (response.status === 204) {
return null as TResponse
}
return (await response.json()) as TResponse
}
export function getProjectWorkspaceQueryKey(projectId: string) {
return ["project-workspace", projectId] as const
}
export async function fetchProjectWorkspace(
projectId: string,
): Promise<ProjectWorkspaceRead> {
return requestJson<ProjectWorkspaceRead>(
`/api/projects/${projectId}/workspace`,
{ method: "GET" },
)
}
export async function postWorkflowAction(
projectId: string,
action: WorkflowActionRequest,
): Promise<ProjectWorkspaceRead | null> {
return requestJson<ProjectWorkspaceRead | null>(
`/api/projects/${projectId}/workflow/actions`,
{
method: "POST",
body: JSON.stringify(action),
},
)
}
export function useProjectWorkspaceQuery(projectId: string) {
return useQuery({
queryKey: getProjectWorkspaceQueryKey(projectId),
queryFn: () => fetchProjectWorkspace(projectId),
enabled: !!projectId,
})
}
export function useWorkflowAction(projectId: string) {
const queryClient = useQueryClient()
const queryKey = getProjectWorkspaceQueryKey(projectId)
return useMutation({
mutationFn: (action: WorkflowActionRequest) =>
postWorkflowAction(projectId, action),
onSuccess: (workspace) => {
if (workspace) {
queryClient.setQueryData(queryKey, workspace)
return
}
queryClient.invalidateQueries({ queryKey })
},
onError: (error) => {
if (
error instanceof WorkflowApiError &&
error.status === 409
) {
queryClient.invalidateQueries({ queryKey })
}
},
})
}
export function isWorkflowConflictError(error: unknown): boolean {
return error instanceof WorkflowApiError && error.status === 409
}
+7
View File
@@ -14,6 +14,7 @@ import {
NotificationItem,
setNotifications,
} from "@shared/store/notifications"
import { getProjectWorkspaceQueryKey } from "@shared/api/projectWorkflow"
interface SocketContextValue {
isConnected: boolean
@@ -246,6 +247,12 @@ export const SocketProvider = ({
queryKey: ["get", "/api/files/files/"],
})
}
if (data.project_id) {
queryClient.invalidateQueries({
queryKey: getProjectWorkspaceQueryKey(data.project_id),
})
}
} catch {
// Ignore malformed messages
}
File diff suppressed because it is too large Load Diff
+156 -131
View File
@@ -13,12 +13,13 @@ import {
} from "react"
import api from "@shared/api"
import {
type WorkflowWorkspaceView,
useProjectWorkspaceQuery,
useWorkflowAction,
} from "@shared/api/projectWorkflow"
import { useDebounce } from "@shared/hooks/useDebounce"
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type SelectedFile = {
id: string
path: string
@@ -43,98 +44,182 @@ interface WorkspaceFileContextValue {
isLoaded: boolean
}
/* ------------------------------------------------------------------ */
/* Context */
/* ------------------------------------------------------------------ */
const FileContext = createContext<WorkspaceFileContextValue | null>(null)
/* ------------------------------------------------------------------ */
/* Provider */
/* ------------------------------------------------------------------ */
const DEBOUNCE_MS = 300
const DEBOUNCE_MS = 1000
function getFileIconType(mimeType: string | null | undefined) {
if (!mimeType) return "other" as const
if (mimeType.startsWith("video/")) return "video" as const
if (mimeType.startsWith("audio/")) return "audio" as const
if (mimeType.includes("json") || mimeType.startsWith("text/")) {
return "text" as const
}
return "other" as const
}
function getArtifactDisplayName(artifactType: string | null | undefined): string {
switch (artifactType) {
case "TRANSCRIPTION_JSON":
return "Субтитры"
default:
return artifactType ?? "Артефакт"
}
}
export const WorkspaceProvider: FunctionComponent<{
projectId: string
children: ReactNode
}> = ({ projectId, children }) => {
const [selectedFile, setSelectedFileState] = useState<SelectedFile | null>(
const { data: workspace } = useProjectWorkspaceQuery(projectId)
const workflowAction = useWorkflowAction(projectId)
const [usedFileIds, setUsedFileIds] = useState<string[]>([])
const [selectedPersistedId, setSelectedPersistedId] = useState<string | null>(
null,
)
const [usedFiles, setUsedFiles] = useState<UsedFile[]>([])
const isInitializedRef = useRef(false)
const initialValueRef = useRef<string | null>(null)
const [selectedFile, setSelectedFileState] = useState<SelectedFile | null>(null)
const latestRevisionRef = useRef<number | null>(null)
/* ---- Load from server ---- */
useEffect(() => {
if (!workspace) return
const { data: project, isSuccess } = api.useQuery(
"get",
"/api/projects/{project_id}/",
{ params: { path: { project_id: projectId } } },
{ enabled: !!projectId },
if (latestRevisionRef.current === workspace.revision) {
return
}
latestRevisionRef.current = workspace.revision
setUsedFileIds(workspace.workspace_view.used_file_ids)
setSelectedPersistedId(workspace.workspace_view.selected_file_id)
}, [workspace])
const { data: files } = api.useQuery("get", "/api/files/files/", {})
const { data: artifacts } = api.useQuery("get", "/api/media/artifacts/", {})
const fileMap = useMemo(() => {
const nextMap = new Map<string, UsedFile>()
for (const file of files ?? []) {
if (file.project_id !== projectId || file.is_deleted) continue
nextMap.set(file.id, {
id: file.id,
path: file.path,
source: "file",
mimeType: file.mime_type,
displayName: file.original_filename,
iconType: getFileIconType(file.mime_type),
})
}
return nextMap
}, [files, projectId])
const artifactMap = useMemo(() => {
const nextMap = new Map<string, UsedFile>()
for (const artifact of artifacts ?? []) {
if (artifact.project_id !== projectId || artifact.is_deleted) continue
nextMap.set(artifact.id, {
id: artifact.id,
path: "transcription",
source: "artifact",
artifactType: artifact.artifact_type,
displayName: getArtifactDisplayName(artifact.artifact_type),
iconType:
artifact.artifact_type === "TRANSCRIPTION_JSON" ? "text" : "other",
})
}
return nextMap
}, [artifacts, projectId])
const resolveUsedFile = useCallback(
(fileId: string, previous?: UsedFile | null): UsedFile | null => {
return fileMap.get(fileId) ?? artifactMap.get(fileId) ?? previous ?? null
},
[fileMap, artifactMap],
)
const usedFiles = useMemo(
() =>
usedFileIds
.map((fileId) => resolveUsedFile(fileId))
.filter((file): file is UsedFile => file !== null),
[resolveUsedFile, usedFileIds],
)
useEffect(() => {
if (!isSuccess || isInitializedRef.current) return
setSelectedFileState((prev) => {
if (!selectedPersistedId) return null
const saved = project?.workspace_state as
| { used_files?: UsedFile[] }
| null
| undefined
const loaded = saved?.used_files ?? []
const resolved = resolveUsedFile(
selectedPersistedId,
prev as UsedFile | null,
)
if (!resolved) return prev
setUsedFiles(loaded)
initialValueRef.current = JSON.stringify(loaded)
isInitializedRef.current = true
}, [isSuccess, project])
if (prev?.id === selectedPersistedId) {
return {
...resolved,
scrollToSegmentIndex: prev.scrollToSegmentIndex,
}
}
/* ---- Save to server (debounced) ---- */
const debouncedUsedFiles = useDebounce(usedFiles, DEBOUNCE_MS)
const saveMutation = api.useMutation(
"patch",
"/api/projects/{project_id}/",
)
useEffect(() => {
if (!isInitializedRef.current) return
const serialized = JSON.stringify(debouncedUsedFiles)
if (serialized === initialValueRef.current) return
initialValueRef.current = serialized
saveMutation.mutate({
params: { path: { project_id: projectId } },
body: {
workspace_state: { used_files: debouncedUsedFiles },
},
return resolved
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedUsedFiles, projectId])
}, [resolveUsedFile, selectedPersistedId])
/* ---- Actions ---- */
const setSelectedFile = useCallback(
(file: SelectedFile | null) => setSelectedFileState(file),
[],
const persistableWorkspaceView = useMemo<WorkflowWorkspaceView>(
() => ({
used_file_ids: usedFileIds,
selected_file_id: selectedPersistedId,
}),
[selectedPersistedId, usedFileIds],
)
const debouncedWorkspaceView = useDebounce(
persistableWorkspaceView,
DEBOUNCE_MS,
)
useEffect(() => {
if (!workspace) return
const localSignature = JSON.stringify(debouncedWorkspaceView)
const serverSignature = JSON.stringify(workspace.workspace_view)
if (localSignature === serverSignature) return
void workflowAction.mutateAsync({
type: "SET_WORKSPACE_VIEW",
revision: workspace.revision,
workspace_view: debouncedWorkspaceView,
})
}, [debouncedWorkspaceView, workflowAction, workspace])
const setSelectedFile = useCallback((file: SelectedFile | null) => {
setSelectedFileState(file)
setSelectedPersistedId(file?.id ?? null)
}, [])
const addUsedFile = useCallback((file: UsedFile) => {
setUsedFiles((prev) => {
if (prev.some((f) => f.id === file.id)) return prev
return [...prev, file]
setUsedFileIds((prev) => {
if (prev.includes(file.id)) return prev
return [...prev, file.id]
})
}, [])
const removeUsedFile = useCallback((id: string) => {
setUsedFiles((prev) => prev.filter((f) => f.id !== id))
setUsedFileIds((prev) => prev.filter((fileId) => fileId !== id))
setSelectedPersistedId((prev) => (prev === id ? null : prev))
setSelectedFileState((prev) => (prev?.id === id ? null : prev))
}, [])
const isFileUsed = useCallback(
(id: string) => usedFiles.some((f) => f.id === id),
[usedFiles],
(id: string) => usedFileIds.includes(id),
[usedFileIds],
)
const value = useMemo<WorkspaceFileContextValue>(
@@ -145,82 +230,22 @@ export const WorkspaceProvider: FunctionComponent<{
addUsedFile,
removeUsedFile,
isFileUsed,
isLoaded: isInitializedRef.current,
isLoaded: Boolean(workspace),
}),
[
addUsedFile,
isFileUsed,
removeUsedFile,
selectedFile,
setSelectedFile,
usedFiles,
addUsedFile,
removeUsedFile,
isFileUsed,
workspace,
],
)
return <FileContext.Provider value={value}>{children}</FileContext.Provider>
}
/* ------------------------------------------------------------------ */
/* Static provider (in-memory only, no server persistence) */
/* ------------------------------------------------------------------ */
export const StaticWorkspaceProvider: FunctionComponent<{
children: ReactNode
}> = ({ children }) => {
const [selectedFile, setSelectedFileState] = useState<SelectedFile | null>(
null,
)
const [usedFiles, setUsedFiles] = useState<UsedFile[]>([])
const setSelectedFile = useCallback(
(file: SelectedFile | null) => setSelectedFileState(file),
[],
)
const addUsedFile = useCallback((file: UsedFile) => {
setUsedFiles((prev) => {
if (prev.some((f) => f.id === file.id)) return prev
return [...prev, file]
})
}, [])
const removeUsedFile = useCallback((id: string) => {
setUsedFiles((prev) => prev.filter((f) => f.id !== id))
}, [])
const isFileUsed = useCallback(
(id: string) => usedFiles.some((f) => f.id === id),
[usedFiles],
)
const value = useMemo<WorkspaceFileContextValue>(
() => ({
selectedFile,
setSelectedFile,
usedFiles,
addUsedFile,
removeUsedFile,
isFileUsed,
isLoaded: true,
}),
[
selectedFile,
setSelectedFile,
usedFiles,
addUsedFile,
removeUsedFile,
isFileUsed,
],
)
return <FileContext.Provider value={value}>{children}</FileContext.Provider>
}
/* ------------------------------------------------------------------ */
/* Hook */
/* ------------------------------------------------------------------ */
/** File selection & used-files list — stable during playback */
export function useWorkspaceFiles(): WorkspaceFileContextValue {
const ctx = useContext(FileContext)
if (!ctx) {