Files
next-tjwater-frontend/src/features/agent/answer-ui/answer-view-state.ts
T
jiang c0e4231b83
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s
feat: expand intelligent investigation workspace
2026-10-30 17:30:19 +08:00

128 lines
8.9 KiB
TypeScript

import type { Spec } from "@json-render/core";
// `selection` is an empty legacy v1 wire field, never an interactive row selection.
export type AnswerTableState = { type: "Table"; query: string; sort: { column: number; descending: boolean } | null; selection: number[] };
export type AnswerChartState = { type: "Chart"; view: "line" | "bar" | "table"; hidden: number[]; range: [number, number]; point: number; inspecting: boolean; table?: Omit<AnswerTableState, "type"> };
export type AnswerFormState = { type: "FollowUpForm"; values: Record<string, string> };
export type AnswerEvidenceState = { type: "Evidence"; open: boolean };
export type AnswerUiNodeState = AnswerChartState | AnswerTableState | AnswerFormState | AnswerEvidenceState;
export type AnswerViewState = { nodes: Record<string, AnswerUiNodeState>; block: { evidenceFilter: string } };
export type AnswerViewStateChange = (next: AnswerViewState, dirtyPaths: string[]) => void;
export type AnswerViewStateRecord = AnswerViewState & {
version: "answer-view-state@1"; message_id: string; block_id: string; revision: number; schema_digest: string; updated_at: string | null;
};
type Node = Spec["elements"][string];
const record = (value: unknown): Record<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
const indices = (value: unknown, length: number) => Array.isArray(value) ? [...new Set(value.filter((index): index is number => Number.isInteger(index) && index >= 0 && index < length))].slice(0, 100) : [];
const index = (value: unknown, length: number, fallback = 0) => typeof value === "number" && Number.isInteger(value) && value >= 0 && value < length ? value : fallback;
export const answerStatePointer = (value: string) => value.replace(/~/g, "~0").replace(/\//g, "~1");
/** Only the local component allowlist can contribute state. Model props stay unchanged. */
export function normalizeAnswerNodeState(node: Node, input?: unknown): AnswerUiNodeState | undefined {
const value = record(input);
const state = value.type === node.type ? value : {};
if (node.type === "Chart") {
const labels = node.props.x_data as string[];
const series = node.props.series as unknown[];
const proposed = Array.isArray(state.range) ? state.range : [];
const start = index(proposed[0], labels.length);
const end = Math.max(start, index(proposed[1], labels.length, labels.length - 1));
const hidden = indices(state.hidden, series.length);
const chart: AnswerChartState = { type: "Chart", view: ["line", "bar", "table"].includes(String(state.view)) ? state.view as AnswerChartState["view"] : node.props.chart_type as "line" | "bar",
hidden: hidden.length < series.length ? hidden : [], range: [start, end], point: index(state.point, labels.length), inspecting: state.inspecting === true };
const data = answerChartTableData(node, chart);
const table = normalizeAnswerNodeState({ type: "Table", props: data, children: [] }, { ...record(state.table), type: "Table" }) as AnswerTableState;
const { type: _type, ...tableState } = table;
chart.table = tableState;
return chart;
}
if (node.type === "Table") {
const columns = node.props.columns as unknown[];
const sort = record(state.sort);
return { type: "Table", query: typeof state.query === "string" ? state.query : "",
sort: Number.isInteger(sort.column) && Number(sort.column) >= 0 && Number(sort.column) < columns.length && typeof sort.descending === "boolean" ? { column: Number(sort.column), descending: sort.descending } : null,
selection: [] };
}
if (node.type === "FollowUpForm") {
const values = record(state.values);
return { type: "FollowUpForm", values: Object.fromEntries((node.props.fields as Array<{ name: string }>).filter(({ name }) => typeof values[name] === "string").map(({ name }) => [name, String(values[name])])) };
}
if (node.type === "Evidence") return { type: "Evidence", open: state.open === true };
}
export function answerChartTableData(node: Node, state: AnswerChartState) {
const labels = node.props.x_data as string[], series = node.props.series as Array<{ name: string; data: number[] }>;
const visible = series.filter((_, index) => !state.hidden.includes(index));
return { columns: ["时间或类别", ...visible.map(({ name }) => name)], rows: labels.slice(state.range[0], state.range[1] + 1).map((label, index) => [label, ...visible.map(({ data }) => String(data[state.range[0] + index]))]) };
}
export function normalizeAnswerViewState(spec: Spec, input?: unknown): AnswerViewState {
const state = record(input), nodes = record(state.nodes), block = record(state.block);
const kinds = Object.values(spec.elements).filter((node) => node.type === "Evidence").map((node) => node.props.kind);
return { nodes: Object.fromEntries(Object.entries(spec.elements).flatMap(([id, node]) => {
const value = normalizeAnswerNodeState(node, nodes[id]);
return value ? [[id, value]] : [];
})), block: { evidenceFilter: typeof block.evidenceFilter === "string" && kinds.includes(block.evidenceFilter) ? block.evidenceFilter : "all" } };
}
/** Migrate by explicit data identity, never by assuming old indices identify new data. */
export function migrateAnswerViewState(previous: Spec, next: Spec, input: AnswerViewState): AnswerViewState {
const state = normalizeAnswerViewState(next, input);
for (const [id, value] of Object.entries(state.nodes)) {
const before = previous.elements[id], after = next.elements[id], old = input.nodes[id];
if (!before || before.type !== after.type || !old || old.type !== value.type) continue;
if (value.type === "Table") {
if (!same(before.props.columns, after.props.columns)) value.sort = null;
}
if (value.type === "FollowUpForm") {
const fields = before.props.fields as Array<{ name: string; kind: string }>;
const nextFields = after.props.fields as Array<{ name: string; kind: string }>;
value.values = Object.fromEntries(Object.entries(value.values).filter(([name]) => fields.some((field) => field.name === name && nextFields.some((nextField) => nextField.name === name && nextField.kind === field.kind))));
}
if (value.type === "Chart" && old.type === "Chart") {
const names = (node: Node) => (node.props.series as Array<{ name: string }>).map(({ name }) => name);
if (!same(names(before), names(after))) value.hidden = [];
const previousLabels = before.props.x_data as string[], labels = after.props.x_data as string[];
if (!same(previousLabels, labels) || !same(before.props.series, after.props.series)) {
const relocate = (position: number) => {
const label = previousLabels[position];
return label !== undefined && previousLabels.filter((item) => item === label).length === 1 && labels.filter((item) => item === label).length === 1 ? labels.indexOf(label) : -1;
};
const point = relocate(old.point), start = relocate(old.range[0]), end = relocate(old.range[1]);
if (point < 0 || start < 0 || end < start) {
value.point = 0; value.range = [0, labels.length - 1]; value.inspecting = false;
} else { value.point = point; value.range = [start, end]; }
}
if (value.table) {
const oldData = answerChartTableData(before, old), newData = answerChartTableData(after, value);
if (!same(oldData.columns, newData.columns)) value.table.sort = null;
}
}
}
return state;
}
export function answerViewStateDirtyPaths(before: AnswerViewState, after: AnswerViewState): string[] {
const paths: string[] = [];
for (const [id, value] of Object.entries(after.nodes)) {
const old = before.nodes[id];
if (old && old.type !== value.type) { paths.push(`/nodes/${answerStatePointer(id)}`); continue; }
for (const [field, next] of Object.entries(value)) {
if (field === "type") continue;
const prefix = `/nodes/${answerStatePointer(id)}/${field}`;
if (field === "values" && value.type === "FollowUpForm") {
const oldValues = old?.type === "FollowUpForm" ? old.values : {};
for (const name of new Set([...Object.keys(oldValues), ...Object.keys(value.values)])) if (oldValues[name] !== value.values[name]) paths.push(`${prefix}/${answerStatePointer(name)}`);
} else if (field === "table" && value.type === "Chart" && value.table) {
const oldTable = old?.type === "Chart" ? old.table : undefined;
for (const [key, item] of Object.entries(value.table)) if (!same(oldTable?.[key as keyof typeof oldTable], item)) paths.push(`${prefix}/${key}`);
} else if (!same(old && (old as unknown as Record<string, unknown>)[field], next)) paths.push(prefix);
}
}
if (before.block.evidenceFilter !== after.block.evidenceFilter) paths.push("/block/evidenceFilter");
for (const id of Object.keys(before.nodes)) if (!Object.hasOwn(after.nodes, id)) paths.push(`/nodes/${answerStatePointer(id)}`);
return paths;
}