feat: add scoped history and artifacts
This commit is contained in:
@@ -37,6 +37,8 @@ export const answerUiSpecSchema = z.object({ root: key, elements: z.record(z.obj
|
||||
});
|
||||
|
||||
export const answerUiInputSchema = z.object({ block_id: key, fallback_text: z.string().min(1).max(4000), spec: answerUiSpecSchema }).strict();
|
||||
export const answerUiBlockSchema = answerUiInputSchema.extend({ version: z.literal("answer-ui@1") })
|
||||
.refine((block) => JSON.stringify(block).length <= 100000, "界面块过大");
|
||||
export const parseAnswerUiInput = (value: unknown) => {
|
||||
const parsed = answerUiInputSchema.safeParse(value);
|
||||
return parsed.success ? { version: "answer-ui@1" as const, ...parsed.data } : null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type InputDelivery = "steer" | "queue";
|
||||
export type ResponseInput = { id: string; text: string; delivery: InputDelivery; status: "pending" | "delivered" };
|
||||
export type ResponseInput = { id: string; text: string; delivery: InputDelivery; status: "pending" | "delivered" | "cancelled" };
|
||||
type InboxRuntime = {
|
||||
enqueueUserInput: (sessionId: string, input: ResponseInput) => Promise<void>;
|
||||
listPendingUserInputs: (sessionId: string) => Promise<ResponseInput[]>;
|
||||
@@ -19,7 +19,7 @@ export class ResponseInputTracker {
|
||||
|
||||
markReady() { this.releaseReady(); }
|
||||
close() { this.accepting = false; this.releaseReady(); }
|
||||
get hasAdditionalInput() { return this.items.size > 0; }
|
||||
get hasAdditionalInput() { return [...this.items.values()].some((input) => input.status !== "cancelled"); }
|
||||
|
||||
async restore() {
|
||||
for (const input of await this.runtime.listPendingUserInputs(this.sessionId)) this.items.set(input.id, input);
|
||||
@@ -53,8 +53,9 @@ export class ResponseInputTracker {
|
||||
|
||||
observe(type: string, data: { inboxID?: string; delivery?: InputDelivery }) {
|
||||
const input = data.inboxID ? this.items.get(data.inboxID) : undefined;
|
||||
if (!input) return;
|
||||
if (!input || input.status !== "pending") return;
|
||||
if (type === "session.inbox.delivered") input.status = "delivered";
|
||||
else if (type === "session.inbox.cancelled") input.status = "cancelled";
|
||||
else if (type === "session.inbox.delivery.changed" && data.delivery) input.delivery = data.delivery;
|
||||
else return;
|
||||
this.changed({ ...input });
|
||||
|
||||
@@ -18,6 +18,8 @@ export const responseRequestSchema = z.object({
|
||||
conversation: z.union([z.string().min(1).max(128), z.object({ id: z.string().min(1).max(128) }).strict()]).optional(),
|
||||
metadata: z.record(z.string().max(64), z.string().max(512)).refine((value) => Object.keys(value).length <= 16).optional(),
|
||||
tjwater: z.object({
|
||||
topic_id: z.string().uuid().optional(),
|
||||
client_message_id: z.string().min(1).max(128).optional(),
|
||||
delivery: z.enum(["steer", "queue"]).optional(),
|
||||
input_id: z.string().regex(/^msg_[a-f0-9]{32}$/).optional(),
|
||||
approval_mode: z.enum(["request", "always"]).default("request"),
|
||||
|
||||
Vendored
+326
-15
@@ -3318,8 +3318,8 @@ export interface paths {
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Search Fengxian places
|
||||
* @description 调用天地图地名搜索,固定上海市奉贤区范围,只返回前 10 个可定位候选,不提供分页。
|
||||
* Search project places
|
||||
* @description 调用天地图地名搜索,按当前项目 map_config.place_search 的行政区范围,只返回前 10 个可定位候选,不提供分页。
|
||||
*/
|
||||
post: operations["post_place_search_requests"];
|
||||
delete?: never;
|
||||
@@ -3918,6 +3918,26 @@ export interface paths {
|
||||
patch: operations["patch_reservoirs_y"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/reverse-geocoding-requests": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Reverse geocode a project map point
|
||||
* @description 查询 EPSG:4326 地图取点的地名和地址,复用当前项目的地名搜索配置并保留输入坐标。
|
||||
*/
|
||||
post: operations["post_reverse_geocoding_requests"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/rule-properties": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -5056,6 +5076,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/timeseries/scada-readings/latest": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* 获取SCADA设备最新数据
|
||||
* @description Read one latest persisted row per project device without applying a time window.
|
||||
*/
|
||||
get: operations["get_timeseries_scada_readings_latest"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/timeseries/scada-readings/{device_id}/field": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -5687,6 +5727,7 @@ export interface components {
|
||||
description?: string | null;
|
||||
/** Gs Workspace */
|
||||
gs_workspace: string;
|
||||
map_config?: components["schemas"]["ProjectMapConfig"];
|
||||
/** Map Extent */
|
||||
map_extent?: {
|
||||
[key: string]: unknown;
|
||||
@@ -5713,6 +5754,7 @@ export interface components {
|
||||
description?: string | null;
|
||||
/** Gs Workspace */
|
||||
gs_workspace: string;
|
||||
map_config?: components["schemas"]["ProjectMapConfig"];
|
||||
/** Map Extent */
|
||||
map_extent?: {
|
||||
[key: string]: unknown;
|
||||
@@ -5740,6 +5782,7 @@ export interface components {
|
||||
description?: string | null;
|
||||
/** Gs Workspace */
|
||||
gs_workspace?: string | null;
|
||||
map_config?: components["schemas"]["ProjectMapConfig"] | null;
|
||||
/** Map Extent */
|
||||
map_extent?: {
|
||||
[key: string]: unknown;
|
||||
@@ -5992,6 +6035,39 @@ export interface components {
|
||||
selected_report?: components["schemas"]["InvestigationReportRevision"] | null;
|
||||
task: components["schemas"]["OperationalTaskDetail"];
|
||||
};
|
||||
/** AnswerUiBlock */
|
||||
AnswerUiBlock: {
|
||||
/** Block Id */
|
||||
block_id: string;
|
||||
/** Fallback Text */
|
||||
fallback_text: string;
|
||||
spec: components["schemas"]["AnswerUiSpec"];
|
||||
/**
|
||||
* Version
|
||||
* @constant
|
||||
*/
|
||||
version: "answer-ui@1";
|
||||
};
|
||||
/** AnswerUiElement */
|
||||
AnswerUiElement: {
|
||||
/** Children */
|
||||
children: string[];
|
||||
/** Props */
|
||||
props: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** Type */
|
||||
type: string;
|
||||
};
|
||||
/** AnswerUiSpec */
|
||||
AnswerUiSpec: {
|
||||
/** Elements */
|
||||
elements: {
|
||||
[key: string]: components["schemas"]["AnswerUiElement"];
|
||||
};
|
||||
/** Root */
|
||||
root: string;
|
||||
};
|
||||
/**
|
||||
* AuditLogResponse
|
||||
* @description 审计日志响应
|
||||
@@ -6914,6 +6990,8 @@ export interface components {
|
||||
stop_reason?: string | null;
|
||||
/** Title */
|
||||
title: string;
|
||||
/** Ui Blocks */
|
||||
ui_blocks?: components["schemas"]["ReportUiBlock"][];
|
||||
};
|
||||
/**
|
||||
* InvestigationReportInput
|
||||
@@ -6947,6 +7025,8 @@ export interface components {
|
||||
stop_reason?: string | null;
|
||||
/** Title */
|
||||
title: string;
|
||||
/** Ui Blocks */
|
||||
ui_blocks?: components["schemas"]["ReportUiBlock"][];
|
||||
};
|
||||
/** InvestigationReportPage */
|
||||
InvestigationReportPage: {
|
||||
@@ -7759,18 +7839,32 @@ export interface components {
|
||||
/** Total */
|
||||
total: number;
|
||||
};
|
||||
/** PlaceSearchConfig */
|
||||
PlaceSearchConfig: {
|
||||
/** Admin Code */
|
||||
admin_code: string;
|
||||
/**
|
||||
* Enabled
|
||||
* @default false
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Provider
|
||||
* @default tianditu
|
||||
* @constant
|
||||
*/
|
||||
provider: "tianditu";
|
||||
/** Region */
|
||||
region: string;
|
||||
};
|
||||
/** PlaceSearchItem */
|
||||
PlaceSearchItem: {
|
||||
/** Address */
|
||||
address: string;
|
||||
/** Category */
|
||||
category?: string | null;
|
||||
/**
|
||||
* District
|
||||
* @default 奉贤区
|
||||
* @constant
|
||||
*/
|
||||
district: "奉贤区";
|
||||
/** District */
|
||||
district: string;
|
||||
/** Id */
|
||||
id: string;
|
||||
/** Latitude */
|
||||
@@ -7789,12 +7883,8 @@ export interface components {
|
||||
PlaceSearchResponse: {
|
||||
/** Items */
|
||||
items: components["schemas"]["PlaceSearchItem"][];
|
||||
/**
|
||||
* Region
|
||||
* @default 奉贤区
|
||||
* @constant
|
||||
*/
|
||||
region: "奉贤区";
|
||||
/** Region */
|
||||
region: string;
|
||||
/** Total */
|
||||
total: number;
|
||||
/**
|
||||
@@ -7938,6 +8028,12 @@ export interface components {
|
||||
*/
|
||||
pool_min_size: number;
|
||||
};
|
||||
/** ProjectMapConfig */
|
||||
ProjectMapConfig: {
|
||||
place_search?: components["schemas"]["PlaceSearchConfig"] | null;
|
||||
} & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** ProjectMemberCreateRequest */
|
||||
ProjectMemberCreateRequest: {
|
||||
/**
|
||||
@@ -7994,6 +8090,7 @@ export interface components {
|
||||
description?: string | null;
|
||||
/** Gs Workspace */
|
||||
gs_workspace: string;
|
||||
map_config?: components["schemas"]["ProjectMapConfig"];
|
||||
/** Map Extent */
|
||||
map_extent?: {
|
||||
[key: string]: unknown;
|
||||
@@ -8110,6 +8207,19 @@ export interface components {
|
||||
/** Task Ids */
|
||||
task_ids: string[];
|
||||
};
|
||||
/** ReportUiBlock */
|
||||
ReportUiBlock: {
|
||||
block: components["schemas"]["AnswerUiBlock"];
|
||||
/** Evidence Refs */
|
||||
evidence_refs: string[];
|
||||
};
|
||||
/** ReverseGeocodingRequest */
|
||||
ReverseGeocodingRequest: {
|
||||
/** Latitude */
|
||||
latitude: number;
|
||||
/** Longitude */
|
||||
longitude: number;
|
||||
};
|
||||
/** RunSimulationManuallyByDateRest */
|
||||
RunSimulationManuallyByDateRest: {
|
||||
/**
|
||||
@@ -8155,6 +8265,46 @@ export interface components {
|
||||
/** Y */
|
||||
y?: number | null;
|
||||
};
|
||||
/**
|
||||
* ScadaLatestReadingResponse
|
||||
* @description Latest persisted observation for one project SCADA device.
|
||||
*/
|
||||
ScadaLatestReadingResponse: {
|
||||
/** Cleaned Value */
|
||||
cleaned_value?: number | null;
|
||||
/** Device Id */
|
||||
device_id: string;
|
||||
/** Device Type */
|
||||
device_type: string;
|
||||
/** Measurement Unit */
|
||||
measurement_unit: string;
|
||||
/** Monitored Value */
|
||||
monitored_value?: number | null;
|
||||
/** Observed At */
|
||||
observed_at?: string | null;
|
||||
/** Transmission Frequency */
|
||||
transmission_frequency: string;
|
||||
/** Transmission Mode */
|
||||
transmission_mode: string;
|
||||
};
|
||||
/**
|
||||
* ScadaLatestReadingsResponse
|
||||
* @description Bounded device page with each device's latest persisted observation.
|
||||
*/
|
||||
ScadaLatestReadingsResponse: {
|
||||
/** Items */
|
||||
items: components["schemas"]["ScadaLatestReadingResponse"][];
|
||||
/** Latest Observed At */
|
||||
latest_observed_at?: string | null;
|
||||
/** Limit */
|
||||
limit: number;
|
||||
/** Observed Count */
|
||||
observed_count: number;
|
||||
/** Offset */
|
||||
offset: number;
|
||||
/** Total */
|
||||
total: number;
|
||||
};
|
||||
/** ScadaReadingBatchItem */
|
||||
ScadaReadingBatchItem: {
|
||||
/** Cleaned Value */
|
||||
@@ -8494,7 +8644,7 @@ export interface components {
|
||||
*/
|
||||
queried_at: string;
|
||||
/** Source */
|
||||
source: ("workbench_runtime" | "scada_devices" | "scada_readings" | "peripheral_points" | "peripheral_work_orders" | "peripheral_observations" | "peripheral_test_data") | ("run_simulation" | "run_contaminant_simulation" | "analyze_valve_closure" | "analyze_flushing" | "analyze_pressure_regulation" | "optimize_sensor_placement" | "run_burst_detection" | "run_burst_location" | "analyze_valve_isolation" | "run_leakage_identification" | "get_element_properties" | "list_elements" | "get_network_options" | "get_network_schema" | "list_scada_devices" | "get_scada_device" | "query_scada_readings" | "query_model_baseline_series" | "get_model_baseline_at_time" | "query_model_baseline_field" | "query_analysis_series" | "query_analysis_values" | "list_analysis_runs" | "get_analysis_run" | "list_analysis_results" | "list_sensor_placement_runs" | "get_sensor_placement_run" | "query_element_history" | "get_element_gis_properties" | "get_source_gis_properties" | "get_workbench_runtime" | "get_task_context" | "query_peripheral_data" | "get_task_evidence");
|
||||
source: ("workbench_runtime" | "scada_devices" | "scada_readings" | "peripheral_points" | "peripheral_work_orders" | "peripheral_observations" | "peripheral_test_data") | ("get_mutation_status" | "query_nearby_elements" | "list_regions" | "get_region" | "get_region_nodes" | "get_node_connections" | "query_network_extent" | "list_major_nodes" | "get_model_configuration" | "list_model_configurations" | "search_project_places" | "reverse_geocode" | "get_project_info" | "export_project_model" | "get_sensor_placement_candidate" | "export_sensor_placement" | "list_task_schedules" | "get_task_run_history" | "calculate_demand_allocation" | "run_burst_simulation" | "edit_model_element" | "edit_model_configuration" | "clean_scada_readings" | "write_scada_readings" | "delete_scada_readings" | "save_simulation_scenario" | "create_sensor_placement" | "update_sensor_placement" | "create_operational_task" | "create_task_schedule" | "update_task_schedule" | "retry_operational_task" | "cancel_task_run" | "run_simulation" | "run_contaminant_simulation" | "analyze_valve_closure" | "analyze_flushing" | "analyze_pressure_regulation" | "optimize_sensor_placement" | "run_burst_detection" | "run_burst_location" | "analyze_valve_isolation" | "run_leakage_identification" | "get_element_properties" | "list_elements" | "get_network_options" | "get_network_schema" | "list_scada_devices" | "get_scada_device" | "query_scada_readings" | "query_model_baseline_series" | "get_model_baseline_at_time" | "query_model_baseline_field" | "query_analysis_series" | "query_analysis_values" | "list_analysis_runs" | "get_analysis_run" | "list_analysis_results" | "list_sensor_placement_runs" | "get_sensor_placement_run" | "query_element_history" | "get_element_gis_properties" | "get_source_gis_properties" | "get_workbench_runtime" | "get_task_context" | "query_peripheral_data" | "get_task_evidence");
|
||||
/** Source Ref */
|
||||
source_ref: string;
|
||||
/** Truncated */
|
||||
@@ -29690,6 +29840,86 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
post_reverse_geocoding_requests: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
"X-Project-Id": string;
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ReverseGeocodingRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PlaceSearchItem"];
|
||||
};
|
||||
};
|
||||
/** @description Authentication required */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Insufficient permission */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Validation error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Dependency unavailable */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_rule_properties: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -35600,6 +35830,87 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
get_timeseries_scada_readings_latest: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description 返回设备数上限 */
|
||||
limit?: number;
|
||||
/** @description 设备目录偏移量 */
|
||||
offset?: number;
|
||||
};
|
||||
header: {
|
||||
"X-Project-Id": string;
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ScadaLatestReadingsResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Authentication required */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Insufficient permission */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Validation error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Dependency unavailable */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
patch_timeseries_scada_readings_device_id_field: {
|
||||
parameters: {
|
||||
query: {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { constants } from "node:fs";
|
||||
import { open } from "node:fs/promises";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/client";
|
||||
import { config } from "../config.js";
|
||||
import { isManagedConversationWorkspace, resolveConversationWorkspace, SANDBOX_UID, SANDBOX_GID } from "../runtime/conversationWorkspace.js";
|
||||
import type { RuntimeSessionContext } from "../runtime/sessionContext.js";
|
||||
|
||||
const formats = new Map([
|
||||
["application/zip", ".zip"],
|
||||
["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".xlsx"],
|
||||
]);
|
||||
export const artifactFilename = /^mcp-[a-f0-9-]{36}\.(zip|xlsx)$/;
|
||||
|
||||
export async function openArtifactDirectory(workspace: string, sessionId: string) {
|
||||
const canonical = await resolveConversationWorkspace(workspace, config.CONVERSATION_WORKSPACE_ROOT);
|
||||
if (!isManagedConversationWorkspace(canonical, config.CONVERSATION_WORKSPACE_ROOT, sessionId)) throw new Error("Export workspace owner mismatch");
|
||||
const root = await open(canonical, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
try { return await open(`/proc/self/fd/${root.fd}/files`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); }
|
||||
finally { await root.close(); }
|
||||
}
|
||||
|
||||
export async function saveMcpArtifact(result: CallToolResult, context: RuntimeSessionContext, workspace: string): Promise<CallToolResult> {
|
||||
if (result.isError) return result;
|
||||
const output = result.structuredContent as Record<string, unknown> | undefined;
|
||||
const data = output?.data as Record<string, unknown> | undefined;
|
||||
const file = data?.file as Record<string, unknown> | undefined;
|
||||
if (!file) throw new Error("Missing export file");
|
||||
const extension = formats.get(String(file.mime_type));
|
||||
if (!extension || file.encoding !== "base64" || typeof file.data !== "string" || file.data.length > 7 * 1024 * 1024) throw new Error("Invalid export format");
|
||||
const bytes = Buffer.from(file.data, "base64");
|
||||
if (!bytes.length || bytes.length > 5 * 1024 * 1024 || bytes.length !== file.size || bytes.toString("base64") !== file.data || createHash("sha256").update(bytes).digest("hex") !== file.sha256) throw new Error("Export integrity check failed");
|
||||
const filename = `mcp-${randomUUID()}${extension}`;
|
||||
const directory = await openArtifactDirectory(workspace, context.sessionId);
|
||||
try {
|
||||
const path = `/proc/self/fd/${directory.fd}/${filename}`;
|
||||
const destination = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
try {
|
||||
await destination.writeFile(bytes);
|
||||
if (typeof process.getuid === "function" && process.getuid() === 0) await destination.chown(SANDBOX_UID, SANDBOX_GID);
|
||||
}
|
||||
finally { await destination.close(); }
|
||||
} finally { await directory.close(); }
|
||||
const url = `/api/v1/session/${encodeURIComponent(context.clientSessionId)}/files/${filename}`;
|
||||
const next = { ...output, data: { file: { name: file.name, mime_type: file.mime_type, size: bytes.length,
|
||||
sha256: file.sha256, relative_path: `files/${filename}`, download_url: url,
|
||||
markdown: `[下载 ${extension === ".zip" ? "模型文件" : "监测点清单"}](${url})`,
|
||||
} } };
|
||||
return { ...result, structuredContent: next, content: [{ type: "text", text: JSON.stringify(next) }] };
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { localQueryDescriptions, localQueryInputs } from "./local-query-tools.js
|
||||
import { allowedQueryTools } from "../runtime/query-mcp-policy.js";
|
||||
import { localEvidenceResult } from "./evidence-result.js";
|
||||
import { nativeQueryTimeout, queryTimeouts } from "./query-timeouts.js";
|
||||
import { saveMcpArtifact } from "./artifact-files.js";
|
||||
|
||||
export type LocalQueryName = typeof LOCAL_QUERY_TOOLS[number];
|
||||
const productNames = new Set<string>(PRODUCT_MCP_TOOLS);
|
||||
@@ -16,6 +17,7 @@ export function createQueryMcpHandler(options: {
|
||||
internalToken: string; productClient: Pick<McpQueryClient, "query"> | null; productTools: Tool[];
|
||||
resolveReader: (id: string) => Promise<QueryOwner | null>;
|
||||
executeLocal: (name: LocalQueryName, args: Record<string, unknown>, context: RuntimeSessionContext, signal: AbortSignal) => Promise<unknown>;
|
||||
artifactWorkspace?: (context: RuntimeSessionContext) => Promise<string>;
|
||||
}) {
|
||||
const definitions: Tool[] = [...options.productTools, ...LOCAL_QUERY_TOOLS.map((name) => ({ name, description: localQueryDescriptions[name],
|
||||
inputSchema: z.toJSONSchema(localQueryInputs[name], { io: "input" }) as Tool["inputSchema"], annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: name === "geocode" } }))];
|
||||
@@ -38,7 +40,12 @@ export function createQueryMcpHandler(options: {
|
||||
// JSON-RPC IDs may restart on a native MCP reconnect. Use a host
|
||||
// request key, stable across this client's auth/transport retries,
|
||||
// so a later query cannot collide with a saved evidence receipt.
|
||||
return localEvidenceResult(await options.productClient.query(id, owner, definition.name, query, randomUUID(), signal));
|
||||
const result = await options.productClient.query(id, owner, definition.name, query, randomUUID(), signal);
|
||||
if (["export_project_model", "export_sensor_placement"].includes(definition.name) && !result.isError) {
|
||||
if (!options.artifactWorkspace) return rejected("Export workspace unavailable");
|
||||
return await saveMcpArtifact(result, owner.context, await options.artifactWorkspace(owner.context));
|
||||
}
|
||||
return localEvidenceResult(result);
|
||||
}
|
||||
const value = await options.executeLocal(definition.name as LocalQueryName, args, owner.context, signal);
|
||||
signal.throwIfAborted();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { supplyLayerSchema } from "./task-presentation.js";
|
||||
import { answerUiBlockSchema } from "../chat/answer-ui.js";
|
||||
|
||||
const text = z.string().trim().min(1);
|
||||
export const investigationDiagramSchema = z.object({
|
||||
@@ -54,8 +55,22 @@ export const taskReportSchema = z.object({
|
||||
stop_reason: text.nullable().default(null),
|
||||
data_gaps: z.array(dataGapSchema),
|
||||
diagram: investigationDiagramSchema.nullish(),
|
||||
ui_blocks: z.array(z.object({
|
||||
block: answerUiBlockSchema,
|
||||
evidence_refs: z.array(text.max(128)).min(1).max(100),
|
||||
}).strict()).max(8).optional(),
|
||||
}).strict().superRefine((report, ctx) => {
|
||||
const invalid = (message: string) => ctx.addIssue({ code: z.ZodIssueCode.custom, message });
|
||||
const blocks = report.ui_blocks ?? [];
|
||||
if (Buffer.byteLength(JSON.stringify(blocks), "utf8") > 400000) invalid("报告交互内容超过 400 KB");
|
||||
if (new Set(blocks.map((item) => item.block.block_id)).size !== blocks.length) invalid("报告界面块编号重复");
|
||||
for (const item of blocks) {
|
||||
if (item.evidence_refs.some((ref) => !report.evidence_refs.includes(ref))) invalid("界面依据必须属于报告依据");
|
||||
for (const node of Object.values(item.block.spec.elements)) {
|
||||
if (node.type === "Evidence" && !item.evidence_refs.includes(String(node.props.evidence_id ?? "")))
|
||||
invalid("证据组件必须引用界面块的已有依据");
|
||||
}
|
||||
}
|
||||
if (new Set(report.data_gaps.map((gap) => gap.gap_id)).size !== report.data_gaps.length) invalid("数据缺口编号重复");
|
||||
const blocking = report.data_gaps.some((gap) => gap.blocking);
|
||||
if (report.outcome === "insufficient_data") {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { constants } from "node:fs";
|
||||
import { open } from "node:fs/promises";
|
||||
import type { Router } from "express";
|
||||
import { getLocalAgentContext } from "../context/localContext.js";
|
||||
import { artifactFilename, openArtifactDirectory } from "../mcp/artifact-files.js";
|
||||
import type { SessionMetadataStore } from "../sessions/metadataStore.js";
|
||||
import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
|
||||
export function registerArtifactRoutes(router: Router, options: {
|
||||
sessionMetadataStore: SessionMetadataStore;
|
||||
runtime: Pick<OpencodeRuntimeAdapter, "ensureSessionWorkspace">;
|
||||
}) {
|
||||
router.get("/session/:session_id/files/:filename", async (req, res) => {
|
||||
const filename = req.params.filename;
|
||||
if (!artifactFilename.test(filename)) { res.status(404).end(); return; }
|
||||
try {
|
||||
const record = await options.sessionMetadataStore.get(getLocalAgentContext(req), req.params.session_id);
|
||||
if (!record) { res.status(404).end(); return; }
|
||||
const workspace = await options.runtime.ensureSessionWorkspace(record.sessionId, record);
|
||||
const directory = await openArtifactDirectory(workspace, record.sessionId);
|
||||
try {
|
||||
const file = await open(`/proc/self/fd/${directory.fd}/${filename}`, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const stat = await file.stat();
|
||||
if (!stat.isFile() || stat.size > 5 * 1024 * 1024) { res.status(404).end(); return; }
|
||||
res.setHeader("Content-Type", filename.endsWith(".zip") ? "application/zip" : "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
res.setHeader("Cache-Control", "private, no-store");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
res.send(await file.readFile());
|
||||
} finally { await file.close(); }
|
||||
} finally { await directory.close(); }
|
||||
} catch { res.status(404).end(); }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import { getLocalAgentContext } from "../context/localContext.js";
|
||||
import { readPurpose, type SessionMetadataStore } from "../sessions/metadataStore.js";
|
||||
|
||||
const nameSchema = z.object({ name: z.string().trim().min(1).max(80) }).strict();
|
||||
const membershipSchema = z.object({ topic_id: z.string().uuid().nullable() }).strict();
|
||||
|
||||
export function registerChatTopicRoutes(router: Router, sessions: SessionMetadataStore) {
|
||||
router.get("/topics", async (req, res) => {
|
||||
res.json({ topics: await sessions.topics.list(getLocalAgentContext(req)) });
|
||||
});
|
||||
router.post("/topics", async (req, res) => {
|
||||
const parsed = nameSchema.safeParse(req.body);
|
||||
if (!parsed.success) { res.status(400).json({ message: "主题名称须为 1 至 80 个字符" }); return; }
|
||||
res.status(201).json(await sessions.topics.create(getLocalAgentContext(req), parsed.data.name));
|
||||
});
|
||||
router.patch("/topics/:topic_id", async (req, res) => {
|
||||
const parsed = nameSchema.safeParse(req.body);
|
||||
if (!parsed.success) { res.status(400).json({ message: "主题名称须为 1 至 80 个字符" }); return; }
|
||||
const topic = await sessions.topics.rename(getLocalAgentContext(req), req.params.topic_id, parsed.data.name);
|
||||
if (!topic) { res.status(404).json({ message: "主题不存在" }); return; }
|
||||
res.json(topic);
|
||||
});
|
||||
router.delete("/topics/:topic_id", async (req, res) => {
|
||||
// Removing a grouping never deletes conversations. Old IDs resolve as unclassified.
|
||||
const removed = await sessions.topics.remove(getLocalAgentContext(req), req.params.topic_id);
|
||||
if (!removed) { res.status(404).json({ message: "主题不存在" }); return; }
|
||||
res.status(204).end();
|
||||
});
|
||||
router.patch("/session/:session_id/topic", async (req, res) => {
|
||||
const parsed = membershipSchema.safeParse(req.body);
|
||||
if (!parsed.success) { res.status(400).json({ message: "invalid topic membership" }); return; }
|
||||
const context = getLocalAgentContext(req);
|
||||
const record = await sessions.get(context, req.params.session_id);
|
||||
if (!record || readPurpose(record) === "task_execution") { res.status(404).json({ message: "session not found" }); return; }
|
||||
if (parsed.data.topic_id && !await sessions.topics.get(context, parsed.data.topic_id)) {
|
||||
res.status(404).json({ message: "主题不存在" }); return;
|
||||
}
|
||||
await sessions.touch(record, { topicId: parsed.data.topic_id });
|
||||
res.json({ session_id: record.sessionId, topic_id: parsed.data.topic_id });
|
||||
});
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { registerResponseInputRoutes } from "./response-input-routes.js";
|
||||
import { registerMcpStatusRoutes } from "./mcpRoutes.js";
|
||||
import { registerChatSuggestionRoutes } from "./chatSuggestionRoutes.js";
|
||||
import { registerAgentMessageRoutes } from "./agent-message-routes.js";
|
||||
import { registerArtifactRoutes } from "./artifact-routes.js";
|
||||
import type { AgentMessageCoordinator } from "../chat/agent-message-coordinator.js";
|
||||
|
||||
export type BuildChatRouterOptions = {
|
||||
@@ -76,6 +77,7 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
|
||||
models: agentModelOptions,
|
||||
});
|
||||
});
|
||||
registerArtifactRoutes(chatRouter, { sessionMetadataStore, runtime });
|
||||
|
||||
chatRouter.get("/ui-registry", (_req, res) => {
|
||||
res.json(uiRegistryResponse);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { registerChatTopicRoutes } from "./chat-topic-routes.js";
|
||||
import { matchHistoryText } from "../sessions/history-search.js";
|
||||
import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
|
||||
import { type Router } from "express";
|
||||
import { z } from "zod";
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
} from "../sessions/metadataStore.js";
|
||||
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
|
||||
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
|
||||
import { HistoryCursorError, historyScope, sessionHistoryPage, sessionMessagePage } from "../sessions/history-page.js";
|
||||
import { type LearningOrchestrator } from "../learning/orchestrator.js";
|
||||
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||
import { type ChatRunState } from "./chatRunState.js";
|
||||
@@ -38,6 +41,7 @@ const createSessionPayloadSchema = z.object({
|
||||
session_id: z.string().max(128).optional(),
|
||||
parent_session_id: z.string().max(128).optional(),
|
||||
trigger_context: agentTriggerContextSchema.optional(),
|
||||
topic_id: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
const taskExecutionPayloadSchema = z.object({
|
||||
@@ -50,6 +54,19 @@ const taskExecutionPayloadSchema = z.object({
|
||||
const sessionListQuerySchema = z.object({
|
||||
purpose: z.enum(["ordinary_chat", "task_execution", "task_collaboration"]).optional(),
|
||||
task_id: z.string().uuid().optional(),
|
||||
include_collaboration: z.enum(["true", "false"]).optional(),
|
||||
q: z.string().trim().max(200).optional(),
|
||||
topic_id: z.union([z.string().uuid(), z.literal("unclassified")]).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).optional(),
|
||||
cursor: z.string().max(2048).optional(),
|
||||
});
|
||||
|
||||
const messageQuerySchema = z.object({
|
||||
message_limit: z.coerce.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
const olderMessagesQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(40),
|
||||
before: z.string().max(2048).optional(),
|
||||
});
|
||||
|
||||
const forkPayloadSchema = z.object({
|
||||
@@ -91,6 +108,7 @@ export const registerChatSessionRoutes = (
|
||||
sessionTranscriptStore,
|
||||
sessionUiStateStore,
|
||||
} = options;
|
||||
registerChatTopicRoutes(chatRouter, sessionMetadataStore);
|
||||
const { activeRuns, lastRunStatuses } = runState;
|
||||
const getSessionRunStatus = runState.getStatus;
|
||||
|
||||
@@ -191,6 +209,9 @@ export const registerChatSessionRoutes = (
|
||||
const userId = authContext.userId;
|
||||
const actorKey = authContext.actorKey;
|
||||
const projectKey = authContext.projectKey;
|
||||
if (parsed.data.topic_id && !await sessionMetadataStore.topics.get(authContext, parsed.data.topic_id)) {
|
||||
res.status(404).json({ message: "主题不存在" }); return;
|
||||
}
|
||||
const trigger = parsed.data.trigger_context;
|
||||
if (trigger && !projectId) {
|
||||
res.status(403).json({ code: "PROJECT_CONTEXT_REQUIRED", message: "任务会话必须关联项目" });
|
||||
@@ -244,6 +265,7 @@ export const registerChatSessionRoutes = (
|
||||
const { record, created } = await sessionMetadataStore.ensure({
|
||||
actorKey,
|
||||
parentSessionId: parsed.data.parent_session_id,
|
||||
topicId: parsed.data.topic_id,
|
||||
projectId,
|
||||
projectKey,
|
||||
sessionId,
|
||||
@@ -267,6 +289,7 @@ export const registerChatSessionRoutes = (
|
||||
status: record.status,
|
||||
title: record.title,
|
||||
parent_session_id: record.parentSessionId,
|
||||
topic_id: record.topicId ?? null,
|
||||
created,
|
||||
event_duplicate: false,
|
||||
trigger_id: record.triggerId,
|
||||
@@ -460,19 +483,52 @@ export const registerChatSessionRoutes = (
|
||||
const userId = authContext.userId;
|
||||
const actorKey = authContext.actorKey;
|
||||
const projectKey = authContext.projectKey;
|
||||
const records = await sessionMetadataStore.list({
|
||||
const includeCollaboration = query.data.include_collaboration === "true";
|
||||
let records = await sessionMetadataStore.list({
|
||||
actorKey,
|
||||
projectId,
|
||||
projectKey,
|
||||
userId,
|
||||
}, {
|
||||
purpose: query.data.purpose ?? "ordinary_chat",
|
||||
purpose: includeCollaboration ? undefined : query.data.purpose ?? "ordinary_chat",
|
||||
taskId: query.data.task_id,
|
||||
});
|
||||
if (includeCollaboration) records = records.filter((record) => ["ordinary_chat", "task_collaboration"].includes(readPurpose(record)));
|
||||
const topics = new Map((await sessionMetadataStore.topics.list(authContext)).map((topic) => [topic.id, topic]));
|
||||
const topicId = query.data.topic_id;
|
||||
if (topicId && topicId !== "unclassified" && !topics.has(topicId)) {
|
||||
res.status(404).json({ message: "主题不存在" }); return;
|
||||
}
|
||||
records = records.filter((record) => !topicId || (topicId === "unclassified"
|
||||
? !record.topicId || !topics.has(record.topicId) : record.topicId === topicId));
|
||||
const searchable: Array<SessionRecord & { searchSnippet?: string }> = [];
|
||||
for (const record of records) {
|
||||
const titleMatches = (record.title ?? "新对话").toLocaleLowerCase().includes((query.data.q ?? "").toLocaleLowerCase());
|
||||
const state = query.data.q && !titleMatches ? await sessionUiStateStore.read(toSessionUiStateContext(record)) : null;
|
||||
const messages = activeRuns.get(record.sessionId)?.messages ?? state?.messages ?? [];
|
||||
searchable.push({ ...record, searchSnippet: query.data.q && !titleMatches ? matchHistoryText(messages, query.data.q) : undefined });
|
||||
}
|
||||
let page;
|
||||
try {
|
||||
page = sessionHistoryPage(searchable, {
|
||||
scope: historyScope([actorKey, projectKey, userId, query.data.purpose ?? "ordinary_chat", query.data.task_id, topicId, includeCollaboration]),
|
||||
q: query.data.q, limit: query.data.limit ?? (query.data.q || query.data.cursor ? 30 : Math.max(records.length, 1)),
|
||||
cursor: query.data.cursor,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof HistoryCursorError)) throw error;
|
||||
res.status(400).json({ message: error.message });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
sessions: records.map((record) => ({
|
||||
has_more: page.has_more,
|
||||
next_cursor: page.next_cursor,
|
||||
sessions: page.sessions.map((record) => ({
|
||||
id: record.sessionId,
|
||||
title: record.title ?? "新对话",
|
||||
topic_id: record.topicId && topics.has(record.topicId) ? record.topicId : null,
|
||||
topic_name: record.topicId ? topics.get(record.topicId)?.name : undefined,
|
||||
search_snippet: record.searchSnippet,
|
||||
created_at: record.createdAt,
|
||||
updated_at: record.updatedAt,
|
||||
status: record.status,
|
||||
@@ -490,7 +546,27 @@ export const registerChatSessionRoutes = (
|
||||
});
|
||||
});
|
||||
|
||||
chatRouter.get("/session/:session_id/messages", async (req, res) => {
|
||||
const query = olderMessagesQuerySchema.safeParse(req.query);
|
||||
if (!query.success) { res.status(400).json({ message: "invalid message pagination" }); return; }
|
||||
const context = getLocalAgentContext(req);
|
||||
const record = await sessionMetadataStore.get(context, req.params.session_id);
|
||||
if (!record) { res.status(404).json({ message: "session not found" }); return; }
|
||||
const state = await sessionUiStateStore.read(toSessionUiStateContext(record));
|
||||
const messages = readPurpose(record) === "task_collaboration" ? state?.messages ?? []
|
||||
: activeRuns.get(record.sessionId)?.messages ?? state?.messages ?? [];
|
||||
try {
|
||||
res.json(sessionMessagePage(messages, { scope: historyScope([record.actorKey, record.projectKey, record.sessionId]),
|
||||
...query.data }));
|
||||
} catch (error) {
|
||||
if (!(error instanceof HistoryCursorError)) throw error;
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
chatRouter.get("/session/:session_id", async (req, res) => {
|
||||
const query = messageQuerySchema.safeParse(req.query);
|
||||
if (!query.success) { res.status(400).json({ message: "invalid message pagination" }); return; }
|
||||
const sessionId = req.params.session_id?.trim();
|
||||
const authContext = getLocalAgentContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
@@ -519,15 +595,21 @@ export const registerChatSessionRoutes = (
|
||||
const state = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(sessionRecord),
|
||||
);
|
||||
const messages = readPurpose(sessionRecord) === "task_collaboration" ? state?.messages ?? []
|
||||
: activeRuns.get(sessionRecord.sessionId)?.messages ?? state?.messages ?? [];
|
||||
const messagePayload = query.data.message_limit ? sessionMessagePage(messages, {
|
||||
scope: historyScope([sessionRecord.actorKey, sessionRecord.projectKey, sessionId]), limit: query.data.message_limit,
|
||||
}) : { messages };
|
||||
res.json({
|
||||
id: sessionRecord.sessionId,
|
||||
title: sessionRecord.title ?? "新对话",
|
||||
topic_id: sessionRecord.topicId && await sessionMetadataStore.topics.get(authContext, sessionRecord.topicId) ? sessionRecord.topicId : null,
|
||||
is_title_manually_edited: state?.isTitleManuallyEdited ?? false,
|
||||
created_at: sessionRecord.createdAt,
|
||||
updated_at: sessionRecord.updatedAt,
|
||||
status: sessionRecord.status,
|
||||
session_id: sessionRecord.sessionId,
|
||||
messages: state?.messages ?? [],
|
||||
...messagePayload,
|
||||
execution_plans: state?.executionPlans ?? [],
|
||||
parent_session_id: sessionRecord.parentSessionId,
|
||||
is_streaming: activeRuns.get(sessionRecord.sessionId)?.status === "running",
|
||||
@@ -538,6 +620,8 @@ export const registerChatSessionRoutes = (
|
||||
});
|
||||
|
||||
chatRouter.get("/session/:session_id/stream", async (req, res) => {
|
||||
const query = messageQuerySchema.safeParse(req.query);
|
||||
if (!query.success) { res.status(400).json({ message: "invalid message pagination" }); return; }
|
||||
const sessionId = req.params.session_id?.trim();
|
||||
const authContext = getLocalAgentContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
@@ -567,12 +651,15 @@ export const registerChatSessionRoutes = (
|
||||
|
||||
const run = activeRuns.get(sessionRecord.sessionId);
|
||||
const state = await sessionUiStateStore.read(toSessionUiStateContext(sessionRecord));
|
||||
const messages = readPurpose(sessionRecord) === "task_collaboration" ? state?.messages ?? []
|
||||
: run?.messages ?? state?.messages ?? [];
|
||||
const messagePayload = query.data.message_limit ? sessionMessagePage(messages, {
|
||||
scope: historyScope([sessionRecord.actorKey, sessionRecord.projectKey, sessionId]), limit: query.data.message_limit,
|
||||
}) : { messages };
|
||||
res.write(
|
||||
toSse("state", {
|
||||
session_id: sessionRecord.sessionId,
|
||||
messages: readPurpose(sessionRecord) === "task_collaboration"
|
||||
? state?.messages ?? []
|
||||
: run?.messages ?? state?.messages ?? [],
|
||||
...messagePayload,
|
||||
execution_plans: run ? undefined : state?.executionPlans,
|
||||
is_streaming: run?.status === "running",
|
||||
run_status: getSessionRunStatus(sessionRecord.sessionId),
|
||||
@@ -740,6 +827,7 @@ export const registerChatSessionRoutes = (
|
||||
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
|
||||
actorKey,
|
||||
parentSessionId: sourceSessionId,
|
||||
topicId: sourceSessionRecord?.topicId ?? undefined,
|
||||
projectId,
|
||||
projectKey,
|
||||
sessionId: forkSession.id,
|
||||
|
||||
@@ -181,8 +181,8 @@ const toLegacyMessages = (messages: UIMessage[]) =>
|
||||
];
|
||||
});
|
||||
|
||||
const createFallbackUserUiMessage = (content: string): UIMessage => ({
|
||||
id: `user-${Date.now().toString(36)}`,
|
||||
const createFallbackUserUiMessage = (content: string, id?: string): UIMessage => ({
|
||||
id: id ?? `user-${randomUUID()}`,
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: content }],
|
||||
});
|
||||
@@ -326,6 +326,9 @@ export const registerChatStreamRoute = (
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (input.tjwater.topic_id && !await sessionMetadataStore.topics.get(authContext, input.tjwater.topic_id)) {
|
||||
res.status(404).json({ message: "主题不存在" }); return;
|
||||
}
|
||||
const requestedSessionId = resolveRequestedStreamSessionId({
|
||||
sessionId: parsed.data.session_id,
|
||||
promptText,
|
||||
@@ -403,6 +406,7 @@ export const registerChatStreamRoute = (
|
||||
projectId,
|
||||
projectKey,
|
||||
sessionId: binding.sessionId,
|
||||
topicId: existingSessionRecord?.topicId ?? input.tjwater.topic_id,
|
||||
userId,
|
||||
});
|
||||
const activeSessionRecord = await sessionMetadataStore.touch(ensuredSessionRecord);
|
||||
@@ -454,6 +458,10 @@ export const registerChatStreamRoute = (
|
||||
toSessionUiStateContext(activeSessionRecord),
|
||||
);
|
||||
const persistedUiMessages = toUiMessages(initialSessionState?.messages);
|
||||
if (input.tjwater.client_message_id && persistedUiMessages.some((message) => message.id === input.tjwater.client_message_id)) {
|
||||
res.status(409).json({ message: "client_message_id already exists in this conversation" });
|
||||
return;
|
||||
}
|
||||
let executionPlanHistory = initialSessionState?.executionPlans ?? readExecutionPlans(initialSessionState?.messages ?? []);
|
||||
const visiblePersistedMessages = sessionPurpose === "task_collaboration"
|
||||
? toCollaborationMessages(persistedUiMessages)
|
||||
@@ -466,7 +474,7 @@ export const registerChatStreamRoute = (
|
||||
? visibleRequestMessages
|
||||
: [
|
||||
...visiblePersistedMessages,
|
||||
createFallbackUserUiMessage(promptText),
|
||||
createFallbackUserUiMessage(promptText, input.tjwater.client_message_id),
|
||||
];
|
||||
let latestUiMessages = requestMessages;
|
||||
const baseMessages = toLegacyMessages(requestMessages);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
|
||||
import { type McpRuntimeReport, type McpSessionPolicy } from "../runtime/mcp-config.js";
|
||||
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
|
||||
import { allowedQueryTools } from "../runtime/query-mcp-policy.js";
|
||||
import { PRODUCT_MCP_WRITE_TOOL_IDS } from "../runtime/product-mcp-tools.js";
|
||||
import type { QueryOwner } from "../product/mcp-query-client.js";
|
||||
|
||||
export function registerMcpPolicyBridge(app: Express, options: {
|
||||
@@ -24,8 +25,8 @@ export function registerMcpPolicyBridge(app: Express, options: {
|
||||
const grants = new Set(readonlyTools);
|
||||
const policy: McpSessionPolicy = {
|
||||
mode: context.learningMode === "review" ? "disabled" : !direct || context.purpose === "task_execution" ? "readonly" : "interactive",
|
||||
readonly_tools: readonlyTools,
|
||||
query_tools: allowedQueryTools(context, !direct).filter((name) => grants.has(name)),
|
||||
readonly_tools: readonlyTools.filter((name) => !PRODUCT_MCP_WRITE_TOOL_IDS.includes(name)),
|
||||
query_tools: allowedQueryTools(context, !direct).filter((name) => grants.has(name) || PRODUCT_MCP_WRITE_TOOL_IDS.includes(name)),
|
||||
};
|
||||
res.json(policy);
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ const update = z.object({ delivery: z.enum(["steer", "queue"]) }).strict();
|
||||
export function registerResponseInputRoutes(router: Router, options: {
|
||||
runtime: OpencodeRuntimeAdapter; sessionMetadataStore: SessionMetadataStore; runState: ChatRunState;
|
||||
}) {
|
||||
for (const method of ["get", "patch"] as const) {
|
||||
for (const method of ["get", "patch", "delete"] as const) {
|
||||
router[method](method === "get" ? "/session/:session_id/inputs" : "/session/:session_id/inputs/:input_id", async (req, res) => {
|
||||
const sessionId = String(req.params.session_id);
|
||||
try {
|
||||
@@ -22,6 +22,12 @@ export function registerResponseInputRoutes(router: Router, options: {
|
||||
return;
|
||||
}
|
||||
const inputId = id.parse((req.params as Record<string, string>).input_id);
|
||||
if (method === "delete") {
|
||||
const input = await options.runtime.cancelPendingUserInput(sessionId, inputId);
|
||||
options.runState.activeRuns.get(sessionId)?.inputs?.observe("session.inbox.cancelled", { inboxID: inputId });
|
||||
res.json(input);
|
||||
return;
|
||||
}
|
||||
const { delivery } = update.parse(req.body);
|
||||
const input = await options.runtime.updatePendingUserInput(sessionId, inputId, delivery);
|
||||
options.runState.activeRuns.get(sessionId)?.inputs?.observe("session.inbox.delivery.changed", { inboxID: inputId, delivery });
|
||||
|
||||
@@ -397,6 +397,25 @@ export class OpencodeRuntimeAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async cancelPendingUserInput(sessionId: string, id: string): Promise<ResponseInput> {
|
||||
const item = (await this.listPendingUserInputs(sessionId)).find((input) => input.id === id);
|
||||
if (!item) throw Object.assign(new Error("Message has already been consumed or is unavailable"), { status: 409 });
|
||||
const client = await this.ensureClient();
|
||||
try { await client.session.inbox.cancel({ sessionID: sessionId, inboxID: id }); }
|
||||
catch (error) {
|
||||
if (!(await this.listPendingUserInputs(sessionId)).some((input) => input.id === id)) {
|
||||
throw Object.assign(new Error("Message has already been consumed or is unavailable"), { status: 409 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
// Native cancel also succeeds for an already consumed ID. Check the native
|
||||
// message record so a delivery racing cancellation never restores a sent draft.
|
||||
if (await this.agentMessageState(sessionId, id) !== "missing") {
|
||||
throw Object.assign(new Error("Message has already been consumed or is unavailable"), { status: 409 });
|
||||
}
|
||||
return { ...item, status: "cancelled" };
|
||||
}
|
||||
|
||||
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) {
|
||||
const client = await this.ensureClient();
|
||||
const startedAt = Date.now();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PRODUCT_MCP_TOOL_IDS, QUERY_MCP_TOOL_IDS, queryMcpToolId } from "./product-mcp-tools.js";
|
||||
import { PRODUCT_MCP_TOOL_IDS, QUERY_MCP_AUTOALLOW_TOOL_IDS, PRODUCT_MCP_WRITE_TOOL_IDS, queryMcpToolId } from "./product-mcp-tools.js";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { normalizeMcpConfig } from "./mcp-config.js";
|
||||
@@ -54,7 +54,7 @@ export function serviceOpencodeConfig(input: Record<string, unknown>, models: Ag
|
||||
return {
|
||||
...input,
|
||||
...compileOpencodeModels(models),
|
||||
permissions: sessionWorkspacePermissions([...(input.permissions ?? []) as PermissionRule[], ...QUERY_MCP_TOOL_IDS.filter((name) => !deniedProductQuery(name, (input.permissions ?? []) as PermissionRule[])).map((action): PermissionRule => ({ action, resource: "*", effect: "allow" }))], workspaceRoot),
|
||||
permissions: sessionWorkspacePermissions([...(input.permissions ?? []) as PermissionRule[], ...QUERY_MCP_AUTOALLOW_TOOL_IDS.filter((name) => !deniedProductQuery(name, (input.permissions ?? []) as PermissionRule[])).map((action): PermissionRule => ({ action, resource: "*", effect: "allow" })), ...PRODUCT_MCP_WRITE_TOOL_IDS.filter((name) => !deniedProductQuery(name, (input.permissions ?? []) as PermissionRule[])).map((action): PermissionRule => ({ action, resource: "*", effect: "ask" }))], workspaceRoot),
|
||||
snapshots: false,
|
||||
mcp: normalizeMcpConfig(input.mcp),
|
||||
plugins: ["*", ...((input.plugins ?? []) as unknown[]), resolve(".opencode/tjwater-plugin"),
|
||||
|
||||
@@ -1,36 +1,83 @@
|
||||
export const PRODUCT_MCP_READ_TOOLS = [
|
||||
"get_mutation_status",
|
||||
"export_project_model",
|
||||
"export_sensor_placement",
|
||||
"get_analysis_run",
|
||||
"get_element_gis_properties",
|
||||
"get_element_properties",
|
||||
"get_model_baseline_at_time",
|
||||
"get_model_configuration",
|
||||
"get_network_options",
|
||||
"get_network_schema",
|
||||
"get_node_connections",
|
||||
"get_project_info",
|
||||
"get_region",
|
||||
"get_region_nodes",
|
||||
"get_scada_device",
|
||||
"get_sensor_placement_candidate",
|
||||
"get_sensor_placement_run",
|
||||
"get_source_gis_properties",
|
||||
"get_task_context",
|
||||
"get_task_evidence",
|
||||
"get_task_run_history",
|
||||
"get_workbench_runtime",
|
||||
"list_analysis_results",
|
||||
"list_analysis_runs",
|
||||
"list_elements",
|
||||
"list_major_nodes",
|
||||
"list_model_configurations",
|
||||
"list_regions",
|
||||
"list_scada_devices",
|
||||
"list_sensor_placement_runs",
|
||||
"list_task_schedules",
|
||||
"query_analysis_series",
|
||||
"query_analysis_values",
|
||||
"query_element_history",
|
||||
"query_model_baseline_field",
|
||||
"query_model_baseline_series",
|
||||
"query_nearby_elements",
|
||||
"query_network_extent",
|
||||
"query_peripheral_data",
|
||||
"query_scada_readings",
|
||||
"reverse_geocode",
|
||||
"search_project_places",
|
||||
] as const;
|
||||
export const PRODUCT_MCP_ANALYSIS_TOOLS = ["run_simulation", "run_burst_detection", "run_burst_location", "analyze_valve_isolation", "run_leakage_identification", "run_contaminant_simulation", "analyze_valve_closure", "analyze_flushing", "analyze_pressure_regulation", "optimize_sensor_placement"] as const;
|
||||
export const PRODUCT_MCP_TOOLS = [...PRODUCT_MCP_READ_TOOLS, ...PRODUCT_MCP_ANALYSIS_TOOLS] as const;
|
||||
export const PRODUCT_MCP_ANALYSIS_TOOLS = [
|
||||
"analyze_flushing",
|
||||
"analyze_pressure_regulation",
|
||||
"analyze_valve_closure",
|
||||
"analyze_valve_isolation",
|
||||
"calculate_demand_allocation",
|
||||
"optimize_sensor_placement",
|
||||
"run_burst_detection",
|
||||
"run_burst_location",
|
||||
"run_burst_simulation",
|
||||
"run_contaminant_simulation",
|
||||
"run_leakage_identification",
|
||||
"run_simulation",
|
||||
] as const;
|
||||
export const PRODUCT_MCP_WRITE_TOOLS = [
|
||||
"cancel_task_run",
|
||||
"clean_scada_readings",
|
||||
"create_operational_task",
|
||||
"create_sensor_placement",
|
||||
"create_task_schedule",
|
||||
"delete_scada_readings",
|
||||
"edit_model_configuration",
|
||||
"edit_model_element",
|
||||
"retry_operational_task",
|
||||
"save_simulation_scenario",
|
||||
"update_sensor_placement",
|
||||
"update_task_schedule",
|
||||
"write_scada_readings",
|
||||
] as const;
|
||||
export const PRODUCT_MCP_TOOLS = [...PRODUCT_MCP_READ_TOOLS, ...PRODUCT_MCP_ANALYSIS_TOOLS, ...PRODUCT_MCP_WRITE_TOOLS] as const;
|
||||
export const QUERY_MCP_SERVER = "mcp_tjwater";
|
||||
export const queryMcpToolId = (name: string) => `${QUERY_MCP_SERVER}_${name}`;
|
||||
export const PRODUCT_MCP_TOOL_IDS = PRODUCT_MCP_TOOLS.map(queryMcpToolId);
|
||||
export const PRODUCT_MCP_READ_TOOL_IDS = PRODUCT_MCP_READ_TOOLS.map(queryMcpToolId);
|
||||
export const PRODUCT_MCP_WRITE_TOOL_IDS = PRODUCT_MCP_WRITE_TOOLS.map(queryMcpToolId);
|
||||
export const LOCAL_QUERY_TOOLS = ["geocode", "session_search", "list_investigation_tasks", "get_investigation_task", "read_agent_messages", "todoread"] as const;
|
||||
export const LOCAL_QUERY_TOOL_IDS = LOCAL_QUERY_TOOLS.map(queryMcpToolId);
|
||||
export const QUERY_MCP_TOOL_IDS = [...PRODUCT_MCP_TOOL_IDS, ...LOCAL_QUERY_TOOL_IDS];
|
||||
export const QUERY_MCP_AUTOALLOW_TOOL_IDS = [...PRODUCT_MCP_READ_TOOL_IDS, ...PRODUCT_MCP_ANALYSIS_TOOLS.map(queryMcpToolId), ...LOCAL_QUERY_TOOL_IDS];
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { RuntimeSessionContext } from "./sessionContext.js";
|
||||
import { LOCAL_QUERY_TOOLS, PRODUCT_MCP_TOOLS, PRODUCT_MCP_READ_TOOLS, queryMcpToolId } from "./product-mcp-tools.js";
|
||||
import { LOCAL_QUERY_TOOLS, PRODUCT_MCP_TOOLS, PRODUCT_MCP_READ_TOOLS, PRODUCT_MCP_WRITE_TOOLS, queryMcpToolId } from "./product-mcp-tools.js";
|
||||
|
||||
const projectLocal = new Set(["geocode", "list_investigation_tasks", "get_investigation_task", "read_agent_messages"]);
|
||||
export function allowedQueryTools(context: RuntimeSessionContext, reader = false): string[] {
|
||||
if (context.learningMode === "review") return [];
|
||||
const project = !!context.projectId && !context.projectKey.startsWith("personal-") && ["ordinary_chat", "task_execution", "task_collaboration"].includes(context.purpose ?? "");
|
||||
if (reader) return project ? PRODUCT_MCP_READ_TOOLS.map(queryMcpToolId) : [];
|
||||
return [...(project ? PRODUCT_MCP_TOOLS : []), ...LOCAL_QUERY_TOOLS.filter((name) =>
|
||||
return [...(project ? PRODUCT_MCP_TOOLS.filter((name) => !(PRODUCT_MCP_WRITE_TOOLS as readonly string[]).includes(name) || context.purpose === "ordinary_chat") : []), ...LOCAL_QUERY_TOOLS.filter((name) =>
|
||||
(!projectLocal.has(name) || project) && !(context.purpose === "task_execution" && ["list_investigation_tasks", "get_investigation_task"].includes(name)))].map(queryMcpToolId);
|
||||
}
|
||||
|
||||
@@ -279,6 +279,7 @@ const warmupOpencodeRuntime = async () => {
|
||||
|
||||
const sandboxProbe = await probeLandlockSandbox();
|
||||
const queryMcp = startQueryMcpServer({
|
||||
artifactWorkspace: (context) => opencodeRuntime.ensureSessionWorkspace(context.sessionId, context),
|
||||
internalToken, productClient: productMcpClient,
|
||||
productTools: productMcpClient ? await productMcpClient.prepare() : [],
|
||||
resolveReader: resolveTaskReader,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export class HistoryCursorError extends Error {}
|
||||
|
||||
type Cursor = { scope: string; kind: string; position?: number; anchor?: string; updatedAt?: string; id?: string };
|
||||
|
||||
export function historyScope(value: unknown) {
|
||||
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
||||
}
|
||||
|
||||
function encode(value: Cursor) {
|
||||
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
}
|
||||
|
||||
function decode(value: string, scope: string, kind: string): Cursor {
|
||||
try {
|
||||
const cursor = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as Cursor;
|
||||
if (cursor.scope !== scope || cursor.kind !== kind) throw new Error();
|
||||
return cursor;
|
||||
} catch {
|
||||
throw new HistoryCursorError("invalid history cursor");
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionHistoryPage<T extends { sessionId: string; updatedAt: string; title?: string; searchSnippet?: string }>(
|
||||
records: T[], options: { scope: string; q?: string; limit: number; cursor?: string },
|
||||
) {
|
||||
const query = (options.q ?? "").trim().toLocaleLowerCase();
|
||||
const scope = historyScope([options.scope, query]);
|
||||
const boundary = options.cursor ? decode(options.cursor, scope, "sessions") : null;
|
||||
if (boundary && (typeof boundary.updatedAt !== "string" || typeof boundary.id !== "string")) {
|
||||
throw new HistoryCursorError("invalid history cursor");
|
||||
}
|
||||
const ordered = records.filter((record) => (record.title ?? "新对话").toLocaleLowerCase().includes(query) || Boolean(query && record.searchSnippet))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || b.sessionId.localeCompare(a.sessionId));
|
||||
const remaining = boundary ? ordered.filter((record) => record.updatedAt < boundary.updatedAt!
|
||||
|| (record.updatedAt === boundary.updatedAt && record.sessionId.localeCompare(boundary.id!) < 0)) : ordered;
|
||||
const sessions = remaining.slice(0, options.limit);
|
||||
const has_more = remaining.length > sessions.length;
|
||||
const last = sessions.at(-1);
|
||||
return { sessions, has_more, next_cursor: has_more && last
|
||||
? encode({ scope, kind: "sessions", updatedAt: last.updatedAt, id: last.sessionId }) : null };
|
||||
}
|
||||
|
||||
function messageAnchor(message: unknown) {
|
||||
if (message && typeof message === "object" && "id" in message && typeof message.id === "string") return message.id;
|
||||
return historyScope(message);
|
||||
}
|
||||
|
||||
export function sessionMessagePage(messages: unknown[], options: { scope: string; limit: number; before?: string }) {
|
||||
const boundary = options.before ? decode(options.before, options.scope, "messages") : null;
|
||||
const end = boundary?.position ?? messages.length;
|
||||
if (!Number.isInteger(end) || end < 0 || end > messages.length || (boundary &&
|
||||
(end >= messages.length || messageAnchor(messages[end]) !== boundary.anchor))) {
|
||||
throw new HistoryCursorError("message history changed; reload this conversation");
|
||||
}
|
||||
const start = Math.max(0, end - options.limit);
|
||||
return {
|
||||
messages: messages.slice(start, end),
|
||||
message_page: { has_more: start > 0, before_cursor: start > 0
|
||||
? encode({ scope: options.scope, kind: "messages", position: start, anchor: messageAnchor(messages[start]) }) : null },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Search only visible user/assistant text, never tool arguments or hidden reasoning. */
|
||||
export function matchHistoryText(messages: unknown[], query: string): string | undefined {
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
if (!needle) return;
|
||||
for (const value of messages) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
const message = value as Record<string, unknown>;
|
||||
if (message.role !== "user" && message.role !== "assistant") continue;
|
||||
const parts = Array.isArray(message.parts) ? message.parts : [message];
|
||||
for (const value of parts) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
const part = value as Record<string, unknown>;
|
||||
if (part.type !== "text" || typeof part.text !== "string") continue;
|
||||
const index = part.text.toLocaleLowerCase().indexOf(needle);
|
||||
if (index < 0) continue;
|
||||
const start = Math.max(0, index - 36);
|
||||
const end = Math.min(part.text.length, index + needle.length + 80);
|
||||
return `${start ? "…" : ""}${part.text.slice(start, end).replace(/\s+/g, " ")}${end < part.text.length ? "…" : ""}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { SessionTopicStore } from "./topic-store.js";
|
||||
import { config } from "../config.js";
|
||||
import { type SessionWorkspaceStore } from "./workspace-store.js";
|
||||
import {
|
||||
@@ -30,6 +31,7 @@ export type SessionRecord = {
|
||||
requestId?: string;
|
||||
modelId?: string;
|
||||
title?: string;
|
||||
topicId?: string | null;
|
||||
triggerId?: string;
|
||||
triggerSource?: string;
|
||||
triggerSubjectType?: string;
|
||||
@@ -63,14 +65,17 @@ type EnsureSessionMetadataInput = SessionMetadataContext & {
|
||||
runId?: string;
|
||||
requestId?: string;
|
||||
modelId?: string;
|
||||
topicId?: string;
|
||||
};
|
||||
|
||||
export class SessionMetadataStore {
|
||||
readonly topics: SessionTopicStore;
|
||||
private readonly ensureQueues = new Map<string, Promise<void>>();
|
||||
private index?: Map<string, Map<string, SessionRecord>>;
|
||||
private indexLoading?: Promise<void>;
|
||||
private indexVersion = 0;
|
||||
constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR, private readonly workspaces?: SessionWorkspaceStore) {
|
||||
this.topics = new SessionTopicStore(join(baseDir, "topics"));
|
||||
workspaces?.setDeletionCheck(async (context) => Boolean((await this.get(context, context.sessionId, { includeDeleted: true }))?.deletedAt));
|
||||
}
|
||||
|
||||
@@ -133,6 +138,7 @@ export class SessionMetadataStore {
|
||||
runId: input.runId?.trim(),
|
||||
requestId: input.requestId?.trim(),
|
||||
modelId: input.modelId?.trim(),
|
||||
topicId: input.topicId,
|
||||
...(input.trigger ? {
|
||||
triggerId: input.trigger.triggerId,
|
||||
triggerSource: input.trigger.source,
|
||||
@@ -175,7 +181,7 @@ export class SessionMetadataStore {
|
||||
|
||||
async touch(
|
||||
record: SessionRecord,
|
||||
updates: Partial<Pick<SessionRecord, "title" | "status">> = {},
|
||||
updates: Partial<Pick<SessionRecord, "title" | "status" | "topicId">> = {},
|
||||
) {
|
||||
return this.serialize(record.sessionId, async () => {
|
||||
const current = await this.get({ ...record, userId: record.ownerUserId }, record.sessionId);
|
||||
@@ -314,12 +320,13 @@ const matchesContext = (record: SessionRecord, context: SessionMetadataContext)
|
||||
(context.projectId === undefined || record.projectId === context.projectId);
|
||||
|
||||
const normalizeSessionUpdates = (
|
||||
updates: Partial<Pick<SessionRecord, "title" | "status">>,
|
||||
updates: Partial<Pick<SessionRecord, "title" | "status" | "topicId">>,
|
||||
) => {
|
||||
const normalized: Partial<Pick<SessionRecord, "title" | "status">> = {};
|
||||
const normalized: Partial<Pick<SessionRecord, "title" | "status" | "topicId">> = {};
|
||||
if (updates.status === "active" || updates.status === "archived") {
|
||||
normalized.status = updates.status;
|
||||
}
|
||||
if (updates.topicId === null || typeof updates.topicId === "string") normalized.topicId = updates.topicId;
|
||||
if (typeof updates.title === "string") {
|
||||
const trimmed = updates.title.trim();
|
||||
if (trimmed) {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { atomicWriteJson, readJsonFile } from "../utils/fileStore.js";
|
||||
|
||||
export type TopicScope = { actorKey: string; projectKey: string; userId?: string };
|
||||
export type SessionTopic = { id: string; name: string; createdAt: string; updatedAt: string };
|
||||
|
||||
/** Topics organize one user's chats within an authorized data scope. */
|
||||
export class SessionTopicStore {
|
||||
private queues = new Map<string, Promise<void>>();
|
||||
constructor(private readonly directory: string) {}
|
||||
|
||||
async list(scope: TopicScope): Promise<SessionTopic[]> {
|
||||
return (await readJsonFile<{ topics: SessionTopic[] }>(this.path(scope)))?.topics ?? [];
|
||||
}
|
||||
|
||||
async get(scope: TopicScope, id: string) {
|
||||
return (await this.list(scope)).find((topic) => topic.id === id);
|
||||
}
|
||||
|
||||
create(scope: TopicScope, name: string) {
|
||||
return this.update(scope, (topics) => {
|
||||
const now = new Date().toISOString();
|
||||
const topic = { id: randomUUID(), name, createdAt: now, updatedAt: now };
|
||||
topics.push(topic);
|
||||
return topic;
|
||||
});
|
||||
}
|
||||
|
||||
rename(scope: TopicScope, id: string, name: string) {
|
||||
return this.update(scope, (topics) => {
|
||||
const topic = topics.find((entry) => entry.id === id);
|
||||
if (topic) { topic.name = name; topic.updatedAt = new Date().toISOString(); }
|
||||
return topic;
|
||||
});
|
||||
}
|
||||
|
||||
remove(scope: TopicScope, id: string) {
|
||||
return this.update(scope, (topics) => {
|
||||
const index = topics.findIndex((topic) => topic.id === id);
|
||||
if (index < 0) return false;
|
||||
topics.splice(index, 1);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private async update<T>(scope: TopicScope, change: (topics: SessionTopic[]) => T): Promise<T> {
|
||||
const path = this.path(scope);
|
||||
const previous = this.queues.get(path) ?? Promise.resolve();
|
||||
const result = previous.catch(() => undefined).then(async () => {
|
||||
const topics = await this.list(scope);
|
||||
const value = change(topics);
|
||||
await atomicWriteJson(path, { topics }, 0o600);
|
||||
return value;
|
||||
});
|
||||
const pending = result.then(() => undefined, () => undefined);
|
||||
this.queues.set(path, pending);
|
||||
try { return await result; }
|
||||
finally { if (this.queues.get(path) === pending) this.queues.delete(path); }
|
||||
}
|
||||
|
||||
private path(scope: TopicScope) {
|
||||
const hash = createHash("sha256").update(JSON.stringify([scope.actorKey, scope.projectKey, scope.userId ?? null])).digest("hex");
|
||||
return join(this.directory, `${hash}.json`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user