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:
2026-10-19 18:46:58 +08:00
parent 2c5bf15cce
commit ec23d44b02
131 changed files with 6663 additions and 1204 deletions
+5 -5
View File
@@ -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");
}
+4 -4
View File
@@ -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();
+1 -1
View File
@@ -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"),
+23
View File
@@ -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("未安排");
});
});
+29
View File
@@ -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)}`;
}