feat: refine codex live chat context flows
This commit is contained in:
@@ -4,12 +4,79 @@ export type ChatMessagePart =
|
||||
title: string;
|
||||
url: string;
|
||||
actionLabel?: string | null;
|
||||
}
|
||||
| {
|
||||
type: 'prompt';
|
||||
title: string;
|
||||
description?: string | null;
|
||||
submitLabel?: string | null;
|
||||
mode?: 'queue' | 'direct' | null;
|
||||
multiple?: boolean;
|
||||
responseTemplate?: string | null;
|
||||
freeTextLabel?: string | null;
|
||||
freeTextPlaceholder?: string | null;
|
||||
currentStepKey?: string | null;
|
||||
steps?: Array<{
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
submitLabel?: string | null;
|
||||
mode?: 'queue' | 'direct' | null;
|
||||
multiple?: boolean;
|
||||
optional?: boolean;
|
||||
responseTemplate?: string | null;
|
||||
freeTextLabel?: string | null;
|
||||
freeTextPlaceholder?: string | null;
|
||||
selectedValues?: string[];
|
||||
options: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
preview?:
|
||||
| {
|
||||
type: 'image' | 'markdown' | 'html' | 'resource';
|
||||
url?: string | null;
|
||||
content?: string | null;
|
||||
alt?: string | null;
|
||||
title?: string | null;
|
||||
}
|
||||
| null;
|
||||
}>;
|
||||
}>;
|
||||
readOnly?: boolean;
|
||||
selectedValues?: string[];
|
||||
resolvedBy?: 'user' | 'timeout' | 'system' | null;
|
||||
resolvedAt?: string | null;
|
||||
resultText?: string | null;
|
||||
options: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
preview?:
|
||||
| {
|
||||
type: 'image' | 'markdown' | 'html' | 'resource';
|
||||
url?: string | null;
|
||||
content?: string | null;
|
||||
alt?: string | null;
|
||||
title?: string | null;
|
||||
}
|
||||
| null;
|
||||
}>;
|
||||
};
|
||||
|
||||
type PromptPart = Extract<ChatMessagePart, { type: 'prompt' }>;
|
||||
type PromptOption = PromptPart['options'][number];
|
||||
type PromptPreview = NonNullable<PromptOption['preview']>;
|
||||
type PromptStep = NonNullable<PromptPart['steps']>[number];
|
||||
|
||||
const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\s*$/i;
|
||||
const PROMPT_LINE_PATTERN = /^\s*\[\[prompt:(.+?)\]\]\s*$/i;
|
||||
const STANDALONE_MARKDOWN_LINK_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?\[([^\]]+)\]\(([^)]+)\)\s*$/;
|
||||
const STANDALONE_URL_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?((?:https?:\/\/|\/)[^\s<>)\]]+)\s*$/i;
|
||||
const RESOURCE_PATH_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', '/.codex_chat/'] as const;
|
||||
const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
|
||||
const CHAT_DOT_CODEX_MARKER = '/.codex_chat/';
|
||||
const CHAT_PUBLIC_DOT_CODEX_MARKER = '/public/.codex_chat/';
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return String(value ?? '').trim();
|
||||
@@ -27,6 +94,25 @@ function normalizeUrl(value: string) {
|
||||
return `/${malformedResourceMatch[1]}`;
|
||||
}
|
||||
|
||||
const apiMarkerIndex = normalized.lastIndexOf(CHAT_API_RESOURCE_MARKER);
|
||||
if (apiMarkerIndex >= 0) {
|
||||
const apiPath = normalized.slice(apiMarkerIndex);
|
||||
const dotCodexIndex = apiPath.indexOf(CHAT_DOT_CODEX_MARKER);
|
||||
return dotCodexIndex >= 0
|
||||
? `${CHAT_API_RESOURCE_MARKER}${apiPath.slice(dotCodexIndex + 1)}`
|
||||
: apiPath;
|
||||
}
|
||||
|
||||
const publicDotCodexIndex = normalized.lastIndexOf(CHAT_PUBLIC_DOT_CODEX_MARKER);
|
||||
if (publicDotCodexIndex >= 0) {
|
||||
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(publicDotCodexIndex + 8)}`;
|
||||
}
|
||||
|
||||
const dotCodexIndex = normalized.lastIndexOf(CHAT_DOT_CODEX_MARKER);
|
||||
if (dotCodexIndex >= 0) {
|
||||
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(dotCodexIndex + 1)}`;
|
||||
}
|
||||
|
||||
if (/^(?:https?:\/\/|\/)/i.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
@@ -34,6 +120,114 @@ function normalizeUrl(value: string) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizePromptPreview(value: unknown): PromptPreview | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const type: 'image' | 'markdown' | 'html' | 'resource' | null =
|
||||
record.type === 'image' || record.type === 'markdown' || record.type === 'html' || record.type === 'resource'
|
||||
? record.type
|
||||
: null;
|
||||
const url = normalizeUrl(normalizeText(record.url));
|
||||
const content = String(record.content ?? '').trim() || null;
|
||||
const alt = normalizeText(record.alt) || null;
|
||||
const title = normalizeText(record.title) || null;
|
||||
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === 'image' || type === 'resource') {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
} else if (!content && !url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
url: url || null,
|
||||
content,
|
||||
alt,
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePromptOption(value: unknown): PromptOption | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const optionValue = normalizeText(record.value);
|
||||
const label = normalizeText(record.label);
|
||||
|
||||
if (!optionValue || !label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
value: optionValue,
|
||||
label,
|
||||
description: normalizeText(record.description) || null,
|
||||
preview: normalizePromptPreview(record.preview),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePromptSelectedValues(value: unknown) {
|
||||
return [
|
||||
...(Array.isArray(value) ? value : []),
|
||||
]
|
||||
.map((item) => normalizeText(item))
|
||||
.filter(Boolean)
|
||||
.filter((item, index, array) => array.indexOf(item) === index);
|
||||
}
|
||||
|
||||
function normalizePromptSteps(value: unknown): PromptStep[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.flatMap((item, index) => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const record = item as Record<string, unknown>;
|
||||
const key = normalizeText(record.key) || `step-${index + 1}`;
|
||||
const title = normalizeText(record.title);
|
||||
const options = Array.isArray(record.options)
|
||||
? record.options
|
||||
.map((option) => normalizePromptOption(option))
|
||||
.filter((option): option is PromptOption => Boolean(option))
|
||||
: [];
|
||||
|
||||
if (!title || options.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key,
|
||||
title,
|
||||
description: normalizeText(record.description) || null,
|
||||
submitLabel: normalizeText(record.submitLabel) || null,
|
||||
mode: record.mode === 'direct' || record.mode === 'queue' ? record.mode : null,
|
||||
multiple: record.multiple === true,
|
||||
optional: record.optional === true,
|
||||
responseTemplate: normalizeText(record.responseTemplate) || null,
|
||||
freeTextLabel: normalizeText(record.freeTextLabel) || null,
|
||||
freeTextPlaceholder: normalizeText(record.freeTextPlaceholder) || null,
|
||||
selectedValues: normalizePromptSelectedValues(record.selectedValues),
|
||||
options,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function decodeUrlComponentSafely(value: string) {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
@@ -141,6 +335,66 @@ function buildLinkCardPart(rawBody: string): ChatMessagePart | null {
|
||||
};
|
||||
}
|
||||
|
||||
function buildPromptPart(rawBody: string): ChatMessagePart | null {
|
||||
let parsed: unknown;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(rawBody);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const title = normalizeText(record.title);
|
||||
const options = Array.isArray(record.options)
|
||||
? record.options
|
||||
.map((item) => normalizePromptOption(item))
|
||||
.filter((option): option is PromptOption => Boolean(option))
|
||||
: [];
|
||||
const steps = normalizePromptSteps(record.steps);
|
||||
|
||||
if (!title || (options.length === 0 && steps.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mode = record.mode === 'direct' || record.mode === 'queue' ? record.mode : null;
|
||||
const selectedValues = [
|
||||
...normalizePromptSelectedValues(record.selectedValues),
|
||||
...(record.selectedValue != null ? [record.selectedValue] : []),
|
||||
]
|
||||
.map((item) => normalizeText(item))
|
||||
.filter(Boolean)
|
||||
.filter((value, index, values) => values.indexOf(value) === index);
|
||||
const resolvedBy =
|
||||
record.resolvedBy === 'user' || record.resolvedBy === 'timeout' || record.resolvedBy === 'system'
|
||||
? record.resolvedBy
|
||||
: null;
|
||||
|
||||
return {
|
||||
type: 'prompt',
|
||||
title,
|
||||
description: normalizeText(record.description) || null,
|
||||
submitLabel: normalizeText(record.submitLabel) || null,
|
||||
mode,
|
||||
multiple: record.multiple === true,
|
||||
responseTemplate: normalizeText(record.responseTemplate) || null,
|
||||
freeTextLabel: normalizeText(record.freeTextLabel) || null,
|
||||
freeTextPlaceholder: normalizeText(record.freeTextPlaceholder) || null,
|
||||
currentStepKey: normalizeText(record.currentStepKey) || null,
|
||||
steps: steps.length > 0 ? steps : undefined,
|
||||
readOnly: record.readOnly === true || selectedValues.length > 0,
|
||||
selectedValues,
|
||||
resolvedBy,
|
||||
resolvedAt: normalizeText(record.resolvedAt) || null,
|
||||
resultText: normalizeText(record.resultText) || null,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractChatMessageParts(text: string) {
|
||||
const lines = String(text ?? '').split('\n');
|
||||
const keptLines: string[] = [];
|
||||
@@ -151,7 +405,38 @@ export function extractChatMessageParts(text: string) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dedupeKey = `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}`;
|
||||
const dedupeKey =
|
||||
nextPart.type === 'link_card'
|
||||
? `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}`
|
||||
: [
|
||||
nextPart.type,
|
||||
nextPart.title,
|
||||
nextPart.options
|
||||
.map((option) =>
|
||||
[
|
||||
option.value,
|
||||
option.label,
|
||||
option.preview?.type ?? '',
|
||||
option.preview?.url ?? '',
|
||||
option.preview?.content ?? '',
|
||||
option.preview?.title ?? '',
|
||||
].join('|'),
|
||||
)
|
||||
.join(','),
|
||||
(nextPart.steps ?? [])
|
||||
.map((step) =>
|
||||
[
|
||||
step.key,
|
||||
step.title,
|
||||
step.options.map((option) => `${option.value}:${option.label}`).join(','),
|
||||
].join('|'),
|
||||
)
|
||||
.join(','),
|
||||
nextPart.selectedValues?.join(',') ?? '',
|
||||
nextPart.resolvedBy ?? '',
|
||||
nextPart.resultText ?? '',
|
||||
nextPart.readOnly === true ? 'readonly' : '',
|
||||
].join(':');
|
||||
|
||||
if (seenLinkKeys.has(dedupeKey)) {
|
||||
return true;
|
||||
@@ -163,6 +448,15 @@ export function extractChatMessageParts(text: string) {
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const promptMatched = line.match(PROMPT_LINE_PATTERN);
|
||||
|
||||
if (promptMatched) {
|
||||
if (!pushPart(buildPromptPart(promptMatched[1] ?? ''))) {
|
||||
keptLines.push(line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const matched = line.match(LINK_CARD_LINE_PATTERN);
|
||||
|
||||
if (!matched) {
|
||||
@@ -196,7 +490,7 @@ export function extractChatMessageParts(text: string) {
|
||||
}
|
||||
|
||||
const latestPart = parts.at(-1);
|
||||
if (latestPart && isInternalResourceUrl(latestPart.url)) {
|
||||
if (latestPart?.type === 'link_card' && isInternalResourceUrl(latestPart.url)) {
|
||||
parts.pop();
|
||||
seenLinkKeys.delete(`${latestPart.type}:${latestPart.title}:${latestPart.url}:${latestPart.actionLabel ?? ''}`);
|
||||
keptLines.push(latestPart.url);
|
||||
@@ -222,24 +516,29 @@ export function parseChatMessageParts(value: unknown): ChatMessagePart[] {
|
||||
}
|
||||
|
||||
const record = item as Record<string, unknown>;
|
||||
if (record.type !== 'link_card') {
|
||||
return null;
|
||||
if (record.type === 'link_card') {
|
||||
const title = normalizeText(record.title);
|
||||
const url = normalizeUrl(String(record.url ?? ''));
|
||||
const actionLabel = normalizeText(record.actionLabel) || null;
|
||||
|
||||
if (!title || !url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'link_card' as const,
|
||||
title,
|
||||
url,
|
||||
actionLabel,
|
||||
};
|
||||
}
|
||||
|
||||
const title = normalizeText(record.title);
|
||||
const url = normalizeUrl(String(record.url ?? ''));
|
||||
const actionLabel = normalizeText(record.actionLabel) || null;
|
||||
|
||||
if (!title || !url) {
|
||||
return null;
|
||||
if (record.type === 'prompt') {
|
||||
const promptPart = buildPromptPart(JSON.stringify(record));
|
||||
return promptPart;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'link_card' as const,
|
||||
title,
|
||||
url,
|
||||
actionLabel,
|
||||
};
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as ChatMessagePart[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user