Files
task_pilot/frontend/src/api/client.ts
T
CNWei 8e6b3b92b9 feat: 支持 uv 从 pyproject/requirements 创建虚拟环境
- 工作目录探测新增 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
2026-11-03 22:57:17 +08:00

362 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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' },
)
}