feat: add project-aware agent workspace
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s

This commit is contained in:
2026-10-22 18:52:18 +08:00
parent c6a0e74e72
commit 2d8506fea0
191 changed files with 12373 additions and 3556 deletions
+66 -4
View File
@@ -1,8 +1,10 @@
import { ResourceCache } from "@/shared/cache/resource-cache";
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import type { NetworkViewParams } from "../map/camera";
import { GEOSERVER_WORKSPACE } from "../map/geoserver-config";
import { getWorkbenchAccessToken, getWorkbenchProject, type WorkbenchAccess } from "./request-context";
const projectSchema = z.object({
project_id: z.string().min(1),
@@ -22,12 +24,27 @@ const projectPageSchema = z.object({
export type WorkbenchProject = z.infer<typeof projectSchema>;
const placeSearchConfigurationSchema = z.object({
enabled: z.boolean(),
provider: z.literal("tianditu"),
admin_code: z.string().regex(/^(?:156)?[0-9]{6}$/),
region: z.string().trim().min(1)
});
export type PlaceSearchConfiguration = z.infer<typeof placeSearchConfigurationSchema>;
export function parsePlaceSearchConfiguration(mapConfig: unknown): PlaceSearchConfiguration | null {
if (!mapConfig || typeof mapConfig !== "object" || !("place_search" in mapConfig)) return null;
const parsed = placeSearchConfigurationSchema.safeParse(mapConfig.place_search);
return parsed.success && parsed.data.enabled ? parsed.data : null;
}
const projectMetadataSchema = z.object({
project_id: z.string().min(1),
name: z.string().trim().min(1),
code: z.string().trim().min(1),
gs_workspace: z.string().trim().min(1),
map_extent: z.unknown().nullable().optional(),
map_config: z.unknown().optional(),
status: z.string(),
project_role: z.string().trim().min(1)
});
@@ -64,13 +81,14 @@ export type WorkbenchProjectPresentation = {
systemRole: string | null;
isSystemAdmin: boolean;
networkView: NetworkViewParams | null;
placeSearch: PlaceSearchConfiguration | null;
metadataAvailable: boolean;
accessContextAvailable: boolean;
};
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
const PROJECT_CACHE_TTL_MS = 5 * 60_000;
const projectContextPromises = new WeakMap<AccessTokenProvider, {
const projectContextPromises = new WeakMap<WorkbenchAccess, {
promise: Promise<WorkbenchProject>;
expiresAt: number;
}>();
@@ -86,11 +104,13 @@ function awaitProject(promise: Promise<WorkbenchProject>, signal?: AbortSignal)
}
export async function findOwnedWorkbenchProject(
getAccessToken?: AccessTokenProvider,
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): Promise<{ project: WorkbenchProject; accessToken: string | null | undefined }> {
const accessToken = await getAccessToken?.();
const accessToken = await getWorkbenchAccessToken(getAccessToken);
signal?.throwIfAborted();
const scopedProject = getWorkbenchProject(getAccessToken);
if (scopedProject) return { project: scopedProject, accessToken };
const existing = getAccessToken
? projectContextPromises.get(getAccessToken)
: undefined;
@@ -123,7 +143,7 @@ export async function findOwnedWorkbenchProject(
}
export async function fetchWorkbenchProjectPresentation(
getAccessToken?: AccessTokenProvider,
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): Promise<WorkbenchProjectPresentation> {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
@@ -147,11 +167,53 @@ export async function fetchWorkbenchProjectPresentation(
systemRole: accessContext?.system_role ?? null,
isSystemAdmin: accessContext?.is_system_admin ?? false,
networkView: parseProjectMapExtent(metadata?.map_extent),
placeSearch: parsePlaceSearchConfiguration(metadata?.map_config),
metadataAvailable: metadata !== null,
accessContextAvailable: accessContext !== null
};
}
const projectListCaches = new WeakMap<AccessTokenProvider, ResourceCache>();
export function clearWorkbenchProjectCache(provider?: AccessTokenProvider) {
if (provider) { projectListCaches.delete(provider); projectContextPromises.delete(provider); }
}
export async function fetchWorkbenchProjects(getAccessToken?: AccessTokenProvider, signal?: AbortSignal, force = false) {
if (!getAccessToken) return loadWorkbenchProjects(getAccessToken, signal);
let cache = projectListCaches.get(getAccessToken);
if (!cache) { cache = new ResourceCache(); projectListCaches.set(getAccessToken, cache); }
const pending = cache.load("projects", () => loadWorkbenchProjects(getAccessToken, AbortSignal.timeout(15_000)),
PROJECT_CACHE_TTL_MS, { force });
if (!signal) return pending;
signal.throwIfAborted();
return new Promise<WorkbenchProject[]>((resolve, reject) => {
const abort = () => reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
signal.addEventListener("abort", abort, { once: true });
pending.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
});
}
async function loadWorkbenchProjects(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
const token = await getAccessToken?.();
signal?.throwIfAborted();
const projects: WorkbenchProject[] = [];
let offset = 0;
while (true) {
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=${offset}`, {
headers: createWorkbenchHeaders(token), signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询"));
const page = projectPageSchema.parse(payload);
projects.push(...page.items.filter((project) => project.status === "active"));
offset += page.items.length;
if (offset >= page.total) break;
if (!page.items.length) throw new Error("项目列表分页未返回后续数据");
}
return projects;
}
export function parseProjectMapExtent(value: unknown): NetworkViewParams | null {
let bbox: z.infer<typeof bboxSchema> | null = null;
if (value && typeof value === "object" && "bbox" in value) {