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
@@ -1,7 +1,8 @@
import { getWorkbenchAccessToken } from "./request-context";
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError, type PlaceSearchConfiguration } from "./project-client";
export const MAX_PLACE_SEARCH_RESULTS = 10;
@@ -9,11 +10,11 @@ const placeSchema = z.object({
id: z.string().min(1), name: z.string().trim().min(1), address: z.string(),
longitude: z.number().finite().min(-180).max(180),
latitude: z.number().finite().min(-90).max(90),
category: z.string().nullable(), district: z.literal("奉贤区")
category: z.string().nullable(), district: z.string().trim().min(1)
});
const resultsSchema = z.object({
items: z.array(placeSchema).max(MAX_PLACE_SEARCH_RESULTS), total: z.number().int().min(0).max(MAX_PLACE_SEARCH_RESULTS),
truncated: z.boolean().default(false), region: z.literal("奉贤区")
truncated: z.boolean().default(false), region: z.string().trim().min(1)
});
export type PlaceSearchItem = z.infer<typeof placeSchema>;
export type PlaceSearchResults = z.infer<typeof resultsSchema>;
@@ -21,33 +22,70 @@ export type PlaceSearchResults = z.infer<typeof resultsSchema>;
export async function searchPlaces(
keyword: string,
projectId: string | null,
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal,
configuration?: PlaceSearchConfiguration | null
): Promise<PlaceSearchResults> {
const query = keyword.trim();
if (!query) throw new Error("请输入需要搜索的地址或地名");
if (query.length > 120) throw new Error("查询关键字不能超过120个字符");
const requestSignal = signal
? AbortSignal.any([signal, AbortSignal.timeout(20_000)])
: AbortSignal.timeout(20_000);
const ownedProject = projectId ? null : await findOwnedWorkbenchProject(getAccessToken, requestSignal);
const accessToken = ownedProject?.accessToken ?? await getAccessToken?.();
requestSignal.throwIfAborted();
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
if (!resolvedProjectId) throw new Error("未找到地名搜索所属项目");
const response = await fetch(`${env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "")}/api/v1/place-search-requests`, {
method: "POST",
headers: createWorkbenchHeaders(accessToken, resolvedProjectId, true),
body: JSON.stringify({ keyword: query }),
signal: requestSignal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "地名搜索"));
const payload = await requestPlaceData("place-search-requests", { keyword: query }, projectId, getAccessToken, signal);
const parsed = resultsSchema.safeParse(payload);
if (!parsed.success
|| new Set(parsed.data.items.map((item) => item.id)).size !== parsed.data.items.length
|| parsed.data.total !== parsed.data.items.length) {
|| parsed.data.total !== parsed.data.items.length
|| (configuration && (parsed.data.region !== configuration.region
|| (!configuration.admin_code.endsWith("00") && parsed.data.items.some((item) => item.district !== configuration.region))))) {
throw new Error("未获得有效的地名搜索结果,请稍后重试");
}
return parsed.data;
}
export async function reverseGeocodePoint(
longitude: number,
latitude: number,
projectId: string | null,
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal,
configuration?: PlaceSearchConfiguration | null
): Promise<PlaceSearchItem> {
const coordinates = z.object({
longitude: z.number().finite().min(-180).max(180),
latitude: z.number().finite().min(-90).max(90)
}).safeParse({ longitude, latitude });
if (!coordinates.success) throw new Error("地图取点坐标无效");
const payload = await requestPlaceData("reverse-geocoding-requests", coordinates.data, projectId, getAccessToken, signal);
const parsed = placeSchema.safeParse(payload);
if (!parsed.success || parsed.data.longitude !== longitude || parsed.data.latitude !== latitude
|| (configuration && !configuration.admin_code.endsWith("00") && parsed.data.district !== configuration.region)) {
throw new Error("未获得有效的地图取点结果,请重新取点");
}
return parsed.data;
}
async function requestPlaceData(
path: string,
body: Record<string, string | number>,
projectId: string | null,
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): Promise<unknown> {
const requestSignal = signal
? AbortSignal.any([signal, AbortSignal.timeout(20_000)])
: AbortSignal.timeout(20_000);
const ownedProject = projectId ? null : await findOwnedWorkbenchProject(getAccessToken, requestSignal);
const accessToken = ownedProject?.accessToken ?? await getWorkbenchAccessToken(getAccessToken);
requestSignal.throwIfAborted();
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
if (!resolvedProjectId) throw new Error("未找到地名搜索所属项目");
const response = await fetch(`${env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "")}/api/v1/${path}`, {
method: "POST",
headers: createWorkbenchHeaders(accessToken, resolvedProjectId, true),
body: JSON.stringify(body),
signal: requestSignal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "地名搜索"));
return payload;
}