feat: refine codex live chat context flows

This commit is contained in:
2026-05-08 21:15:51 +09:00
parent 82c0d8a197
commit 442879313f
92 changed files with 14815 additions and 7314 deletions

View File

@@ -1,9 +1,17 @@
import type { ChatMessagePart } from './types';
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/';
type PromptPart = Extract<ChatMessagePart, { type: 'prompt' }>;
type PromptOption = PromptPart['options'][number];
type PromptPreview = NonNullable<PromptOption['preview']>;
type PromptStep = NonNullable<PromptPart['steps']>[number];
function normalizeText(value: unknown) {
return String(value ?? '').trim();
@@ -21,6 +29,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;
}
@@ -28,6 +55,116 @@ 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 =
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 isPromptOption(value: PromptOption | null): value is PromptOption {
return value != null;
}
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(isPromptOption)
: [];
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);
@@ -135,6 +272,64 @@ 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(isPromptOption)
: [];
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[] = [];
@@ -145,7 +340,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;
@@ -157,6 +383,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) {
@@ -190,7 +425,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);