wip: complete rework first round

This commit is contained in:
2026-02-26 09:27:22 +01:00
parent c70f4b9154
commit affd62ca79
78 changed files with 2635 additions and 4053 deletions

View File

@@ -156,276 +156,3 @@ export const assistantPanelSpecSchema = z.object({
export type AssistantPanelElement = z.infer<typeof assistantPanelElementSchema>;
export type AssistantPanelSpec = z.infer<typeof assistantPanelSpecSchema>;
export interface AssistantResponseContent {
displayText: string;
panelSpec: AssistantPanelSpec | null;
}
function toRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function normalizeChartElement(record: Record<string, unknown>): Record<string, unknown> | null {
const chartType = record.chartType;
const normalized: Record<string, unknown> = {
type: 'chart',
chartType: chartType === 'line' || chartType === 'pie' ? chartType : 'bar',
};
if (typeof record.title === 'string' && record.title.trim().length > 0) {
normalized.title = record.title;
}
if (Array.isArray(record.series)) {
const series = record.series
.map((entry) => {
const item = toRecord(entry);
if (!item || typeof item.label !== 'string' || typeof item.value !== 'number') {
return null;
}
return { label: item.label, value: item.value };
})
.filter((entry): entry is { label: string; value: number } => Boolean(entry));
if (series.length > 0) {
normalized.series = series;
return normalized;
}
}
const dataRecord = toRecord(record.data);
if (!dataRecord) {
return normalized;
}
const labels = Array.isArray(dataRecord.labels) ? dataRecord.labels : [];
const datasets = Array.isArray(dataRecord.datasets) ? dataRecord.datasets : [];
const firstDataset = toRecord(datasets[0]);
const values = Array.isArray(firstDataset?.data) ? firstDataset?.data : [];
if (labels.length === 0 || values.length === 0) {
return normalized;
}
const series = labels
.map((label, index) => ({
label: String(label),
value: Number(values[index]),
}))
.filter((entry) => Number.isFinite(entry.value));
if (series.length === 0) {
return normalized;
}
normalized.series = series;
return normalized;
}
function normalizeTabContent(tabValue: unknown): Record<string, unknown>[] {
if (Array.isArray(tabValue)) {
return tabValue.map((entry) => normalizeElement(entry)).filter((entry): entry is Record<string, unknown> => Boolean(entry));
}
const normalized = normalizeElement(tabValue);
return normalized ? [normalized] : [];
}
function normalizeTabsElement(record: Record<string, unknown>): Record<string, unknown> | null {
const tabs = Array.isArray(record.tabs) ? record.tabs : [];
const normalizedTabs = tabs
.map((tabValue, tabIndex) => {
const tabRecord = toRecord(tabValue);
if (!tabRecord) {
return null;
}
const id = typeof tabRecord.id === 'string' && tabRecord.id.trim().length > 0
? tabRecord.id
: `tab-${tabIndex + 1}`;
const label = typeof tabRecord.label === 'string' && tabRecord.label.trim().length > 0
? tabRecord.label
: typeof tabRecord.title === 'string' && tabRecord.title.trim().length > 0
? tabRecord.title
: id;
const elements = Array.isArray(tabRecord.elements)
? normalizeTabContent(tabRecord.elements)
: normalizeTabContent(tabRecord.content);
if (elements.length === 0) {
return null;
}
return {
id,
label,
elements,
};
})
.filter((entry): entry is { id: string; label: string; elements: Record<string, unknown>[] } => Boolean(entry));
if (normalizedTabs.length === 0) {
return null;
}
return {
...record,
tabs: normalizedTabs,
};
}
function normalizeElement(value: unknown): Record<string, unknown> | null {
const record = toRecord(value);
if (!record) {
return null;
}
const type = typeof record.type === 'string' ? record.type : '';
if (type === 'text' && typeof record.content === 'string' && typeof record.text !== 'string') {
return { type: 'text', text: record.content };
}
if (type === 'markdown') {
const textValue = typeof record.content === 'string' ? record.content : typeof record.text === 'string' ? record.text : '';
if (!textValue.trim()) {
return null;
}
return {
type: 'text',
text: textValue,
};
}
if (type === 'chart') {
return normalizeChartElement(record);
}
if (type === 'tabs') {
return normalizeTabsElement(record);
}
return record;
}
function normalizeCandidate(parsed: unknown): AssistantPanelSpec | null {
const canonicalResult = assistantPanelSpecSchema.safeParse(parsed);
if (canonicalResult.success) {
return canonicalResult.data;
}
const record = toRecord(parsed);
if (!record) {
return null;
}
if (record.protocolVersion === '2.0' && record.ui) {
return normalizeCandidate(record.ui);
}
if (record.type === 'tab' && record.content) {
return normalizeCandidate(record.content);
}
if (record.type === 'tabs') {
const tabsElement = normalizeTabsElement(record);
if (!tabsElement) {
return null;
}
const asSpec = {
specVersion: '1',
elements: [tabsElement],
};
const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec);
return normalizedResult.success ? normalizedResult.data : null;
}
if (Array.isArray(record.elements)) {
const normalizedElements = record.elements
.map((element) => normalizeElement(element))
.filter((element): element is Record<string, unknown> => Boolean(element));
if (normalizedElements.length === 0) {
return null;
}
const asSpec = {
specVersion: '1',
elements: normalizedElements,
};
const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec);
return normalizedResult.success ? normalizedResult.data : null;
}
const normalizedElement = normalizeElement(record);
if (!normalizedElement) {
return null;
}
const asSpec = {
specVersion: '1',
elements: [normalizedElement],
};
const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec);
return normalizedResult.success ? normalizedResult.data : null;
}
function parseSpecCandidate(raw: string): AssistantPanelSpec | null {
try {
const parsed = JSON.parse(raw);
return normalizeCandidate(parsed);
} catch {
return null;
}
}
export function extractAssistantPanelSpec(message: string): AssistantPanelSpec | null {
return extractAssistantResponseContent(message).panelSpec;
}
export function extractAssistantResponseContent(message: string): AssistantResponseContent {
const trimmed = message.trim();
const fencedMatches = [...trimmed.matchAll(/```(?:[a-zA-Z0-9_-]+)?\s*([\s\S]*?)```/gi)];
for (const match of fencedMatches) {
const candidate = match[1]?.trim();
if (!candidate) {
continue;
}
const parsed = parseSpecCandidate(candidate);
if (parsed) {
const displayText = trimmed.replace(match[0], '').trim();
return {
displayText,
panelSpec: parsed,
};
}
}
const parsedWholeMessage = parseSpecCandidate(trimmed);
let displayText = parsedWholeMessage ? '' : trimmed;
if (parsedWholeMessage) {
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
if (parsed.protocolVersion === '2.0' && typeof parsed.assistantText === 'string') {
displayText = parsed.assistantText;
}
} catch {
// no-op
}
}
return {
displayText,
panelSpec: parsedWholeMessage,
};
}

