feat(agent): support Responses steering and project-aware tools
Use the dedicated TJWATER_AGENT_URL and Responses stream protocol. Keep running turns active while users steer or queue follow-ups and edit pending delivery. Refresh the model catalog before generation and preserve approval mode selection. Integrate scoped investigation messaging/presentation, geographic place selection, current Agent composer styles and supporting workbench contracts. Validation: 77 focused Vitest checks, 21 browser cases, lint, typecheck and production build. Preserve direct Server/Agent routing and project authentication.
This commit is contained in:
@@ -14,7 +14,7 @@ export type AuthenticatedUser = {
|
||||
role: string;
|
||||
};
|
||||
|
||||
export type AccessTokenProvider = () => Promise<string | null>;
|
||||
export type AccessTokenProvider = (forceRefresh?: boolean) => Promise<string | null>;
|
||||
|
||||
export type Authentication = {
|
||||
enabled: boolean;
|
||||
@@ -68,9 +68,9 @@ export async function initializeAuthentication(
|
||||
throw new Error("Keycloak authentication was not completed");
|
||||
}
|
||||
|
||||
const refreshAccessToken = async () => {
|
||||
const refreshAccessToken = async (forceRefresh = false) => {
|
||||
try {
|
||||
await keycloak.updateToken(TOKEN_MIN_VALIDITY_SECONDS);
|
||||
await keycloak.updateToken(forceRefresh ? -1 : TOKEN_MIN_VALIDITY_SECONDS);
|
||||
} catch (error) {
|
||||
await login();
|
||||
throw error;
|
||||
@@ -84,8 +84,8 @@ export async function initializeAuthentication(
|
||||
return {
|
||||
enabled: true,
|
||||
user: toAuthenticatedUser(keycloak.idTokenParsed ?? keycloak.tokenParsed),
|
||||
getAccessToken: async () => {
|
||||
await refreshAccessToken();
|
||||
getAccessToken: async (forceRefresh) => {
|
||||
await refreshAccessToken(forceRefresh);
|
||||
if (!keycloak.token) {
|
||||
throw new Error("Keycloak access token is unavailable");
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("runtime frontend configuration", () => {
|
||||
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "project-a",
|
||||
TJWATER_SERVER_API_BASE_URL: "https://server.example.test",
|
||||
TJWATER_AGENT_API_BASE_URL: "https://agent.example.test",
|
||||
TJWATER_AGENT_URL: "https://agent.example.test",
|
||||
TJWATER_WORKBENCH_RUNTIME_MODE: "backend",
|
||||
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "https://peripheral.example.test/",
|
||||
TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: "recent",
|
||||
@@ -33,7 +33,7 @@ describe("runtime frontend configuration", () => {
|
||||
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "project-a",
|
||||
TJWATER_SERVER_API_BASE_URL: "https://server.example.test",
|
||||
TJWATER_AGENT_API_BASE_URL: "https://agent.example.test",
|
||||
TJWATER_AGENT_URL: "https://agent.example.test",
|
||||
TJWATER_WORKBENCH_RUNTIME_MODE: "backend",
|
||||
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "https://peripheral.example.test",
|
||||
TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: "recent",
|
||||
@@ -80,8 +80,8 @@ describe("runtime frontend configuration", () => {
|
||||
|
||||
it.each([
|
||||
["TJWATER_MAP_URL", "file:///etc/passwd"],
|
||||
["TJWATER_AGENT_API_BASE_URL", "https://user:secret@agent.example.test"],
|
||||
["TJWATER_AGENT_API_BASE_URL", "https://agent.example.test/#secret"],
|
||||
["TJWATER_AGENT_URL", "https://user:secret@agent.example.test"],
|
||||
["TJWATER_AGENT_URL", "https://agent.example.test/#secret"],
|
||||
["TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL", "https://user:secret@peripheral.example.test"]
|
||||
])("rejects unsafe browser runtime address %s", (key, value) => {
|
||||
expect(() => parseRuntimeConfig({ TJWATER_AUTH_MODE: "disabled", [key]: value })).toThrow();
|
||||
|
||||
@@ -69,7 +69,7 @@ const runtimeConfigSchema = z
|
||||
TJWATER_MAP_URL: browserHttpUrl.default("https://geoserver.waternetwork.cn/geoserver"),
|
||||
TJWATER_GEOSERVER_WORKSPACE: z.string().trim().min(1).default("tjwater_next"),
|
||||
TJWATER_SERVER_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8000"),
|
||||
TJWATER_AGENT_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8787"),
|
||||
TJWATER_AGENT_URL: browserHttpUrl.default("http://127.0.0.1:8787"),
|
||||
TJWATER_WORKBENCH_RUNTIME_MODE: z.enum(["auto", "demo", "history", "backend"]).default("auto"),
|
||||
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: optionalBrowserHttpUrl,
|
||||
TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: z.enum(["demo", "recent"]).default("demo"),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatDateTime, formatTime, formatTimeRange } from "./date-time";
|
||||
|
||||
describe("shared UTC+8 display formatting", () => {
|
||||
it("converts UTC and explicit offsets to the same display time without shifting twice", () => {
|
||||
expect(formatDateTime("2026-10-08T04:30:00Z")).toBe("2026/10/8 12:30:00");
|
||||
expect(formatDateTime("2026-10-08T12:30:00+08:00")).toBe("2026/10/8 12:30:00");
|
||||
expect(formatTime(new Date("2026-10-08T04:30:00Z"))).toBe("12:30");
|
||||
expect(formatTime(Date.parse("2026-10-08T04:30:00Z"))).toBe("12:30");
|
||||
});
|
||||
|
||||
it("rolls the calendar date forward and keeps midnight as 00:00", () => {
|
||||
expect(formatDateTime("2026-12-31T16:00:00Z")).toBe("2027/1/1 00:00:00");
|
||||
expect(formatTimeRange("2026-12-31T15:30:00Z", 60)).toBe("23:30 - 00:30");
|
||||
});
|
||||
|
||||
it("supports compact display options and preserves unparseable labels", () => {
|
||||
expect(formatDateTime("2026-10-08T04:30:00Z", {
|
||||
month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit"
|
||||
})).toBe("10/08 12:30");
|
||||
expect(formatDateTime("未安排")).toBe("未安排");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Display only: API/storage timestamps keep their original UTC instant. */
|
||||
export const DISPLAY_TIME_ZONE = "Asia/Shanghai";
|
||||
|
||||
type DateTimeValue = string | number | Date;
|
||||
type DisplayOptions = Omit<Intl.DateTimeFormatOptions, "timeZone" | "hour12">;
|
||||
|
||||
export function formatDateTime(value: DateTimeValue, options: DisplayOptions = {
|
||||
year: "numeric", month: "numeric", day: "numeric",
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit"
|
||||
}) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
...options, timeZone: DISPLAY_TIME_ZONE, hour12: false
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function formatTime(value: DateTimeValue) {
|
||||
return formatDateTime(value, { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function formatTimeRange(value: string, durationMinutes: number | undefined) {
|
||||
const startTime = formatTime(value);
|
||||
const timestamp = Date.parse(value);
|
||||
if (typeof durationMinutes !== "number" || !Number.isFinite(durationMinutes) || Number.isNaN(timestamp)) {
|
||||
return startTime;
|
||||
}
|
||||
return `${startTime} - ${formatTime(timestamp + durationMinutes * 60_000)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user