- 工作目录探测新增 pyproject.toml / requirements*.txt 扫描、uv 可用性与 can_create - 新增 POST /api/v1/python/venv/create:已有虚拟环境优先复用;pyproject 走 uv sync,requirements 走 uv venv + uv pip install,并使用默认 PyPI 镜像 - 编辑器在缺少虚拟环境且具备依赖文件时显示「用 uv 创建环境」;没有 uv 或没有依赖文件时明确报错 - 解释器字段改为可选,留空由服务端默认 python.exe 并使用虚拟环境;Python 任务缺少虚拟环境/依赖文件时运行直接失败,不再回退系统解释器 - 补后端探测/创建测试、前端入口测试与 README
362 lines
10 KiB
TypeScript
362 lines
10 KiB
TypeScript
import type { components } from './generated'
|
||
|
||
export type HealthResponse = components['schemas']['HealthResponse']
|
||
export type Task = components['schemas']['TaskResponse']
|
||
export type TaskAction = components['schemas']['TaskAction']
|
||
export type TaskActionType = components['schemas']['TaskActionType']
|
||
export type CreateTaskRequest = components['schemas']['CreateTaskRequest']
|
||
export type UpdateTaskRequest = components['schemas']['UpdateTaskRequest']
|
||
export type TaskListResponse = components['schemas']['TaskListResponse']
|
||
export type TaskRun = components['schemas']['TaskRunResponse']
|
||
export type TaskRunListResponse = components['schemas']['TaskRunListResponse']
|
||
export type RunStatus = components['schemas']['RunStatus']
|
||
export type RunTrigger = components['schemas']['RunTrigger']
|
||
export type LogChunkResponse = components['schemas']['LogChunkResponse']
|
||
export type ErrorResponse = components['schemas']['ErrorResponse']
|
||
export type SessionResponse = components['schemas']['SessionResponse']
|
||
export type CronPreviewRequest = components['schemas']['CronPreviewRequest']
|
||
export type CronPreviewResponse = components['schemas']['CronPreviewResponse']
|
||
export type VenvProbeRequest = components['schemas']['VenvProbeRequest']
|
||
export type VenvProbeResponse = components['schemas']['VenvProbeResponse']
|
||
export type CreatePythonVenvRequest =
|
||
components['schemas']['CreatePythonVenvRequest']
|
||
export type VenvProbeStatus = components['schemas']['VenvProbeStatus']
|
||
export type VenvSource = components['schemas']['VenvSource']
|
||
export type InterpreterKind = components['schemas']['InterpreterKind']
|
||
export type DirectoryEntry = components['schemas']['DirectoryEntry']
|
||
export type DirectoryListingResponse =
|
||
components['schemas']['DirectoryListingResponse']
|
||
export type TaskStats = components['schemas']['TaskStatsResponse']
|
||
export type ScheduledTask = components['schemas']['ScheduledTaskResponse']
|
||
export type PythonMirror = components['schemas']['PythonMirrorResponse']
|
||
export type CreatePythonMirrorRequest =
|
||
components['schemas']['CreatePythonMirrorRequest']
|
||
export type UpdatePythonMirrorRequest =
|
||
components['schemas']['UpdatePythonMirrorRequest']
|
||
|
||
export interface TaskFilters {
|
||
q?: string
|
||
enabled?: boolean
|
||
action_type?: TaskActionType
|
||
page?: number
|
||
page_size?: number
|
||
}
|
||
|
||
export interface RunFilters {
|
||
page?: number
|
||
page_size?: number
|
||
status?: RunStatus
|
||
trigger?: RunTrigger
|
||
}
|
||
|
||
export type ExecutionEvent =
|
||
| {
|
||
event: 'run'
|
||
data: {
|
||
run: TaskRun
|
||
}
|
||
}
|
||
| {
|
||
event: 'log'
|
||
data: {
|
||
run_id: number
|
||
offset: number
|
||
next_offset: number
|
||
content: string
|
||
}
|
||
}
|
||
|
||
export class ApiRequestError extends Error {
|
||
readonly status: number
|
||
readonly code?: string
|
||
readonly field?: string | null
|
||
readonly details?: Record<string, string>
|
||
|
||
constructor(status: number, body?: ErrorResponse) {
|
||
super(body?.message ?? `请求失败(HTTP ${status})`)
|
||
this.name = 'ApiRequestError'
|
||
this.status = status
|
||
this.code = body?.code
|
||
this.field = body?.field
|
||
this.details = body?.details
|
||
}
|
||
}
|
||
|
||
async function requestJson<T>(
|
||
path: string,
|
||
init: RequestInit = {},
|
||
): Promise<T> {
|
||
const method = (init.method ?? 'GET').toUpperCase()
|
||
const mutating = !['GET', 'HEAD', 'OPTIONS'].includes(method)
|
||
|
||
const send = async (): Promise<Response> => {
|
||
const headers = new Headers(init.headers)
|
||
headers.set('Accept', 'application/json')
|
||
if (init.body) {
|
||
headers.set('Content-Type', 'application/json')
|
||
}
|
||
if (mutating) {
|
||
headers.set('X-TaskPilot-Token', await fetchSessionToken())
|
||
}
|
||
return fetch(path, { ...init, headers })
|
||
}
|
||
|
||
let response = await send()
|
||
if (mutating && response.status === 403) {
|
||
// 后端重启会轮换管理令牌:丢弃缓存后重试一次。
|
||
invalidateSessionToken()
|
||
response = await send()
|
||
}
|
||
|
||
if (!response.ok) {
|
||
let body: ErrorResponse | undefined
|
||
try {
|
||
body = (await response.json()) as ErrorResponse
|
||
} catch {
|
||
body = undefined
|
||
}
|
||
throw new ApiRequestError(response.status, body)
|
||
}
|
||
|
||
if (response.status === 204) {
|
||
return undefined as T
|
||
}
|
||
return (await response.json()) as T
|
||
}
|
||
|
||
let cachedSessionToken: string | null = null
|
||
let pendingSessionToken: Promise<string> | null = null
|
||
|
||
export function invalidateSessionToken(): void {
|
||
cachedSessionToken = null
|
||
}
|
||
|
||
async function fetchSessionToken(): Promise<string> {
|
||
if (cachedSessionToken) {
|
||
return cachedSessionToken
|
||
}
|
||
if (!pendingSessionToken) {
|
||
pendingSessionToken = fetch('/api/v1/session', {
|
||
headers: { Accept: 'application/json' },
|
||
})
|
||
.then(async (response) => {
|
||
if (!response.ok) {
|
||
throw new ApiRequestError(response.status)
|
||
}
|
||
const session = (await response.json()) as SessionResponse
|
||
cachedSessionToken = session.token
|
||
return session.token
|
||
})
|
||
.finally(() => {
|
||
pendingSessionToken = null
|
||
})
|
||
}
|
||
return pendingSessionToken
|
||
}
|
||
|
||
function withQuery(path: string, values: object): string {
|
||
const query = new URLSearchParams()
|
||
for (const [key, value] of Object.entries(values)) {
|
||
if (value !== undefined && value !== null && value !== '') {
|
||
query.set(key, String(value))
|
||
}
|
||
}
|
||
const serialized = query.toString()
|
||
return serialized ? `${path}?${serialized}` : path
|
||
}
|
||
|
||
export function fetchHealth(signal?: AbortSignal): Promise<HealthResponse> {
|
||
return requestJson<HealthResponse>('/api/v1/health', { signal })
|
||
}
|
||
|
||
export function listTasks(
|
||
filters: TaskFilters = {},
|
||
signal?: AbortSignal,
|
||
): Promise<TaskListResponse> {
|
||
return requestJson<TaskListResponse>(
|
||
withQuery('/api/v1/tasks', filters),
|
||
{ signal },
|
||
)
|
||
}
|
||
|
||
export function getTask(id: string, signal?: AbortSignal): Promise<Task> {
|
||
return requestJson<Task>(`/api/v1/tasks/${encodeURIComponent(id)}`, {
|
||
signal,
|
||
})
|
||
}
|
||
|
||
export function createTask(payload: CreateTaskRequest): Promise<Task> {
|
||
return requestJson<Task>('/api/v1/tasks', {
|
||
method: 'POST',
|
||
body: JSON.stringify(payload),
|
||
})
|
||
}
|
||
|
||
export function updateTask(
|
||
id: string,
|
||
payload: UpdateTaskRequest,
|
||
): Promise<Task> {
|
||
return requestJson<Task>(`/api/v1/tasks/${encodeURIComponent(id)}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(payload),
|
||
})
|
||
}
|
||
|
||
export function deleteTask(id: string): Promise<void> {
|
||
return requestJson<void>(`/api/v1/tasks/${encodeURIComponent(id)}`, {
|
||
method: 'DELETE',
|
||
})
|
||
}
|
||
|
||
export function setTaskEnabled(
|
||
id: string,
|
||
enabled: boolean,
|
||
): Promise<Task> {
|
||
const action = enabled ? 'enable' : 'disable'
|
||
return requestJson<Task>(
|
||
`/api/v1/tasks/${encodeURIComponent(id)}/${action}`,
|
||
{ method: 'POST' },
|
||
)
|
||
}
|
||
|
||
export function runTask(id: string): Promise<TaskRun> {
|
||
return requestJson<TaskRun>(
|
||
`/api/v1/tasks/${encodeURIComponent(id)}/run`,
|
||
{ method: 'POST' },
|
||
)
|
||
}
|
||
|
||
export function listTaskRuns(
|
||
taskId: string,
|
||
filters: RunFilters = {},
|
||
signal?: AbortSignal,
|
||
): Promise<TaskRunListResponse> {
|
||
return requestJson<TaskRunListResponse>(
|
||
withQuery(`/api/v1/tasks/${encodeURIComponent(taskId)}/runs`, filters),
|
||
{ signal },
|
||
)
|
||
}
|
||
|
||
export function getRun(
|
||
runId: number,
|
||
signal?: AbortSignal,
|
||
): Promise<TaskRun> {
|
||
return requestJson<TaskRun>(`/api/v1/runs/${runId}`, { signal })
|
||
}
|
||
|
||
export function getRunLogs(
|
||
runId: number,
|
||
offset = 0,
|
||
signal?: AbortSignal,
|
||
): Promise<LogChunkResponse> {
|
||
return requestJson<LogChunkResponse>(
|
||
withQuery(`/api/v1/runs/${runId}/logs`, { offset }),
|
||
{ signal },
|
||
)
|
||
}
|
||
|
||
export function cancelRun(runId: number): Promise<TaskRun> {
|
||
return requestJson<TaskRun>(`/api/v1/runs/${runId}/cancel`, {
|
||
method: 'POST',
|
||
})
|
||
}
|
||
|
||
export function runLogDownloadUrl(runId: number): string {
|
||
return `/api/v1/runs/${runId}/logs/download`
|
||
}
|
||
|
||
export function previewCron(
|
||
request: CronPreviewRequest,
|
||
signal?: AbortSignal,
|
||
): Promise<CronPreviewResponse> {
|
||
return requestJson<CronPreviewResponse>('/api/v1/cron/preview', {
|
||
method: 'POST',
|
||
body: JSON.stringify(request),
|
||
signal,
|
||
})
|
||
}
|
||
|
||
export function probePythonVenv(
|
||
request: VenvProbeRequest,
|
||
signal?: AbortSignal,
|
||
): Promise<VenvProbeResponse> {
|
||
return requestJson<VenvProbeResponse>('/api/v1/python/venv', {
|
||
method: 'POST',
|
||
body: JSON.stringify(request),
|
||
signal,
|
||
})
|
||
}
|
||
|
||
/** 用 uv 为项目创建/同步 .venv;优先复用已有环境。 */
|
||
export function createPythonVenv(
|
||
request: CreatePythonVenvRequest,
|
||
): Promise<VenvProbeResponse> {
|
||
return requestJson<VenvProbeResponse>('/api/v1/python/venv/create', {
|
||
method: 'POST',
|
||
body: JSON.stringify(request),
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 列出目录下的子文件夹;不传 path 时返回盘符列表。
|
||
* 用于页面内的目录浏览器,选中后把绝对路径回填到表单。
|
||
*/
|
||
export function listDirectories(
|
||
path?: string,
|
||
signal?: AbortSignal,
|
||
): Promise<DirectoryListingResponse> {
|
||
return requestJson<DirectoryListingResponse>(
|
||
withQuery('/api/v1/system/directories', { path }),
|
||
{ signal },
|
||
)
|
||
}
|
||
|
||
/** 仪表盘计数:一次请求拿到全部指标。 */
|
||
export function fetchStats(signal?: AbortSignal): Promise<TaskStats> {
|
||
return requestJson<TaskStats>('/api/v1/stats', { signal })
|
||
}
|
||
|
||
/** 已启用任务的下次触发时间。 */
|
||
export function fetchSchedule(signal?: AbortSignal): Promise<ScheduledTask[]> {
|
||
return requestJson<ScheduledTask[]>('/api/v1/schedule', { signal })
|
||
}
|
||
|
||
/** Python 包镜像(默认镜像会在脚本任务运行时注入 pip / uv 环境变量)。 */
|
||
export function listPythonMirrors(
|
||
signal?: AbortSignal,
|
||
): Promise<PythonMirror[]> {
|
||
return requestJson<PythonMirror[]>('/api/v1/python/mirrors', { signal })
|
||
}
|
||
|
||
export function createPythonMirror(
|
||
request: CreatePythonMirrorRequest,
|
||
): Promise<PythonMirror> {
|
||
return requestJson<PythonMirror>('/api/v1/python/mirrors', {
|
||
method: 'POST',
|
||
body: JSON.stringify(request),
|
||
})
|
||
}
|
||
|
||
export function updatePythonMirror(
|
||
id: string,
|
||
request: UpdatePythonMirrorRequest,
|
||
): Promise<PythonMirror> {
|
||
return requestJson<PythonMirror>(`/api/v1/python/mirrors/${id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(request),
|
||
})
|
||
}
|
||
|
||
export function deletePythonMirror(id: string): Promise<void> {
|
||
return requestJson<void>(`/api/v1/python/mirrors/${id}`, {
|
||
method: 'DELETE',
|
||
})
|
||
}
|
||
|
||
export function setDefaultPythonMirror(id: string): Promise<PythonMirror> {
|
||
return requestJson<PythonMirror>(
|
||
`/api/v1/python/mirrors/${id}/default`,
|
||
{ method: 'POST' },
|
||
)
|
||
}
|