View File

@@ -1,12 +1,10 @@
import type { ProtocolResponseEnvelope } from '../types/electron';
export interface ChatService {
createConversation: (title?: string, model?: string) => Promise<{ id: string } | null | undefined>;
sendMessage: (
conversationId: string,
message: string,
metadata?: SendMessageMetadata,
) => Promise<{ success: boolean; message?: string; envelope?: ProtocolResponseEnvelope; protocolVersion?: '2.0'; traceId?: string; warnings?: string[]; error?: string } | null | undefined>;
) => Promise<{ success: boolean; message?: string; error?: string } | null | undefined>;
}
export interface SendMessageMetadata {
@@ -29,10 +27,6 @@ export interface SendConversationMessageInput {
export interface SendConversationMessageResult {
success: boolean;
message: string;
envelope?: ProtocolResponseEnvelope;
protocolVersion?: '2.0';
traceId?: string;
warnings?: string[];
error?: string;
}
@@ -75,9 +69,5 @@ export async function sendConversationMessage(
return {
success: true,
message: result.message || '',
envelope: result.envelope,
protocolVersion: result.protocolVersion,
traceId: result.traceId,
warnings: result.warnings,
};
}

View File

@@ -1,18 +0,0 @@
import type { ProtocolResponseEnvelope } from '../types/electron';
export type ActionPolicyLevel = 'silent' | 'confirm' | 'danger';
export function buildActionPoliciesFromEnvelope(
envelope: Pick<ProtocolResponseEnvelope, 'actions' | 'needsInput'>,
): Record<string, ActionPolicyLevel> {
const policies = envelope.actions.reduce<Record<string, ActionPolicyLevel>>((accumulator, action) => {
accumulator[action.action] = action.policy;
return accumulator;
}, {});
if (envelope.needsInput.required && envelope.needsInput.fields.length > 0 && !policies.submitNeedsInput) {
policies.submitNeedsInput = 'confirm';
}
return policies;
}

View File

@@ -1,38 +0,0 @@
import type { ProtocolNeedsInputField, ProtocolResponseEnvelope } from '../types/electron';
import type { AssistantPanelElement } from './assistantPanelSpec';
function toFormField(field: ProtocolNeedsInputField): {
key: string;
label: string;
inputType: 'text' | 'textarea' | 'select' | 'checkbox' | 'date' | 'number';
placeholder?: string;
defaultValue?: string | number | boolean;
options?: Array<{ label: string; value: string }>;
required?: boolean;
} {
return {
key: field.key,
label: field.label,
inputType: field.inputType,
placeholder: field.placeholder,
defaultValue: field.defaultValue,
options: field.options,
required: field.required,
};
}
export function toClarificationElements(
needsInput: ProtocolResponseEnvelope['needsInput'],
): AssistantPanelElement[] {
if (!needsInput.required || needsInput.fields.length === 0) {
return [];
}
return [{
type: 'form',
formId: 'agui-needs-input',
submitLabel: needsInput.fields[0].label,
action: 'submitNeedsInput',
fields: needsInput.fields.map(toFormField),
}];
}