@avocadostudio-ai/orchestrator-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/dist/agent/agent-context.d.ts +19 -0
- package/dist/agent/agent-context.js +67 -0
- package/dist/agent/agent-logger.d.ts +5 -0
- package/dist/agent/agent-logger.js +22 -0
- package/dist/agent/agent-loop-openai.d.ts +8 -0
- package/dist/agent/agent-loop-openai.js +172 -0
- package/dist/agent/agent-loop.d.ts +56 -0
- package/dist/agent/agent-loop.js +167 -0
- package/dist/agent/agent-provider.d.ts +28 -0
- package/dist/agent/agent-provider.js +63 -0
- package/dist/agent/agent-tools.d.ts +28 -0
- package/dist/agent/agent-tools.js +899 -0
- package/dist/agent/context/editing-guidelines.md +46 -0
- package/dist/agent/context/role.md +39 -0
- package/dist/agent/integration-prompt.d.ts +9 -0
- package/dist/agent/integration-prompt.js +154 -0
- package/dist/agent/sites-agent-context.d.ts +12 -0
- package/dist/agent/sites-agent-context.js +316 -0
- package/dist/agent/sites-agent-shared.d.ts +161 -0
- package/dist/agent/sites-agent-shared.js +1101 -0
- package/dist/agent/sites-agent-tools.d.ts +18 -0
- package/dist/agent/sites-agent-tools.js +1227 -0
- package/dist/chat/anthropic-cache.d.ts +20 -0
- package/dist/chat/anthropic-cache.js +54 -0
- package/dist/chat/anthropic-planner.d.ts +98 -0
- package/dist/chat/anthropic-planner.js +1012 -0
- package/dist/chat/changelog-coverage-validator.d.ts +37 -0
- package/dist/chat/changelog-coverage-validator.js +215 -0
- package/dist/chat/chat-pipeline-context.d.ts +211 -0
- package/dist/chat/chat-pipeline-context.js +249 -0
- package/dist/chat/chat-pipeline-deterministic.d.ts +61 -0
- package/dist/chat/chat-pipeline-deterministic.js +407 -0
- package/dist/chat/chat-pipeline-image.d.ts +86 -0
- package/dist/chat/chat-pipeline-image.js +897 -0
- package/dist/chat/chat-pipeline-shared.d.ts +69 -0
- package/dist/chat/chat-pipeline-shared.js +212 -0
- package/dist/chat/chat-pipeline-translation.d.ts +27 -0
- package/dist/chat/chat-pipeline-translation.js +417 -0
- package/dist/chat/chat-pipeline-ui.d.ts +14 -0
- package/dist/chat/chat-pipeline-ui.js +244 -0
- package/dist/chat/chat-pipeline.d.ts +99 -0
- package/dist/chat/chat-pipeline.js +3999 -0
- package/dist/chat/decomposer.d.ts +21 -0
- package/dist/chat/decomposer.js +65 -0
- package/dist/chat/gemini-planner.d.ts +70 -0
- package/dist/chat/gemini-planner.js +541 -0
- package/dist/chat/hallucination-validator.d.ts +36 -0
- package/dist/chat/hallucination-validator.js +110 -0
- package/dist/chat/locale-strings.d.ts +47 -0
- package/dist/chat/locale-strings.js +100 -0
- package/dist/chat/plan-json-schema.d.ts +133 -0
- package/dist/chat/plan-json-schema.js +112 -0
- package/dist/chat/planner-types.d.ts +120 -0
- package/dist/chat/planner-types.js +66 -0
- package/dist/chat/planner.d.ts +148 -0
- package/dist/chat/planner.js +1361 -0
- package/dist/chat/prompts.d.ts +67 -0
- package/dist/chat/prompts.js +356 -0
- package/dist/chat/provider-routing.d.ts +14 -0
- package/dist/chat/provider-routing.js +27 -0
- package/dist/chat/variation-pipeline.d.ts +135 -0
- package/dist/chat/variation-pipeline.js +837 -0
- package/dist/chat/vision-alt-generator.d.ts +35 -0
- package/dist/chat/vision-alt-generator.js +152 -0
- package/dist/cms/adapter.d.ts +62 -0
- package/dist/cms/adapter.js +1 -0
- package/dist/cms/bootstrap.d.ts +17 -0
- package/dist/cms/bootstrap.js +85 -0
- package/dist/cms/editor-api-adapter.d.ts +23 -0
- package/dist/cms/editor-api-adapter.js +71 -0
- package/dist/cms/index.d.ts +4 -0
- package/dist/cms/index.js +3 -0
- package/dist/cms/json-file-adapter.d.ts +11 -0
- package/dist/cms/json-file-adapter.js +62 -0
- package/dist/demo-mode.d.ts +59 -0
- package/dist/demo-mode.js +201 -0
- package/dist/errors.d.ts +67 -0
- package/dist/errors.js +129 -0
- package/dist/http/chat-stream-resumable.d.ts +108 -0
- package/dist/http/chat-stream-resumable.js +290 -0
- package/dist/http/chat-stream.d.ts +99 -0
- package/dist/http/chat-stream.js +92 -0
- package/dist/image/gdrive-client.d.ts +22 -0
- package/dist/image/gdrive-client.js +215 -0
- package/dist/image/image-helpers.d.ts +95 -0
- package/dist/image/image-helpers.js +488 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/jira/jira-approval.d.ts +22 -0
- package/dist/jira/jira-approval.js +51 -0
- package/dist/jira/jira-client.d.ts +44 -0
- package/dist/jira/jira-client.js +313 -0
- package/dist/jira/jira-poller.d.ts +46 -0
- package/dist/jira/jira-poller.js +184 -0
- package/dist/jira/jira-processor.d.ts +103 -0
- package/dist/jira/jira-processor.js +1085 -0
- package/dist/jira/jira-types.d.ts +117 -0
- package/dist/jira/jira-types.js +38 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +28 -0
- package/dist/migration/mcp-server-stdio.d.ts +8 -0
- package/dist/migration/mcp-server-stdio.js +672 -0
- package/dist/migration/migration-prompt.d.ts +7 -0
- package/dist/migration/migration-prompt.js +197 -0
- package/dist/migration/migration-tools.d.ts +17 -0
- package/dist/migration/migration-tools.js +159 -0
- package/dist/migration/scrape-cache.d.ts +9 -0
- package/dist/migration/scrape-cache.js +19 -0
- package/dist/nlp/deterministic-planner-context.d.ts +141 -0
- package/dist/nlp/deterministic-planner-context.js +362 -0
- package/dist/nlp/deterministic-planner-pages.d.ts +26 -0
- package/dist/nlp/deterministic-planner-pages.js +170 -0
- package/dist/nlp/deterministic-planner-patches.d.ts +80 -0
- package/dist/nlp/deterministic-planner-patches.js +508 -0
- package/dist/nlp/deterministic-planner-refs.d.ts +33 -0
- package/dist/nlp/deterministic-planner-refs.js +164 -0
- package/dist/nlp/deterministic-planner-suggestions.d.ts +49 -0
- package/dist/nlp/deterministic-planner-suggestions.js +579 -0
- package/dist/nlp/deterministic-planner.d.ts +85 -0
- package/dist/nlp/deterministic-planner.js +1631 -0
- package/dist/nlp/intent-detection.d.ts +309 -0
- package/dist/nlp/intent-detection.js +730 -0
- package/dist/nlp/intent-helpers.d.ts +15 -0
- package/dist/nlp/intent-helpers.js +243 -0
- package/dist/nlp/intent-patterns.d.ts +40 -0
- package/dist/nlp/intent-patterns.js +223 -0
- package/dist/nlp/plan-normalizer.d.ts +41 -0
- package/dist/nlp/plan-normalizer.js +1537 -0
- package/dist/ops/destructive-action-gate.d.ts +44 -0
- package/dist/ops/destructive-action-gate.js +90 -0
- package/dist/ops/ops-engine.d.ts +151 -0
- package/dist/ops/ops-engine.js +1394 -0
- package/dist/publish/diff-engine.d.ts +18 -0
- package/dist/publish/diff-engine.js +305 -0
- package/dist/publish/publish-helpers.d.ts +87 -0
- package/dist/publish/publish-helpers.js +521 -0
- package/dist/publish/publish-target-registry.d.ts +7 -0
- package/dist/publish/publish-target-registry.js +61 -0
- package/dist/publish/publish-target.d.ts +81 -0
- package/dist/publish/publish-target.js +1 -0
- package/dist/publish/targets/deploy-hook.d.ts +13 -0
- package/dist/publish/targets/deploy-hook.js +123 -0
- package/dist/publish/targets/git.d.ts +13 -0
- package/dist/publish/targets/git.js +55 -0
- package/dist/publish/targets/site-contract.d.ts +19 -0
- package/dist/publish/targets/site-contract.js +124 -0
- package/dist/state/content-source.d.ts +17 -0
- package/dist/state/content-source.js +1 -0
- package/dist/state/in-memory-content-source.d.ts +27 -0
- package/dist/state/in-memory-content-source.js +51 -0
- package/dist/state/session-lock.d.ts +13 -0
- package/dist/state/session-lock.js +29 -0
- package/dist/state/session-state.d.ts +310 -0
- package/dist/state/session-state.js +1083 -0
- package/dist/state/sqlite-store-singleton.d.ts +31 -0
- package/dist/state/sqlite-store-singleton.js +170 -0
- package/dist/state/sqlite-store.d.ts +135 -0
- package/dist/state/sqlite-store.js +421 -0
- package/dist/telemetry/chat-telemetry.d.ts +105 -0
- package/dist/telemetry/chat-telemetry.js +247 -0
- package/dist/telemetry/eval-candidate-store.d.ts +50 -0
- package/dist/telemetry/eval-candidate-store.js +120 -0
- package/dist/telemetry/feedback-store.d.ts +34 -0
- package/dist/telemetry/feedback-store.js +76 -0
- package/dist/telemetry/jira-telemetry.d.ts +57 -0
- package/dist/telemetry/jira-telemetry.js +68 -0
- package/dist/telemetry/migration-telemetry.d.ts +35 -0
- package/dist/telemetry/migration-telemetry.js +40 -0
- package/dist/telemetry/usage.d.ts +24 -0
- package/dist/telemetry/usage.js +80 -0
- package/dist/tools/builtin-registrations.d.ts +12 -0
- package/dist/tools/builtin-registrations.js +33 -0
- package/dist/tools/builtins/gdrive-browse.d.ts +3 -0
- package/dist/tools/builtins/gdrive-browse.js +68 -0
- package/dist/tools/builtins/image-generate.d.ts +3 -0
- package/dist/tools/builtins/image-generate.js +211 -0
- package/dist/tools/builtins/unsplash-get-by-id.d.ts +23 -0
- package/dist/tools/builtins/unsplash-get-by-id.js +119 -0
- package/dist/tools/builtins/unsplash-search.d.ts +3 -0
- package/dist/tools/builtins/unsplash-search.js +74 -0
- package/dist/tools/executor.d.ts +23 -0
- package/dist/tools/executor.js +169 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/registry.d.ts +21 -0
- package/dist/tools/registry.js +75 -0
- package/dist/tools/runtime.d.ts +27 -0
- package/dist/tools/runtime.js +48 -0
- package/dist/tools/schema-validator.d.ts +24 -0
- package/dist/tools/schema-validator.js +88 -0
- package/dist/tools/types.d.ts +86 -0
- package/dist/tools/types.js +1 -0
- package/dist/variation-images.d.ts +19 -0
- package/dist/variation-images.js +12 -0
- package/package.json +78 -0
|
@@ -0,0 +1,3999 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { blockManifestSchema } from "@avocadostudio-ai/shared";
|
|
3
|
+
import { GENERATING_IMAGE_PLACEHOLDER, SEARCHING_IMAGE_PLACEHOLDER, isGeneratingPlaceholder, cleanupImagePlaceholders, buildPageDirectory, isVariationRequestMessage, variationVerbIntent, resolveEffectiveSlug, throwIfCanceled, raceCancel, sleepMs, suppressCancelOnly } from "./chat-pipeline-shared.js";
|
|
4
|
+
import { siteCapabilitiesSchema, isBatchAddRequest, isDuplicateBlockRequest, isBlockCatalogQuery, isInfoQuery, isAdviceQuery, adviceResponse, isContentQuery, isPageListQuery, requestsPlanFirst, plannerMessageWithPendingContext, buildSiteContextBlock, infoResponse } from "../nlp/intent-detection.js";
|
|
5
|
+
import { isLikelyClarificationFollowUp } from "../nlp/intent-helpers.js";
|
|
6
|
+
import { versions, pendingClarificationBySession, chatHistoryBySession, pendingApprovalPlanBySession, continuationChainBySession, imageSourcePreferenceBySession, getSessionDraft, getPage, setPage, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, pushChatHistory, schedulePersistState, removePage } from "../state/session-state.js";
|
|
7
|
+
import { toErrorDetail, isNoEffectiveChangeError, classifyGuardrailError, formatValidationError, isDeterministicRepairEligible, buildDeterministicRepairFeedback, validateOperations, applyOpsAtomically, isStructuralOperation, pickFocusBlockId, pickUpdatedSlug } from "../ops/ops-engine.js";
|
|
8
|
+
import { evaluateDestructiveActions } from "../ops/destructive-action-gate.js";
|
|
9
|
+
import { clarificationSuggestions, postEditSuggestions, demoPlanFromMessage, plannerContextPack, compileDeterministicPlan, inferDeterministicIntent, isHighConfidenceDeterministicCase, tryCompoundDeterministicPlan, resolveImageUrlForAltField } from "../nlp/deterministic-planner.js";
|
|
10
|
+
import { generatePlanWithOpenAI, isPlannerOutputError, isStrictJsonResponseEnabled, parseIntentWithOpenAI } from "./planner.js";
|
|
11
|
+
import { isDemoModeEnabled, splitDemoOps, getDemoAllowedBlockTypes } from "../demo-mode.js";
|
|
12
|
+
import { isCancelError as _isCancelError } from "../errors.js";
|
|
13
|
+
// `isCancelError` is used directly (without underscore) in runChatPipeline
|
|
14
|
+
const isCancelError = _isCancelError;
|
|
15
|
+
import { isMultiStepCandidate, decomposeRequest } from "./decomposer.js";
|
|
16
|
+
import { generatePlanWithAnthropic, parseIntentWithAnthropic } from "./anthropic-planner.js";
|
|
17
|
+
import { generatePlanWithGemini, parseIntentWithGemini } from "./gemini-planner.js";
|
|
18
|
+
import { createPlannerRegistry, budgetTokensToEffort } from "./planner-types.js";
|
|
19
|
+
import { estimateUsd } from "../telemetry/usage.js";
|
|
20
|
+
import { executeToolCall } from "../tools/runtime.js";
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Re-exports from extracted modules (for backwards compat with external importers)
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
export { sentenceCase, firstUrlFromText, preferredImageAltText, collectMentionedSlugsFromPlan, collectMentionedSlugsFromOps, normalizePlanCopyForUi, futureToPastTense } from "./chat-pipeline-ui.js";
|
|
25
|
+
export { sanitizeMessageForPlanning, inferTranslationScopeFromMessage, isNonEmptyString, findFullPageTranslationCoverageGap, findExplicitCtaTargetCoverageGap, parseCloneAndTranslateRequest } from "./chat-pipeline-translation.js";
|
|
26
|
+
export { shouldPreferFastModelForMessage, shouldUseLlmIntentRouter, compactPlannerContextPack, minimalPlannerContextPack, shouldUseMinimalPlannerContext, shouldPreferFocusedTranslation, shouldEnableReasoningForMessage } from "./chat-pipeline-context.js";
|
|
27
|
+
export { isRewriteLikeMessage, isPerformanceAwareMessage, isLikelyTextField, collectChangedTextFields, buildMetaChangeLogEntries, buildAiInsightChanges, buildOpChangeLogEntries, deterministicCreatePagePlan, deterministicDuplicatePagePlan, deterministicSelectedTextRewritePlan, shouldReturnDeterministicClarification, fmtSlug } from "./chat-pipeline-deterministic.js";
|
|
28
|
+
export { blockHasImageUrlProp, parsePath, getValueAtPath, setValueAtPath, deleteValueAtPath, extractIndexedQueries, extractReferencedItemIndices, blockSupportsImageAtPath, detectImagePaths, imageQueryFromItem, shouldPopulateAllChildImages, findImageTargets, rewriteAddBlockToChildImageUpdate, withUnsplashHeroImage, shouldResolveCreatePageHeroImage, resolveHeroImageForCreatePage, detectImageOps } from "./chat-pipeline-image.js";
|
|
29
|
+
export { GENERATING_IMAGE_PLACEHOLDER, SEARCHING_IMAGE_PLACEHOLDER, isGeneratingPlaceholder, cleanupImagePlaceholders, buildPageDirectory, resolveEffectiveSlug, CancelError, isCancelError, throwIfCanceled, raceCancel, sseWrite, sleepMs, suppressCancelOnly } from "./chat-pipeline-shared.js";
|
|
30
|
+
// Internal imports from extracted modules (used by this file)
|
|
31
|
+
import { collectMentionedSlugsFromPlan, normalizePlanCopyForUi, futureToPastTense, pastToFutureTense } from "./chat-pipeline-ui.js";
|
|
32
|
+
import { sanitizeMessageForPlanning, inferTranslationScopeFromMessage, findFullPageTranslationCoverageGap, findExplicitCtaTargetCoverageGap, parseCloneAndTranslateRequest } from "./chat-pipeline-translation.js";
|
|
33
|
+
import { shouldPreferFastModelForMessage, shouldUseLlmIntentRouter, compactPlannerContextPack, minimalPlannerContextPack, shouldUseMinimalPlannerContext, shouldPreferFocusedTranslation, classifyMessageComplexity, isRouterPlanTooShallow, shouldEnableReasoningForMessage } from "./chat-pipeline-context.js";
|
|
34
|
+
import { buildAiInsightChanges, buildMetaChangeLogEntries, buildOpChangeLogEntries, deterministicCreatePagePlan, deterministicDuplicatePagePlan, shouldReturnDeterministicClarification, fmtSlug } from "./chat-pipeline-deterministic.js";
|
|
35
|
+
import { getValueAtPath, setValueAtPath, deleteValueAtPath, blockSupportsImageAtPath, detectImageOps, rewriteAddBlockToChildImageUpdate, withUnsplashHeroImage, resolveHeroImageForCreatePage } from "./chat-pipeline-image.js";
|
|
36
|
+
import { resolveEffectiveProvider, resolveModelKeyForProvider, resolvePlannerSource } from "./provider-routing.js";
|
|
37
|
+
import { runVariationPipeline, getCachedVariations } from "./variation-pipeline.js";
|
|
38
|
+
import { validateAndStripHallucinatedProps } from "./hallucination-validator.js";
|
|
39
|
+
import { validateChangelogCoverage } from "./changelog-coverage-validator.js";
|
|
40
|
+
import { generateAltTextFromVision, parseAltPathForOp } from "./vision-alt-generator.js";
|
|
41
|
+
/**
|
|
42
|
+
* Whether the CURRENT message plausibly depends on prior conversation turns — an
|
|
43
|
+
* anaphoric or very terse follow-up ("make it bigger", "undo", "do that again").
|
|
44
|
+
* Self-contained requests that name their own target ("shorten the hero heading")
|
|
45
|
+
* do NOT, so they should plan from a clean slate rather than inherit stale, unrelated
|
|
46
|
+
* history that bleeds into summaries/clarifications (#25).
|
|
47
|
+
*/
|
|
48
|
+
function messageDependsOnHistory(message) {
|
|
49
|
+
const m = message.toLowerCase().trim();
|
|
50
|
+
if (!m)
|
|
51
|
+
return false;
|
|
52
|
+
if (m.split(/\s+/).length <= 2)
|
|
53
|
+
return true; // "bigger", "undo that" — terse follow-up
|
|
54
|
+
return /\b(it|its|it's|that|this|those|these|them|they|same|again|also|too|as well|instead|previous(?:ly)?|earlier|undo|redo|revert)\b/.test(m);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Parses an image-source chip answer ("Use Unsplash photo", "Generate with AI",
|
|
58
|
+
* "Either's fine — pick for me") and returns the matching preference, or null
|
|
59
|
+
* when the message isn't a chip reply. Matching is loose (plain-text containment)
|
|
60
|
+
* so typed-out variants still resolve.
|
|
61
|
+
*/
|
|
62
|
+
function parseImageSourceChoiceFromMessage(message) {
|
|
63
|
+
if (typeof message !== "string")
|
|
64
|
+
return null;
|
|
65
|
+
const normalized = message.toLowerCase().replace(/\s+/g, " ").trim();
|
|
66
|
+
if (!normalized)
|
|
67
|
+
return null;
|
|
68
|
+
// "Either's fine" must take precedence — otherwise a message that mentions
|
|
69
|
+
// both sources ("I'll let you pick between unsplash or AI") would resolve
|
|
70
|
+
// ambiguously.
|
|
71
|
+
if (/\beither\b/.test(normalized) && /\b(fine|pick|choose|you\s+decide)\b/.test(normalized))
|
|
72
|
+
return "either";
|
|
73
|
+
if (/\buse\s+unsplash\b/.test(normalized) || /^unsplash(?:\s+photo)?$/.test(normalized))
|
|
74
|
+
return "unsplash";
|
|
75
|
+
if (/\bgenerate\s+with\s+ai\b/.test(normalized) || /^(?:ai|ai[-\s]?generated)$/.test(normalized))
|
|
76
|
+
return "genai";
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Appends a source hint token ("unsplash" / "generate image") to the planner
|
|
81
|
+
* message IFF the user's message doesn't already name a source. Avoids
|
|
82
|
+
* contradicting an explicit prompt like "Generate an AI image of X" when the
|
|
83
|
+
* session preference happens to be Unsplash.
|
|
84
|
+
*
|
|
85
|
+
* Exported for unit tests.
|
|
86
|
+
*/
|
|
87
|
+
export function applyImageSourceHint(plannerMessage, sanitizedMessage, preference) {
|
|
88
|
+
if (!preference || preference === "either")
|
|
89
|
+
return plannerMessage;
|
|
90
|
+
const hasExplicit = /\b(unsplash|stock\s+photo|stock\s+image|royalty[-\s]?free)\b/i.test(sanitizedMessage) ||
|
|
91
|
+
/\b(generate|generated|ai[-\s]?generated|ai\s+image|ai\s+photo|dall[-\s]?e|midjourney|stable\s+diffusion)\b/i.test(sanitizedMessage) ||
|
|
92
|
+
/\bwith\s+ai\b/i.test(sanitizedMessage);
|
|
93
|
+
if (hasExplicit)
|
|
94
|
+
return plannerMessage;
|
|
95
|
+
if (preference === "unsplash")
|
|
96
|
+
return `${plannerMessage} unsplash`;
|
|
97
|
+
if (preference === "genai")
|
|
98
|
+
return `${plannerMessage} generate image`;
|
|
99
|
+
return plannerMessage;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Quick-action prompts the inline AI wand on imageAlt fields sends ("Generate
|
|
103
|
+
* alt text", "Improve accessibility", "Make more descriptive", "Describe this
|
|
104
|
+
* image"). When one of these arrives with an alt-text field active and a
|
|
105
|
+
* companion image URL on disk, we must route to the full vision-aware planner —
|
|
106
|
+
* the fast JSON-only intent router can't see images.
|
|
107
|
+
*/
|
|
108
|
+
const VISION_ALT_PROMPT_RE = /\b(generate\s+alt(?:\s+text)?|improve\s+accessibility|make\s+(?:it\s+|this\s+)?more\s+descriptive|describe\s+(?:this|the|that)\s+(?:image|photo|picture|icon|logo|illustration)|write\s+alt(?:\s+text)?)\b/i;
|
|
109
|
+
export function isVisionAltGenerationRequest(args) {
|
|
110
|
+
if (!args.message || !args.activeEditablePath || !args.activeBlockId)
|
|
111
|
+
return false;
|
|
112
|
+
if (!/(^|[.])imageAlt$/.test(args.activeEditablePath) && !/(^|[.])alt$/.test(args.activeEditablePath))
|
|
113
|
+
return false;
|
|
114
|
+
if (!VISION_ALT_PROMPT_RE.test(args.message))
|
|
115
|
+
return false;
|
|
116
|
+
const block = args.currentPage.blocks.find((b) => b.id === args.activeBlockId);
|
|
117
|
+
if (!block)
|
|
118
|
+
return false;
|
|
119
|
+
return Boolean(resolveImageUrlForAltField(block.props, args.activeEditablePath));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Image fields that, when they are the ONLY fields touched in an update_props
|
|
123
|
+
* patch, make the op a pure "image update" — safe to auto-apply via the deferred
|
|
124
|
+
* image resolution path (shimmer placeholder → real URL streamed in over SSE).
|
|
125
|
+
*
|
|
126
|
+
* Compound plans or plans that touch text fields alongside images keep the
|
|
127
|
+
* approval gate so the user can still review text copy before it lands.
|
|
128
|
+
*/
|
|
129
|
+
const IMAGE_ONLY_PATCH_FIELDS = new Set(["imageUrl", "imageAlt", "ogImage", "logoUrl"]);
|
|
130
|
+
/**
|
|
131
|
+
* True when every op in the plan is an `update_props` whose patch touches
|
|
132
|
+
* only image-related fields (imageUrl/imageAlt/ogImage/logoUrl). Such plans
|
|
133
|
+
* should skip the approval gate — image resolution is deterministic work.
|
|
134
|
+
*
|
|
135
|
+
* Exported for unit tests.
|
|
136
|
+
*/
|
|
137
|
+
export function isImageOnlyUpdatePropsPlan(plan) {
|
|
138
|
+
if (plan.intent !== "edit_plan")
|
|
139
|
+
return false;
|
|
140
|
+
if (!Array.isArray(plan.ops) || plan.ops.length === 0)
|
|
141
|
+
return false;
|
|
142
|
+
return plan.ops.every((op) => {
|
|
143
|
+
if (op.op !== "update_props")
|
|
144
|
+
return false;
|
|
145
|
+
const rawPatch = op.patch;
|
|
146
|
+
if (!rawPatch || typeof rawPatch !== "object")
|
|
147
|
+
return false;
|
|
148
|
+
const patch = typeof rawPatch.props === "object" &&
|
|
149
|
+
rawPatch.props !== null &&
|
|
150
|
+
!Array.isArray(rawPatch.props)
|
|
151
|
+
? (rawPatch.props)
|
|
152
|
+
: rawPatch;
|
|
153
|
+
const keys = Object.keys(patch);
|
|
154
|
+
if (keys.length === 0)
|
|
155
|
+
return false;
|
|
156
|
+
return keys.every((k) => IMAGE_ONLY_PATCH_FIELDS.has(k));
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
let generatePlanWithOpenAIImpl = generatePlanWithOpenAI;
|
|
160
|
+
export function setGeneratePlanWithOpenAIForTests(fn) {
|
|
161
|
+
generatePlanWithOpenAIImpl = fn ?? generatePlanWithOpenAI;
|
|
162
|
+
}
|
|
163
|
+
let generatePlanWithAnthropicImpl = generatePlanWithAnthropic;
|
|
164
|
+
export function setGeneratePlanWithAnthropicForTests(fn) {
|
|
165
|
+
generatePlanWithAnthropicImpl = fn ?? generatePlanWithAnthropic;
|
|
166
|
+
}
|
|
167
|
+
let demoPlanFromMessageImpl = demoPlanFromMessage;
|
|
168
|
+
export function setDemoPlanFromMessageForTests(fn) {
|
|
169
|
+
demoPlanFromMessageImpl = fn ?? demoPlanFromMessage;
|
|
170
|
+
}
|
|
171
|
+
let parseIntentWithOpenAIImpl = parseIntentWithOpenAI;
|
|
172
|
+
export function setParseIntentWithOpenAIForTests(fn) {
|
|
173
|
+
parseIntentWithOpenAIImpl = fn ?? parseIntentWithOpenAI;
|
|
174
|
+
}
|
|
175
|
+
let parseIntentWithAnthropicImpl = parseIntentWithAnthropic;
|
|
176
|
+
export function setParseIntentWithAnthropicForTests(fn) {
|
|
177
|
+
parseIntentWithAnthropicImpl = fn ?? parseIntentWithAnthropic;
|
|
178
|
+
}
|
|
179
|
+
let generatePlanWithGeminiImpl = generatePlanWithGemini;
|
|
180
|
+
export function setGeneratePlanWithGeminiForTests(fn) {
|
|
181
|
+
generatePlanWithGeminiImpl = fn ?? generatePlanWithGemini;
|
|
182
|
+
}
|
|
183
|
+
let parseIntentWithGeminiImpl = parseIntentWithGemini;
|
|
184
|
+
export function setParseIntentWithGeminiForTests(fn) {
|
|
185
|
+
parseIntentWithGeminiImpl = fn ?? parseIntentWithGemini;
|
|
186
|
+
}
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Main chat pipeline
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
export async function runChatPipeline(ctx, body, options) {
|
|
191
|
+
let executionMode = body.executionMode ?? "auto";
|
|
192
|
+
const requiresMessage = executionMode === "auto" || executionMode === "plan_only";
|
|
193
|
+
if (!body.session || !body.slug || (requiresMessage && !body.message)) {
|
|
194
|
+
return { code: 400, payload: { error: "session and slug are required; message is required for planning" } };
|
|
195
|
+
}
|
|
196
|
+
// An explicit "make a plan first" / "plan it before doing" request now APPLIES
|
|
197
|
+
// the plan to the draft as a revertible soft draft instead of holding it
|
|
198
|
+
// uncommitted: the user reviews the whole page in the live preview and Keeps or
|
|
199
|
+
// Reverts (one-step undo) inline in chat. Scoped to the typed phrase so the
|
|
200
|
+
// editor's complexity-based plan_only (and destructive ops) still hold behind
|
|
201
|
+
// the Apply/Discard gate. Keys off the message regardless of executionMode, so
|
|
202
|
+
// it works whether the editor sent "auto" or "plan_only".
|
|
203
|
+
const planFirstApplyAsDraft = !!body.message && requestsPlanFirst(body.message);
|
|
204
|
+
const manifestPayload = (() => {
|
|
205
|
+
if (!body.componentsManifest)
|
|
206
|
+
return undefined;
|
|
207
|
+
if (typeof body.componentsManifest !== "string")
|
|
208
|
+
return body.componentsManifest;
|
|
209
|
+
try {
|
|
210
|
+
return JSON.parse(body.componentsManifest);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return "__invalid_json__";
|
|
214
|
+
}
|
|
215
|
+
})();
|
|
216
|
+
const parsedManifest = manifestPayload === "__invalid_json__"
|
|
217
|
+
? { success: false }
|
|
218
|
+
: manifestPayload
|
|
219
|
+
? blockManifestSchema.safeParse(manifestPayload)
|
|
220
|
+
: { success: true, data: undefined };
|
|
221
|
+
if (!parsedManifest.success) {
|
|
222
|
+
return { code: 400, payload: { error: "invalid componentsManifest payload" } };
|
|
223
|
+
}
|
|
224
|
+
const componentsManifest = parsedManifest.data;
|
|
225
|
+
const capabilitiesPayload = (() => {
|
|
226
|
+
if (!body.siteCapabilities)
|
|
227
|
+
return undefined;
|
|
228
|
+
if (typeof body.siteCapabilities !== "string")
|
|
229
|
+
return body.siteCapabilities;
|
|
230
|
+
try {
|
|
231
|
+
return JSON.parse(body.siteCapabilities);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return "__invalid_json__";
|
|
235
|
+
}
|
|
236
|
+
})();
|
|
237
|
+
const parsedCapabilities = capabilitiesPayload === "__invalid_json__"
|
|
238
|
+
? { success: false }
|
|
239
|
+
: capabilitiesPayload
|
|
240
|
+
? siteCapabilitiesSchema.safeParse(capabilitiesPayload)
|
|
241
|
+
: { success: true, data: undefined };
|
|
242
|
+
if (!parsedCapabilities.success) {
|
|
243
|
+
return { code: 400, payload: { error: "invalid siteCapabilities payload" } };
|
|
244
|
+
}
|
|
245
|
+
const siteCapabilities = parsedCapabilities.data;
|
|
246
|
+
if (executionMode === "discard_pending_plan") {
|
|
247
|
+
const existing = pendingApprovalPlanBySession.get(body.session);
|
|
248
|
+
if (!existing) {
|
|
249
|
+
const defaultProvider = ctx.availableProviders[0] ?? "openai";
|
|
250
|
+
const defaultModelKey = process.env.OPENAI_MODEL_KEY ?? "balanced";
|
|
251
|
+
return {
|
|
252
|
+
code: 200,
|
|
253
|
+
payload: {
|
|
254
|
+
status: "canceled",
|
|
255
|
+
summary: "No pending plan to stop.",
|
|
256
|
+
changes: [],
|
|
257
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
258
|
+
plannerSource: ctx.availableProviders.length > 0 ? ctx.availableProviders[0] : "demo",
|
|
259
|
+
modelUsed: ctx.modelLookup[defaultProvider][defaultModelKey],
|
|
260
|
+
modelKey: defaultModelKey
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
if (body.pendingPlanId && body.pendingPlanId !== existing.id) {
|
|
265
|
+
return { code: 409, payload: { error: "pending plan mismatch" } };
|
|
266
|
+
}
|
|
267
|
+
pendingApprovalPlanBySession.delete(body.session);
|
|
268
|
+
return {
|
|
269
|
+
code: 200,
|
|
270
|
+
payload: {
|
|
271
|
+
status: "canceled",
|
|
272
|
+
summary: "Stopped. The pending plan was discarded and will not be executed.",
|
|
273
|
+
changes: [],
|
|
274
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
275
|
+
plannerSource: existing.source,
|
|
276
|
+
modelUsed: existing.modelUsed,
|
|
277
|
+
modelKey: existing.modelKey
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if (executionMode === "continue_chain") {
|
|
282
|
+
const chain = continuationChainBySession.get(body.session);
|
|
283
|
+
if (!chain) {
|
|
284
|
+
return { code: 409, payload: { error: "no continuation chain found for this session" } };
|
|
285
|
+
}
|
|
286
|
+
if (body.continuationChainId && body.continuationChainId !== chain.id) {
|
|
287
|
+
return { code: 409, payload: { error: "continuation chain id mismatch" } };
|
|
288
|
+
}
|
|
289
|
+
if (chain.currentStep >= chain.totalSteps) {
|
|
290
|
+
continuationChainBySession.delete(body.session);
|
|
291
|
+
return { code: 409, payload: { error: "continuation chain already completed" } };
|
|
292
|
+
}
|
|
293
|
+
// Execute the next step as a normal chat message
|
|
294
|
+
throwIfCanceled(options?.signal);
|
|
295
|
+
const stepMessage = chain.steps[chain.currentStep];
|
|
296
|
+
chain.currentStep++;
|
|
297
|
+
const isLastStep = chain.currentStep >= chain.totalSteps;
|
|
298
|
+
const result = await runChatPipeline(ctx, {
|
|
299
|
+
...body,
|
|
300
|
+
message: stepMessage,
|
|
301
|
+
slug: chain.effectiveSlug,
|
|
302
|
+
executionMode: "auto"
|
|
303
|
+
}, options);
|
|
304
|
+
// Attach continuation info if more steps remain
|
|
305
|
+
if (!isLastStep && result.code === 200 && "status" in result.payload && result.payload.status !== "error") {
|
|
306
|
+
const payload = result.payload;
|
|
307
|
+
payload.continuation = {
|
|
308
|
+
chainId: chain.id,
|
|
309
|
+
currentStep: chain.currentStep + 1,
|
|
310
|
+
totalSteps: chain.totalSteps,
|
|
311
|
+
nextStepLabel: chain.stepLabels[chain.currentStep]
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
if (isLastStep) {
|
|
315
|
+
continuationChainBySession.delete(body.session);
|
|
316
|
+
}
|
|
317
|
+
return result;
|
|
318
|
+
}
|
|
319
|
+
const sanitizedMessage = sanitizeMessageForPlanning(body.message ?? "");
|
|
320
|
+
const pageDirectory = buildPageDirectory(body.session);
|
|
321
|
+
const siteContextBlock = buildSiteContextBlock({
|
|
322
|
+
sitePurpose: body.sitePurpose,
|
|
323
|
+
siteHosting: body.siteHosting,
|
|
324
|
+
businessContext: body.businessContext,
|
|
325
|
+
siteContext: body.siteContext,
|
|
326
|
+
pageDirectory: pageDirectory || undefined
|
|
327
|
+
});
|
|
328
|
+
// Site context is now passed to the LLM system prompt (cacheable) instead of the user message.
|
|
329
|
+
let plannerMessage = plannerMessageWithPendingContext(body.session, sanitizedMessage);
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// Image source preference (Layer 3): capture the user's choice from a chip
|
|
332
|
+
// answer (if any), then bias downstream image-source detection via a hint
|
|
333
|
+
// token so repeated prompts stop within the same session. The ambiguity
|
|
334
|
+
// challenge itself (returning needs_clarification chips) runs further down
|
|
335
|
+
// once withDebugPayload is in scope.
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
const capturedImageSourceChoice = parseImageSourceChoiceFromMessage(sanitizedMessage);
|
|
338
|
+
if (capturedImageSourceChoice) {
|
|
339
|
+
imageSourcePreferenceBySession.set(body.session, capturedImageSourceChoice);
|
|
340
|
+
}
|
|
341
|
+
const activeImageSourcePreference = capturedImageSourceChoice ?? imageSourcePreferenceBySession.get(body.session);
|
|
342
|
+
plannerMessage = applyImageSourceHint(plannerMessage, sanitizedMessage, activeImageSourcePreference);
|
|
343
|
+
// Let the LLM decide whether a prompt is genuinely ambiguous about image
|
|
344
|
+
// source (Unsplash vs AI). This flag only opens the option when both sources
|
|
345
|
+
// are configured AND no session-level preference is in effect — otherwise the
|
|
346
|
+
// planner must proceed without asking. See sectionImageSourceChoice in
|
|
347
|
+
// prompts.ts for the full rule the LLM receives.
|
|
348
|
+
const hasUnsplashConfigured = Boolean(process.env.UNSPLASH_ACCESS_KEY?.trim());
|
|
349
|
+
const hasGenAIConfigured = Boolean(process.env.OPENAI_API_KEY?.trim() || process.env.GOOGLE_GENAI_API_KEY?.trim());
|
|
350
|
+
const imageSourceChoiceOpen = hasUnsplashConfigured && hasGenAIConfigured && !activeImageSourcePreference;
|
|
351
|
+
const inferredTranslationScope = inferTranslationScopeFromMessage(plannerMessage);
|
|
352
|
+
const translationScope = shouldPreferFocusedTranslation({
|
|
353
|
+
message: plannerMessage,
|
|
354
|
+
inferredScope: inferredTranslationScope,
|
|
355
|
+
activeBlockId: body.activeBlockId
|
|
356
|
+
})
|
|
357
|
+
? "component"
|
|
358
|
+
: inferredTranslationScope;
|
|
359
|
+
const planningActiveBlockId = translationScope === "page" ? undefined : body.activeBlockId;
|
|
360
|
+
const planningActiveEditablePath = translationScope === "page" ? undefined : body.activeEditablePath;
|
|
361
|
+
const parsedSiteContext = (() => {
|
|
362
|
+
if (!body.siteContext)
|
|
363
|
+
return {};
|
|
364
|
+
if (typeof body.siteContext === "object")
|
|
365
|
+
return body.siteContext;
|
|
366
|
+
try {
|
|
367
|
+
return JSON.parse(body.siteContext);
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
return {};
|
|
371
|
+
}
|
|
372
|
+
})();
|
|
373
|
+
const gdriveFolderId = typeof parsedSiteContext.gdriveFolderId === "string" ? parsedSiteContext.gdriveFolderId.trim() || undefined : undefined;
|
|
374
|
+
const sessionChatHistory = chatHistoryBySession.get(body.session) ?? [];
|
|
375
|
+
// Only feed prior turns to the planner when the current message actually leans on
|
|
376
|
+
// them: an anaphoric/terse follow-up (#25), OR a direct answer to an OPEN clarification.
|
|
377
|
+
// A self-contained command that merely happens to arrive while a clarification is pending
|
|
378
|
+
// is NOT a follow-up — passing history there lets the abandoned clarification's context
|
|
379
|
+
// bleed into an unrelated turn (#26). When that happens the user has clearly moved on, so
|
|
380
|
+
// drop the stale pending clarification too.
|
|
381
|
+
const hasPendingClarification = pendingClarificationBySession.has(body.session);
|
|
382
|
+
const answersPendingClarification = hasPendingClarification && isLikelyClarificationFollowUp(body.message ?? "");
|
|
383
|
+
if (hasPendingClarification && !answersPendingClarification) {
|
|
384
|
+
pendingClarificationBySession.delete(body.session);
|
|
385
|
+
}
|
|
386
|
+
const plannerChatHistory = answersPendingClarification || messageDependsOnHistory(body.message ?? "")
|
|
387
|
+
? sessionChatHistory
|
|
388
|
+
: [];
|
|
389
|
+
const chatRequestId = randomUUID();
|
|
390
|
+
const requestedSlug = body.slug;
|
|
391
|
+
const effectiveSlug = resolveEffectiveSlug({
|
|
392
|
+
session: body.session,
|
|
393
|
+
requestedSlug,
|
|
394
|
+
activeBlockId: planningActiveBlockId
|
|
395
|
+
});
|
|
396
|
+
ctx.log.info({
|
|
397
|
+
event: "chat_pipeline_start",
|
|
398
|
+
chatRequestId,
|
|
399
|
+
session: body.session,
|
|
400
|
+
requestedSlug,
|
|
401
|
+
effectiveSlug,
|
|
402
|
+
activeBlockId: planningActiveBlockId,
|
|
403
|
+
activeEditablePath: planningActiveEditablePath,
|
|
404
|
+
message: body.message
|
|
405
|
+
}, "Chat pipeline request received");
|
|
406
|
+
if (ctx.evalCandidates && body.message) {
|
|
407
|
+
const draftMap = getSessionDraft(body.session);
|
|
408
|
+
const fixture = Array.from(draftMap.values()).map((page) => structuredClone(page));
|
|
409
|
+
ctx.evalCandidates.start({
|
|
410
|
+
id: chatRequestId,
|
|
411
|
+
session: body.session,
|
|
412
|
+
slug: effectiveSlug,
|
|
413
|
+
prompt: body.message,
|
|
414
|
+
fixture,
|
|
415
|
+
activeBlockId: planningActiveBlockId,
|
|
416
|
+
activeEditablePath: planningActiveEditablePath
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
// Planner model pin — "always run on Sonnet". Small fast models under-follow
|
|
420
|
+
// the planner contract (op/summary consistency, assumption handling under the
|
|
421
|
+
// review gate), so when Anthropic is configured we pin the *planner* (not
|
|
422
|
+
// variations or the agent) to Sonnet (balanced tier) and skip every fast/cheap
|
|
423
|
+
// downgrade below. Honors an explicit caller provider/modelKey and falls back
|
|
424
|
+
// to the prior auto-routing when Anthropic is unavailable. Disable with
|
|
425
|
+
// CHAT_PLANNER_FORCE_SONNET=0. See CLAUDE.md § Chat Pipeline Flags.
|
|
426
|
+
const forceSonnet = /^(1|true|yes|on)$/i.test((process.env.CHAT_PLANNER_FORCE_SONNET ?? "").trim()) &&
|
|
427
|
+
!body.provider &&
|
|
428
|
+
!body.modelKey &&
|
|
429
|
+
ctx.availableProviders.includes("anthropic");
|
|
430
|
+
const requestedProvider = body.provider ??
|
|
431
|
+
(forceSonnet ? "anthropic" : ctx.availableProviders[0]);
|
|
432
|
+
const provider = resolveEffectiveProvider({
|
|
433
|
+
requestedProvider,
|
|
434
|
+
availableProviders: ctx.availableProviders,
|
|
435
|
+
fallbackProvider: ctx.availableProviders[0] ?? "openai"
|
|
436
|
+
});
|
|
437
|
+
const baseModelKey = resolveModelKeyForProvider({
|
|
438
|
+
requestedModelKey: body.modelKey,
|
|
439
|
+
provider,
|
|
440
|
+
modelLookup: ctx.modelLookup,
|
|
441
|
+
defaultModelKey: process.env.OPENAI_MODEL_KEY ?? "balanced"
|
|
442
|
+
});
|
|
443
|
+
const modelKey = forceSonnet
|
|
444
|
+
? "balanced"
|
|
445
|
+
: !body.modelKey &&
|
|
446
|
+
baseModelKey !== "fast" &&
|
|
447
|
+
ctx.modelLookup[provider].fast &&
|
|
448
|
+
shouldPreferFastModelForMessage(plannerMessage)
|
|
449
|
+
? "fast"
|
|
450
|
+
: baseModelKey;
|
|
451
|
+
const modelUsed = ctx.modelLookup[provider][modelKey];
|
|
452
|
+
const plannerSource = resolvePlannerSource(provider);
|
|
453
|
+
// Auto-escalate to extended thinking for complex/ambiguous Anthropic prompts.
|
|
454
|
+
// Only applies when provider = anthropic (other providers ignore `thinking`).
|
|
455
|
+
// User can disable with CHAT_AUTO_REASONING=0.
|
|
456
|
+
const reasoningAutoEnabled = process.env.CHAT_AUTO_REASONING !== "0";
|
|
457
|
+
const parsedReasoningBudget = Number(process.env.CHAT_AUTO_REASONING_BUDGET ?? 2048);
|
|
458
|
+
const reasoningBudgetTokens = Math.max(1024, Number.isFinite(parsedReasoningBudget) ? parsedReasoningBudget : 2048);
|
|
459
|
+
if (!Number.isFinite(parsedReasoningBudget)) {
|
|
460
|
+
ctx.log.warn({
|
|
461
|
+
event: "reasoning_budget_invalid",
|
|
462
|
+
raw: process.env.CHAT_AUTO_REASONING_BUDGET
|
|
463
|
+
}, "CHAT_AUTO_REASONING_BUDGET is not a number; falling back to 2048");
|
|
464
|
+
}
|
|
465
|
+
const reasoningEnabled = reasoningAutoEnabled &&
|
|
466
|
+
plannerSource === "anthropic" &&
|
|
467
|
+
shouldEnableReasoningForMessage(plannerMessage);
|
|
468
|
+
const reasoningEffort = budgetTokensToEffort(reasoningBudgetTokens);
|
|
469
|
+
const thinkingConfig = reasoningEnabled ? { effort: reasoningEffort } : undefined;
|
|
470
|
+
if (reasoningEnabled) {
|
|
471
|
+
ctx.log.info({
|
|
472
|
+
event: "reasoning_auto_enabled",
|
|
473
|
+
model: modelUsed,
|
|
474
|
+
effort: reasoningEffort,
|
|
475
|
+
budgetTokens: reasoningBudgetTokens,
|
|
476
|
+
promptLength: plannerMessage.length
|
|
477
|
+
}, "Adaptive thinking auto-enabled for this request");
|
|
478
|
+
}
|
|
479
|
+
const promptHash = ctx.chatTelemetry.promptHash(plannerMessage);
|
|
480
|
+
const promptExcerpt = ctx.chatTelemetry.promptExcerpt(plannerMessage);
|
|
481
|
+
const pipelineStartedAtMs = Date.now();
|
|
482
|
+
const stageTimeline = [];
|
|
483
|
+
let planningStartedAtMs = null;
|
|
484
|
+
let planningFinishedAtMs = null;
|
|
485
|
+
let firstPlanningTokenMs = null;
|
|
486
|
+
// Auto-routing decision tracker. Populated by the parallel-router branch
|
|
487
|
+
// when it observes a complexity signal worth acting on (or shadow-logging).
|
|
488
|
+
// Threaded into `debug.routingDecision` via `withDebugPayload`.
|
|
489
|
+
let routingDecision;
|
|
490
|
+
// Which planner actually produced the response. Surfaced in `debug.plannerTier`
|
|
491
|
+
// so a deterministic plan isn't attributed to the configured LLM (its provider
|
|
492
|
+
// name + model still appear because they'd be used IF an LLM ran) — issue #14b.
|
|
493
|
+
// respondFromPlan overrides this with the plan's true tier; null tokens +
|
|
494
|
+
// tier=deterministic together signal "no LLM spend".
|
|
495
|
+
let activePlannerTier;
|
|
496
|
+
let planningAttempts = 0;
|
|
497
|
+
let imageResolutionDurationMs = 0;
|
|
498
|
+
let applyDurationMs;
|
|
499
|
+
let firstApplyMs = null;
|
|
500
|
+
let doneStageMarked = false;
|
|
501
|
+
const markPlanningStart = () => {
|
|
502
|
+
if (planningStartedAtMs === null)
|
|
503
|
+
planningStartedAtMs = Date.now();
|
|
504
|
+
};
|
|
505
|
+
const markPlanningFinish = () => {
|
|
506
|
+
planningFinishedAtMs = Date.now();
|
|
507
|
+
};
|
|
508
|
+
const onPlanningToken = (token) => {
|
|
509
|
+
if (firstPlanningTokenMs === null) {
|
|
510
|
+
firstPlanningTokenMs = Date.now() - pipelineStartedAtMs;
|
|
511
|
+
emitStatusTone("generating_plan");
|
|
512
|
+
if (!stageTimeline.some((item) => item.stage === "first_token")) {
|
|
513
|
+
stageTimeline.push({ stage: "first_token", atMs: firstPlanningTokenMs });
|
|
514
|
+
}
|
|
515
|
+
ctx.chatTelemetry.push({
|
|
516
|
+
id: chatRequestId,
|
|
517
|
+
at: new Date().toISOString(),
|
|
518
|
+
phase: "milestone",
|
|
519
|
+
timelineStage: "first_token",
|
|
520
|
+
session: body.session,
|
|
521
|
+
requestedSlug,
|
|
522
|
+
effectiveSlug,
|
|
523
|
+
plannerSource,
|
|
524
|
+
modelKey,
|
|
525
|
+
modelUsed,
|
|
526
|
+
promptHash,
|
|
527
|
+
promptExcerpt,
|
|
528
|
+
promptLength: plannerMessage.length,
|
|
529
|
+
totalDurationMs: firstPlanningTokenMs
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
options?.onPlanningToken?.(token);
|
|
533
|
+
};
|
|
534
|
+
const markFirstStructuredProgress = () => {
|
|
535
|
+
if (stageTimeline.some((item) => item.stage === "first_structured_progress"))
|
|
536
|
+
return;
|
|
537
|
+
const atMs = Date.now() - pipelineStartedAtMs;
|
|
538
|
+
stageTimeline.push({ stage: "first_structured_progress", atMs });
|
|
539
|
+
ctx.chatTelemetry.push({
|
|
540
|
+
id: chatRequestId,
|
|
541
|
+
at: new Date().toISOString(),
|
|
542
|
+
phase: "milestone",
|
|
543
|
+
timelineStage: "first_structured_progress",
|
|
544
|
+
session: body.session,
|
|
545
|
+
requestedSlug,
|
|
546
|
+
effectiveSlug,
|
|
547
|
+
plannerSource,
|
|
548
|
+
modelKey,
|
|
549
|
+
modelUsed,
|
|
550
|
+
promptHash,
|
|
551
|
+
promptExcerpt,
|
|
552
|
+
promptLength: plannerMessage.length,
|
|
553
|
+
totalDurationMs: atMs
|
|
554
|
+
});
|
|
555
|
+
};
|
|
556
|
+
const emitStatus = (message) => {
|
|
557
|
+
options?.onStatusUpdate?.(message);
|
|
558
|
+
ctx.log.info({
|
|
559
|
+
event: "chat_pipeline_status",
|
|
560
|
+
chatRequestId,
|
|
561
|
+
session: body.session,
|
|
562
|
+
message,
|
|
563
|
+
elapsedMs: Date.now() - pipelineStartedAtMs
|
|
564
|
+
}, "Chat pipeline status update");
|
|
565
|
+
};
|
|
566
|
+
const pipelineStatusVariants = {
|
|
567
|
+
analyzing: [
|
|
568
|
+
"Analyzing your request...",
|
|
569
|
+
"Reviewing your request...",
|
|
570
|
+
"Looking over your request..."
|
|
571
|
+
],
|
|
572
|
+
planning: [
|
|
573
|
+
"Planning edits...",
|
|
574
|
+
"Drafting the edit plan...",
|
|
575
|
+
"Mapping out the changes..."
|
|
576
|
+
],
|
|
577
|
+
understanding: [
|
|
578
|
+
"Understanding your request...",
|
|
579
|
+
"Interpreting your request...",
|
|
580
|
+
"Confirming intent..."
|
|
581
|
+
],
|
|
582
|
+
breaking_down: [
|
|
583
|
+
"Breaking down your request...",
|
|
584
|
+
"Splitting your request into steps...",
|
|
585
|
+
"Organizing requested changes..."
|
|
586
|
+
],
|
|
587
|
+
generating_plan: [
|
|
588
|
+
"Generating plan...",
|
|
589
|
+
"Composing edit plan...",
|
|
590
|
+
"Building execution plan..."
|
|
591
|
+
],
|
|
592
|
+
resolving_assets: [
|
|
593
|
+
"Resolving image assets...",
|
|
594
|
+
"Collecting image assets...",
|
|
595
|
+
"Preparing image assets..."
|
|
596
|
+
],
|
|
597
|
+
applying: [
|
|
598
|
+
"Applying changes...",
|
|
599
|
+
"Applying edits...",
|
|
600
|
+
"Updating draft..."
|
|
601
|
+
],
|
|
602
|
+
validating: [
|
|
603
|
+
"Wrapping up…",
|
|
604
|
+
"Finalizing the response…",
|
|
605
|
+
"Almost done…"
|
|
606
|
+
],
|
|
607
|
+
repairing: [
|
|
608
|
+
"Repairing plan and retrying...",
|
|
609
|
+
"Fixing plan details and retrying...",
|
|
610
|
+
"Adjusting plan and trying again..."
|
|
611
|
+
],
|
|
612
|
+
thinking: [
|
|
613
|
+
"Thinking...",
|
|
614
|
+
"Working through the request...",
|
|
615
|
+
"Refining the plan..."
|
|
616
|
+
]
|
|
617
|
+
};
|
|
618
|
+
const pipelineStatusCursor = new Map();
|
|
619
|
+
const emitStatusTone = (tone) => {
|
|
620
|
+
const variants = pipelineStatusVariants[tone];
|
|
621
|
+
const nextIdx = (pipelineStatusCursor.get(tone) ?? 0) % variants.length;
|
|
622
|
+
pipelineStatusCursor.set(tone, nextIdx + 1);
|
|
623
|
+
emitStatus(variants[nextIdx] ?? variants[0] ?? "Working...");
|
|
624
|
+
};
|
|
625
|
+
const planningDurationMs = () => planningStartedAtMs !== null && planningFinishedAtMs !== null
|
|
626
|
+
? Math.max(0, planningFinishedAtMs - planningStartedAtMs)
|
|
627
|
+
: undefined;
|
|
628
|
+
const timingFields = () => ({
|
|
629
|
+
totalDurationMs: Date.now() - pipelineStartedAtMs,
|
|
630
|
+
planningDurationMs: planningDurationMs(),
|
|
631
|
+
firstPlanningTokenMs: firstPlanningTokenMs ?? undefined,
|
|
632
|
+
applyDurationMs,
|
|
633
|
+
imageResolutionDurationMs: imageResolutionDurationMs > 0 ? imageResolutionDurationMs : undefined,
|
|
634
|
+
planningAttempts: planningAttempts > 0 ? planningAttempts : undefined
|
|
635
|
+
});
|
|
636
|
+
const incrementalApplyEnabled = !/^(0|false|no|off)$/i.test((process.env.CHAT_INCREMENTAL_APPLY ?? "1").trim());
|
|
637
|
+
const streamedApplyMinStepMsRaw = Number(process.env.CHAT_STREAM_APPLY_MIN_STEP_MS ?? 260);
|
|
638
|
+
const streamedApplyMinStepMs = Number.isFinite(streamedApplyMinStepMsRaw) && streamedApplyMinStepMsRaw > 0
|
|
639
|
+
? Math.min(Math.max(Math.trunc(streamedApplyMinStepMsRaw), 1), 2000)
|
|
640
|
+
: 0;
|
|
641
|
+
const incrementalPlanStreamEnabled = !/^(0|false|no|off)$/i.test((process.env.CHAT_INCREMENTAL_PLAN_STREAM ?? "1").trim());
|
|
642
|
+
const withDebugPayload = (payload, extra) => ({
|
|
643
|
+
...payload,
|
|
644
|
+
debug: {
|
|
645
|
+
traceId: chatRequestId,
|
|
646
|
+
promptHash,
|
|
647
|
+
promptExcerpt,
|
|
648
|
+
modelUsed,
|
|
649
|
+
plannerSource,
|
|
650
|
+
executionMode,
|
|
651
|
+
currentPage: effectiveSlug,
|
|
652
|
+
siteId: body.siteId || undefined,
|
|
653
|
+
activeBlockId: planningActiveBlockId || undefined,
|
|
654
|
+
activeEditablePath: planningActiveEditablePath || undefined,
|
|
655
|
+
...(planningAttempts > 1 ? { planningAttempts } : {}),
|
|
656
|
+
...(routingDecision ? { routingDecision } : {}),
|
|
657
|
+
...(activePlannerTier ? { plannerTier: activePlannerTier } : {}),
|
|
658
|
+
timeline: (() => {
|
|
659
|
+
if (!doneStageMarked) {
|
|
660
|
+
doneStageMarked = true;
|
|
661
|
+
const doneAtMs = Date.now() - pipelineStartedAtMs;
|
|
662
|
+
stageTimeline.push({ stage: "done", atMs: doneAtMs });
|
|
663
|
+
ctx.chatTelemetry.push({
|
|
664
|
+
id: chatRequestId,
|
|
665
|
+
at: new Date().toISOString(),
|
|
666
|
+
phase: "milestone",
|
|
667
|
+
timelineStage: "done",
|
|
668
|
+
session: body.session,
|
|
669
|
+
requestedSlug,
|
|
670
|
+
effectiveSlug,
|
|
671
|
+
plannerSource,
|
|
672
|
+
modelKey,
|
|
673
|
+
modelUsed,
|
|
674
|
+
promptHash,
|
|
675
|
+
promptExcerpt,
|
|
676
|
+
promptLength: plannerMessage.length,
|
|
677
|
+
totalDurationMs: doneAtMs
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
return stageTimeline.slice();
|
|
681
|
+
})(),
|
|
682
|
+
...(payload.debug ?? {}),
|
|
683
|
+
...(extra ?? {})
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
stageTimeline.push({ stage: "request_received", atMs: 0 });
|
|
687
|
+
ctx.chatTelemetry.push({
|
|
688
|
+
id: chatRequestId,
|
|
689
|
+
at: new Date().toISOString(),
|
|
690
|
+
phase: "received",
|
|
691
|
+
session: body.session,
|
|
692
|
+
requestedSlug,
|
|
693
|
+
effectiveSlug,
|
|
694
|
+
plannerSource,
|
|
695
|
+
modelKey,
|
|
696
|
+
modelUsed,
|
|
697
|
+
promptHash,
|
|
698
|
+
promptExcerpt,
|
|
699
|
+
promptLength: plannerMessage.length
|
|
700
|
+
});
|
|
701
|
+
ctx.chatTelemetry.push({
|
|
702
|
+
id: chatRequestId,
|
|
703
|
+
at: new Date().toISOString(),
|
|
704
|
+
phase: "milestone",
|
|
705
|
+
timelineStage: "request_received",
|
|
706
|
+
session: body.session,
|
|
707
|
+
requestedSlug,
|
|
708
|
+
effectiveSlug,
|
|
709
|
+
plannerSource,
|
|
710
|
+
modelKey,
|
|
711
|
+
modelUsed,
|
|
712
|
+
promptHash,
|
|
713
|
+
promptExcerpt,
|
|
714
|
+
promptLength: plannerMessage.length,
|
|
715
|
+
totalDurationMs: 0
|
|
716
|
+
});
|
|
717
|
+
if (executionMode === "auto") {
|
|
718
|
+
const existingPendingPlan = pendingApprovalPlanBySession.get(body.session);
|
|
719
|
+
const normalizedIncomingMessage = typeof body.message === "string" ? body.message.trim() : "";
|
|
720
|
+
const normalizedPendingMessage = typeof existingPendingPlan?.originalMessage === "string"
|
|
721
|
+
? existingPendingPlan.originalMessage.trim()
|
|
722
|
+
: "";
|
|
723
|
+
const replayedPendingPrompt = Boolean(existingPendingPlan &&
|
|
724
|
+
(existingPendingPlan.promptHash === promptHash ||
|
|
725
|
+
(normalizedIncomingMessage.length > 0 && normalizedPendingMessage.length > 0 && normalizedIncomingMessage === normalizedPendingMessage)));
|
|
726
|
+
if (replayedPendingPrompt) {
|
|
727
|
+
executionMode = "apply_pending_plan";
|
|
728
|
+
body.pendingPlanId = body.pendingPlanId ?? existingPendingPlan?.id;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
let current = getPage(body.session, effectiveSlug);
|
|
732
|
+
if (!current) {
|
|
733
|
+
// Allow page-creation requests through by providing an empty stub page
|
|
734
|
+
const looksLikeCreatePage = body.message && /\b(create|add|make|build|generate|new)\b.*\b(page|homepage|landing)\b/i.test(body.message);
|
|
735
|
+
if (!looksLikeCreatePage)
|
|
736
|
+
return { code: 404, payload: { error: "page not found" } };
|
|
737
|
+
current = { id: effectiveSlug, slug: effectiveSlug, title: "", updatedAt: new Date().toISOString(), blocks: [] };
|
|
738
|
+
}
|
|
739
|
+
// --- Clone-and-translate compound ---
|
|
740
|
+
// "clone the home page into /test, same content but translated to Spanish" is
|
|
741
|
+
// a two-phase request a single planner pass can't satisfy: it can emit
|
|
742
|
+
// `duplicate_page`, but the copy's blocks don't exist at plan time, so the
|
|
743
|
+
// translation half is silently dropped and the copy stays in the source
|
|
744
|
+
// language while the summary claims otherwise. Run it server-side as two
|
|
745
|
+
// phases — duplicate the page, then translate the fresh copy via the normal
|
|
746
|
+
// full-page translation path (which already covers every translatable field).
|
|
747
|
+
if (executionMode === "auto" &&
|
|
748
|
+
body.message &&
|
|
749
|
+
current.blocks.length > 0 &&
|
|
750
|
+
!continuationChainBySession.has(body.session) &&
|
|
751
|
+
(plannerSource === "openai" || plannerSource === "anthropic" || plannerSource === "gemini")) {
|
|
752
|
+
const cloneTranslate = parseCloneAndTranslateRequest(plannerMessage);
|
|
753
|
+
if (cloneTranslate) {
|
|
754
|
+
// Set once phase 1 commits the copy. After that the fall-through to the
|
|
755
|
+
// normal planner is NOT safe — it would re-plan the same clone request
|
|
756
|
+
// against the already-committed copy and emit a SECOND duplicate (or a
|
|
757
|
+
// "slug exists" guardrail error). So once committed we return the
|
|
758
|
+
// untranslated-duplicate result on any phase-2 failure, never fall through.
|
|
759
|
+
let clonedSlug = null;
|
|
760
|
+
let clonedDupVersion = 0;
|
|
761
|
+
// Captured narrowed (the closure would otherwise widen body.session back to
|
|
762
|
+
// `string | undefined`; it's already guarded non-empty at the top).
|
|
763
|
+
const sessionId = body.session;
|
|
764
|
+
const untranslatedDuplicateResult = (newSlug, dupVersion) => ({
|
|
765
|
+
code: 200,
|
|
766
|
+
payload: withDebugPayload({
|
|
767
|
+
status: "applied",
|
|
768
|
+
summary: `Cloned the ${fmtSlug(effectiveSlug)} page to ${fmtSlug(newSlug)}, but couldn't translate it to ${cloneTranslate.languageName} — try "translate this page to ${cloneTranslate.languageName}" on ${fmtSlug(newSlug)}.`,
|
|
769
|
+
changes: [`Duplicated ${fmtSlug(effectiveSlug)} → ${fmtSlug(newSlug)}.`],
|
|
770
|
+
mentionedSlugs: [newSlug],
|
|
771
|
+
suggestions: [`Translate ${fmtSlug(newSlug)} to ${cloneTranslate.languageName}`],
|
|
772
|
+
previewVersion: versions.get(sessionId) ?? dupVersion,
|
|
773
|
+
updatedSlug: newSlug,
|
|
774
|
+
undoSlug: newSlug,
|
|
775
|
+
plannerSource,
|
|
776
|
+
modelUsed,
|
|
777
|
+
modelKey
|
|
778
|
+
}, {
|
|
779
|
+
outcome: "applied",
|
|
780
|
+
intent: "edit_plan",
|
|
781
|
+
opCount: 1,
|
|
782
|
+
opTypes: ["duplicate_page"]
|
|
783
|
+
})
|
|
784
|
+
});
|
|
785
|
+
try {
|
|
786
|
+
options?.onStatusUpdate?.(`Duplicating ${fmtSlug(effectiveSlug)}…`);
|
|
787
|
+
// Phase 1 — duplicate the current page (deterministic; the planner is
|
|
788
|
+
// reliable here but we skip it to avoid a needless LLM round-trip).
|
|
789
|
+
const dupOp = {
|
|
790
|
+
op: "duplicate_page",
|
|
791
|
+
pageSlug: effectiveSlug,
|
|
792
|
+
...(cloneTranslate.newSlug ? { newPageSlug: cloneTranslate.newSlug } : {}),
|
|
793
|
+
// Tag the copy's title with the language code (e.g. "Home (ES)") so the
|
|
794
|
+
// localized page is distinguishable in nav/SEO from its source. The
|
|
795
|
+
// full-page translation in phase 2 covers block content, not page meta.
|
|
796
|
+
...(current.title.trim() ? { newTitle: `${current.title.trim()} (${cloneTranslate.languageCode})` } : {})
|
|
797
|
+
};
|
|
798
|
+
const dupResult = await applyOpsAtomically(body.session, [dupOp], { componentsManifest });
|
|
799
|
+
const created = dupResult.duplicatedPages[0];
|
|
800
|
+
if (created) {
|
|
801
|
+
const newSlug = created.slug;
|
|
802
|
+
// Make the duplication undoable on its own (undo = delete the copy).
|
|
803
|
+
// The phase-2 translation pushes its own pre-translation snapshot on
|
|
804
|
+
// top, so undo unwinds: translation → English copy → page removed.
|
|
805
|
+
pushUndo(body.session, newSlug, null);
|
|
806
|
+
const dupVersion = bumpVersion(body.session);
|
|
807
|
+
clonedSlug = newSlug;
|
|
808
|
+
clonedDupVersion = dupVersion;
|
|
809
|
+
options?.onOpApplied?.({ index: 1, total: 1, op: dupOp, previewVersion: dupVersion, updatedSlug: newSlug });
|
|
810
|
+
options?.onStatusUpdate?.(`Translating ${fmtSlug(newSlug)} to ${cloneTranslate.languageName}…`);
|
|
811
|
+
// Phase 2 — translate the fresh copy. Clear any active-block context so
|
|
812
|
+
// resolveEffectiveSlug can't follow the original (un-suffixed) block id
|
|
813
|
+
// back to the source page and translate that instead.
|
|
814
|
+
const translateResult = await runChatPipeline(ctx, {
|
|
815
|
+
...body,
|
|
816
|
+
slug: newSlug,
|
|
817
|
+
message: `translate this page to ${cloneTranslate.languageName}`,
|
|
818
|
+
executionMode: "auto",
|
|
819
|
+
activeBlockId: undefined,
|
|
820
|
+
activeEditablePath: undefined
|
|
821
|
+
}, options);
|
|
822
|
+
if (translateResult.code === 200 &&
|
|
823
|
+
"status" in translateResult.payload &&
|
|
824
|
+
translateResult.payload.status === "applied") {
|
|
825
|
+
const payload = translateResult.payload;
|
|
826
|
+
payload.summary = `Cloned the ${fmtSlug(effectiveSlug)} page to ${fmtSlug(newSlug)} and translated all content to ${cloneTranslate.languageName}.`;
|
|
827
|
+
payload.changes = [`Duplicated ${fmtSlug(effectiveSlug)} → ${fmtSlug(newSlug)}.`, ...(payload.changes ?? [])];
|
|
828
|
+
payload.updatedSlug = newSlug;
|
|
829
|
+
payload.undoSlug = newSlug;
|
|
830
|
+
payload.mentionedSlugs = payload.mentionedSlugs?.includes(newSlug)
|
|
831
|
+
? payload.mentionedSlugs
|
|
832
|
+
: [newSlug, ...(payload.mentionedSlugs ?? [])];
|
|
833
|
+
return translateResult;
|
|
834
|
+
}
|
|
835
|
+
// Phase 2 didn't apply — return the (untranslated) duplicate so the user
|
|
836
|
+
// at least gets the copy, with an honest note that translation failed.
|
|
837
|
+
return untranslatedDuplicateResult(newSlug, dupVersion);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
catch (cloneTranslateError) {
|
|
841
|
+
if (isCancelError(cloneTranslateError))
|
|
842
|
+
throw cloneTranslateError;
|
|
843
|
+
ctx.log.warn({ event: "clone_translate_failed", err: toErrorDetail(cloneTranslateError) }, clonedSlug
|
|
844
|
+
? "Clone-and-translate phase 2 threw after the copy was committed; returning the untranslated duplicate"
|
|
845
|
+
: "Clone-and-translate compound failed before committing; falling back to normal planner");
|
|
846
|
+
// If the copy is already committed, returning it (untranslated) is the
|
|
847
|
+
// only safe outcome — see the note where clonedSlug is declared.
|
|
848
|
+
if (clonedSlug)
|
|
849
|
+
return untranslatedDuplicateResult(clonedSlug, clonedDupVersion);
|
|
850
|
+
// Nothing committed (phase 1 is atomic) — fall through to the normal planner.
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
// "describe this image" on an image field — return the current image URL and suggest alt text
|
|
855
|
+
if (body.message && /\bdescribe\s+(?:this|the|that)\s+(?:image|photo|picture|icon|logo|illustration)\b/i.test(body.message) && body.activeEditablePath) {
|
|
856
|
+
const isImageField = /(?:imageUrl|image\.?src|\.url)$/i.test(body.activeEditablePath);
|
|
857
|
+
if (isImageField && body.activeBlockId) {
|
|
858
|
+
const block = current.blocks.find((b) => b.id === body.activeBlockId);
|
|
859
|
+
if (block) {
|
|
860
|
+
const imageUrl = getValueAtPath(block.props, body.activeEditablePath);
|
|
861
|
+
const summary = typeof imageUrl === "string" && imageUrl.startsWith("http")
|
|
862
|
+
? `This image is: ${imageUrl}`
|
|
863
|
+
: "No image URL is currently set for this field.";
|
|
864
|
+
return {
|
|
865
|
+
code: 200,
|
|
866
|
+
payload: withDebugPayload({
|
|
867
|
+
status: "info",
|
|
868
|
+
summary,
|
|
869
|
+
changes: [],
|
|
870
|
+
suggestions: [
|
|
871
|
+
"Write alt text for this image",
|
|
872
|
+
"Replace this image",
|
|
873
|
+
"Search for a new image"
|
|
874
|
+
],
|
|
875
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
876
|
+
plannerSource,
|
|
877
|
+
modelUsed,
|
|
878
|
+
modelKey
|
|
879
|
+
}, { outcome: "info" })
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
if (body.message && isInfoQuery(body.message) && !isBatchAddRequest(body.message)) {
|
|
885
|
+
activePlannerTier = "deterministic";
|
|
886
|
+
const info = infoResponse({ body, current, plannerSource, modelUsed, modelKey });
|
|
887
|
+
return { code: info.code, payload: withDebugPayload(info.payload, { outcome: "info" }) };
|
|
888
|
+
}
|
|
889
|
+
if (body.message && isAdviceQuery(body.message) && !isBatchAddRequest(body.message)) {
|
|
890
|
+
activePlannerTier = "deterministic";
|
|
891
|
+
const advice = adviceResponse({ body, current, plannerSource, modelUsed, modelKey });
|
|
892
|
+
return { code: advice.code, payload: withDebugPayload(advice.payload, { outcome: "advice" }) };
|
|
893
|
+
}
|
|
894
|
+
if (body.message && isPageListQuery(body.message)) {
|
|
895
|
+
const directory = buildPageDirectory(body.session);
|
|
896
|
+
const draft = getSessionDraft(body.session);
|
|
897
|
+
const pageCount = draft.size;
|
|
898
|
+
return {
|
|
899
|
+
code: 200,
|
|
900
|
+
payload: withDebugPayload({
|
|
901
|
+
status: "info",
|
|
902
|
+
summary: `This site has ${pageCount} page${pageCount === 1 ? "" : "s"}:`,
|
|
903
|
+
changes: directory ? directory.split("\n").map((l) => l.trim()) : ["No pages found."],
|
|
904
|
+
suggestions: [
|
|
905
|
+
"Add a new page",
|
|
906
|
+
"Delete a page",
|
|
907
|
+
"Rename a page",
|
|
908
|
+
"Reorder pages"
|
|
909
|
+
],
|
|
910
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
911
|
+
plannerSource,
|
|
912
|
+
modelUsed,
|
|
913
|
+
modelKey
|
|
914
|
+
}, { outcome: "info" })
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
if (body.message && isVariationRequestMessage(body.message)) {
|
|
918
|
+
if (body.activeBlockId) {
|
|
919
|
+
// Route to the dedicated variation pipeline and return its result directly.
|
|
920
|
+
// The editor detects the VariationResult shape (status "ok" + variations array)
|
|
921
|
+
// and opens the variation modal, same as it does for /chat/variations responses.
|
|
922
|
+
// Disambiguate "show me the variations again" (cached, no LLM) from
|
|
923
|
+
// "generate new variations" (fresh LLM call). When the user clearly wants
|
|
924
|
+
// to re-display an existing set and we have one cached, return it immediately.
|
|
925
|
+
const verbIntent = variationVerbIntent(body.message);
|
|
926
|
+
if (verbIntent === "show") {
|
|
927
|
+
const cached = getCachedVariations(body.session, body.activeBlockId);
|
|
928
|
+
if (cached) {
|
|
929
|
+
const shown = {
|
|
930
|
+
...cached,
|
|
931
|
+
summary: `Showing your ${cached.variations.length} variations for ${cached.blockType}.`
|
|
932
|
+
};
|
|
933
|
+
return { code: 200, payload: shown };
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
const intent = verbIntent === "show" ? "show" : "generate";
|
|
937
|
+
const variationResult = await runVariationPipeline(ctx, {
|
|
938
|
+
session: body.session,
|
|
939
|
+
slug: effectiveSlug,
|
|
940
|
+
message: body.message,
|
|
941
|
+
activeBlockId: body.activeBlockId,
|
|
942
|
+
activeBlockType: body.activeBlockType,
|
|
943
|
+
modelKey,
|
|
944
|
+
provider: body.provider,
|
|
945
|
+
sitePurpose: body.sitePurpose,
|
|
946
|
+
siteHosting: body.siteHosting,
|
|
947
|
+
businessContext: body.businessContext,
|
|
948
|
+
siteContext: body.siteContext
|
|
949
|
+
}, { intent });
|
|
950
|
+
return variationResult;
|
|
951
|
+
}
|
|
952
|
+
// No block selected — ask which block
|
|
953
|
+
return {
|
|
954
|
+
code: 200,
|
|
955
|
+
payload: withDebugPayload({
|
|
956
|
+
status: "needs_clarification",
|
|
957
|
+
summary: "Select a block first, then ask for variants.",
|
|
958
|
+
changes: [],
|
|
959
|
+
suggestions: [
|
|
960
|
+
"Click on a block to select it, then try again.",
|
|
961
|
+
"Example: select the Hero block, then say 'generate 3 variations'."
|
|
962
|
+
],
|
|
963
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
964
|
+
plannerSource,
|
|
965
|
+
modelUsed,
|
|
966
|
+
modelKey
|
|
967
|
+
}, { outcome: "variation_request_redirect", reasonCategory: "ambiguity" })
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
emitStatusTone("analyzing");
|
|
971
|
+
// Content queries (read-only questions about page content) need full props for the LLM
|
|
972
|
+
const contentQuery = body.message ? isContentQuery(body.message) : false;
|
|
973
|
+
const contextPack = plannerContextPack({
|
|
974
|
+
session: body.session,
|
|
975
|
+
slug: effectiveSlug,
|
|
976
|
+
message: plannerMessage,
|
|
977
|
+
currentPage: current,
|
|
978
|
+
activeBlockId: planningActiveBlockId,
|
|
979
|
+
activeBlockType: body.activeBlockType,
|
|
980
|
+
activeEditablePath: planningActiveEditablePath,
|
|
981
|
+
// Plan-first requests get full props for EVERY block (not just text hints +
|
|
982
|
+
// array lengths) so the planner can see nested list-item content — FAQ items,
|
|
983
|
+
// card titles, etc. Without this the model sees "FAQ has 3 items" with no
|
|
984
|
+
// labels and is forced to ask, defeating the plan-first flow. The user opted
|
|
985
|
+
// into a considered plan, so the extra context tokens are justified.
|
|
986
|
+
includeFullProps: translationScope === "page" || contentQuery || requestsPlanFirst(plannerMessage),
|
|
987
|
+
attachments: body.attachments
|
|
988
|
+
});
|
|
989
|
+
const compactContextExperimentEnabled = /^(1|true|yes|on)$/i.test((process.env.CHAT_COMPACT_CONTEXT_EXPERIMENT ?? "").trim());
|
|
990
|
+
const minimalContextExperimentEnabled = /^(1|true|yes|on)$/i.test((process.env.CHAT_MINIMAL_CONTEXT_EXPERIMENT ?? "").trim());
|
|
991
|
+
const intentComplexity = !contentQuery
|
|
992
|
+
? classifyMessageComplexity({
|
|
993
|
+
message: plannerMessage,
|
|
994
|
+
currentPage: current,
|
|
995
|
+
activeBlockId: planningActiveBlockId,
|
|
996
|
+
activeEditablePath: planningActiveEditablePath,
|
|
997
|
+
translationScope
|
|
998
|
+
})
|
|
999
|
+
: "standard";
|
|
1000
|
+
let isLightweightEdit = intentComplexity === "simple";
|
|
1001
|
+
const basePlannerContext = (compactContextExperimentEnabled || isLightweightEdit)
|
|
1002
|
+
? compactPlannerContextPack({ contextPack, message: plannerMessage, translationScope })
|
|
1003
|
+
: contextPack;
|
|
1004
|
+
const useMinimalPlannerContext = !contentQuery && (minimalContextExperimentEnabled || isLightweightEdit) && shouldUseMinimalPlannerContext({
|
|
1005
|
+
message: plannerMessage,
|
|
1006
|
+
translationScope,
|
|
1007
|
+
activeBlockId: planningActiveBlockId,
|
|
1008
|
+
activeEditablePath: planningActiveEditablePath
|
|
1009
|
+
});
|
|
1010
|
+
let plannerContext = useMinimalPlannerContext
|
|
1011
|
+
? minimalPlannerContextPack({ contextPack: basePlannerContext })
|
|
1012
|
+
: basePlannerContext;
|
|
1013
|
+
const contextPackBytes = (() => {
|
|
1014
|
+
try {
|
|
1015
|
+
return Buffer.byteLength(JSON.stringify(plannerContext), "utf8");
|
|
1016
|
+
}
|
|
1017
|
+
catch {
|
|
1018
|
+
return undefined;
|
|
1019
|
+
}
|
|
1020
|
+
})();
|
|
1021
|
+
const plannerContextTelemetryFields = {
|
|
1022
|
+
...(typeof contextPackBytes === "number" ? { contextPackBytes } : {}),
|
|
1023
|
+
compactContextEnabled: compactContextExperimentEnabled,
|
|
1024
|
+
minimalContextEnabled: useMinimalPlannerContext,
|
|
1025
|
+
strictJsonEnabled: isStrictJsonResponseEnabled()
|
|
1026
|
+
};
|
|
1027
|
+
const guardrailFailureResponse = (args) => {
|
|
1028
|
+
const category = classifyGuardrailError(args.reason);
|
|
1029
|
+
ctx.chatTelemetry.push({
|
|
1030
|
+
id: chatRequestId,
|
|
1031
|
+
at: new Date().toISOString(),
|
|
1032
|
+
phase: "result",
|
|
1033
|
+
session: body.session,
|
|
1034
|
+
requestedSlug,
|
|
1035
|
+
effectiveSlug,
|
|
1036
|
+
plannerSource: args.source,
|
|
1037
|
+
modelKey,
|
|
1038
|
+
modelUsed,
|
|
1039
|
+
promptHash,
|
|
1040
|
+
promptExcerpt,
|
|
1041
|
+
promptLength: plannerMessage.length,
|
|
1042
|
+
outcome: "guardrail_failure",
|
|
1043
|
+
reason: args.reason.slice(0, 300),
|
|
1044
|
+
reasonCategory: category,
|
|
1045
|
+
...plannerContextTelemetryFields,
|
|
1046
|
+
...timingFields()
|
|
1047
|
+
});
|
|
1048
|
+
if (category === "ambiguity") {
|
|
1049
|
+
const selected = planningActiveBlockId && current.blocks.find((b) => b.id === planningActiveBlockId)
|
|
1050
|
+
? current.blocks.find((b) => b.id === planningActiveBlockId)
|
|
1051
|
+
: null;
|
|
1052
|
+
return {
|
|
1053
|
+
code: 200,
|
|
1054
|
+
payload: withDebugPayload({
|
|
1055
|
+
status: "needs_clarification",
|
|
1056
|
+
summary: "I need one more detail before applying this safely.",
|
|
1057
|
+
changes: [],
|
|
1058
|
+
mentionedSlugs: [effectiveSlug],
|
|
1059
|
+
suggestions: clarificationSuggestions({ body, current, selected }),
|
|
1060
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
1061
|
+
plannerSource: args.source,
|
|
1062
|
+
modelUsed,
|
|
1063
|
+
modelKey
|
|
1064
|
+
}, { outcome: "needs_clarification", reasonCategory: category })
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
return {
|
|
1068
|
+
code: 400,
|
|
1069
|
+
payload: withDebugPayload({
|
|
1070
|
+
status: "validation_error",
|
|
1071
|
+
summary: "I could not apply that change safely.",
|
|
1072
|
+
changes: [],
|
|
1073
|
+
validationErrors: [formatValidationError(args.reason)],
|
|
1074
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
1075
|
+
plannerSource: args.source,
|
|
1076
|
+
modelUsed,
|
|
1077
|
+
modelKey
|
|
1078
|
+
}, { outcome: "validation_error", reasonCategory: category })
|
|
1079
|
+
};
|
|
1080
|
+
};
|
|
1081
|
+
let planUsage;
|
|
1082
|
+
let usedNativeUnsplashTool = false;
|
|
1083
|
+
let usedNativeImageTool = false;
|
|
1084
|
+
let deferredNativeImageCalls = [];
|
|
1085
|
+
const respondFromPlan = async (plan, source, applyMode = "apply_now", optionsOverride, plannerTier) => {
|
|
1086
|
+
if (plannerTier)
|
|
1087
|
+
activePlannerTier = plannerTier;
|
|
1088
|
+
const usageFields = planUsage ? {
|
|
1089
|
+
inputTokens: planUsage.inputTokens,
|
|
1090
|
+
outputTokens: planUsage.outputTokens,
|
|
1091
|
+
totalTokens: planUsage.totalTokens,
|
|
1092
|
+
...(typeof planUsage.cacheCreationInputTokens === "number"
|
|
1093
|
+
? { cacheCreationInputTokens: planUsage.cacheCreationInputTokens }
|
|
1094
|
+
: {}),
|
|
1095
|
+
...(typeof planUsage.cacheReadInputTokens === "number"
|
|
1096
|
+
? { cacheReadInputTokens: planUsage.cacheReadInputTokens }
|
|
1097
|
+
: {}),
|
|
1098
|
+
estimatedUsd: estimateUsd(modelUsed, planUsage)
|
|
1099
|
+
} : {};
|
|
1100
|
+
ctx.chatTelemetry.push({
|
|
1101
|
+
id: chatRequestId,
|
|
1102
|
+
at: new Date().toISOString(),
|
|
1103
|
+
phase: "plan_generated",
|
|
1104
|
+
session: body.session,
|
|
1105
|
+
requestedSlug,
|
|
1106
|
+
effectiveSlug,
|
|
1107
|
+
plannerSource: source,
|
|
1108
|
+
modelKey,
|
|
1109
|
+
modelUsed,
|
|
1110
|
+
promptHash,
|
|
1111
|
+
promptExcerpt,
|
|
1112
|
+
promptLength: plannerMessage.length,
|
|
1113
|
+
intent: plan.intent,
|
|
1114
|
+
opCount: plan.ops.length,
|
|
1115
|
+
opTypes: plan.ops.map((op) => op.op),
|
|
1116
|
+
plannerTier,
|
|
1117
|
+
...plannerContextTelemetryFields,
|
|
1118
|
+
...usageFields,
|
|
1119
|
+
...timingFields()
|
|
1120
|
+
});
|
|
1121
|
+
let resolvedPlan = plan;
|
|
1122
|
+
let detectedImageOps = [];
|
|
1123
|
+
let effectiveApplyMode = applyMode;
|
|
1124
|
+
if (!optionsOverride?.preResolvedPlan) {
|
|
1125
|
+
resolvedPlan = normalizePlanCopyForUi(plan, current);
|
|
1126
|
+
resolvedPlan = rewriteAddBlockToChildImageUpdate({
|
|
1127
|
+
plan: resolvedPlan,
|
|
1128
|
+
message: plannerMessage,
|
|
1129
|
+
currentPage: current,
|
|
1130
|
+
slug: effectiveSlug
|
|
1131
|
+
});
|
|
1132
|
+
// Strip hallucinated props (planner promising changes to fields the
|
|
1133
|
+
// block schema doesn't have — e.g. "colors" on Stats). Appends a note
|
|
1134
|
+
// to summary_for_user and change_log so the user isn't misled.
|
|
1135
|
+
const hallucinationResult = validateAndStripHallucinatedProps({
|
|
1136
|
+
plan: resolvedPlan,
|
|
1137
|
+
draft: getSessionDraft(body.session)
|
|
1138
|
+
});
|
|
1139
|
+
if (hallucinationResult.hallucinatedProps.length > 0) {
|
|
1140
|
+
for (const entry of hallucinationResult.hallucinatedProps) {
|
|
1141
|
+
ctx.log.warn({
|
|
1142
|
+
event: "planner_hallucinated_prop",
|
|
1143
|
+
chatRequestId,
|
|
1144
|
+
session: body.session,
|
|
1145
|
+
slug: effectiveSlug,
|
|
1146
|
+
blockId: entry.blockId,
|
|
1147
|
+
blockType: entry.blockType,
|
|
1148
|
+
propName: entry.propName,
|
|
1149
|
+
plannerSource: source,
|
|
1150
|
+
modelKey,
|
|
1151
|
+
modelUsed
|
|
1152
|
+
}, "Planner generated an unsupported prop; stripped before apply");
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
// Ensure change_log has one entry per op. When the planner clusters
|
|
1156
|
+
// ops into fewer entries, synthesize generic fallbacks so the user
|
|
1157
|
+
// sees the correct count instead of silently approving uncovered ops.
|
|
1158
|
+
const changelogResult = validateChangelogCoverage({
|
|
1159
|
+
plan: resolvedPlan,
|
|
1160
|
+
draft: getSessionDraft(body.session)
|
|
1161
|
+
});
|
|
1162
|
+
if (changelogResult.missingCount > 0) {
|
|
1163
|
+
ctx.log.warn({
|
|
1164
|
+
event: "planner_incomplete_changelog",
|
|
1165
|
+
chatRequestId,
|
|
1166
|
+
session: body.session,
|
|
1167
|
+
slug: effectiveSlug,
|
|
1168
|
+
opCount: resolvedPlan.ops.length,
|
|
1169
|
+
missingCount: changelogResult.missingCount,
|
|
1170
|
+
plannerSource: source,
|
|
1171
|
+
modelKey,
|
|
1172
|
+
modelUsed
|
|
1173
|
+
}, "Planner returned fewer change_log entries than ops; synthesized fallbacks");
|
|
1174
|
+
}
|
|
1175
|
+
if (changelogResult.extraCount > 0) {
|
|
1176
|
+
ctx.log.warn({
|
|
1177
|
+
event: "planner_overdescribed_changelog",
|
|
1178
|
+
chatRequestId,
|
|
1179
|
+
session: body.session,
|
|
1180
|
+
slug: effectiveSlug,
|
|
1181
|
+
opCount: resolvedPlan.ops.length,
|
|
1182
|
+
extraCount: changelogResult.extraCount,
|
|
1183
|
+
droppedEntries: changelogResult.droppedEntries,
|
|
1184
|
+
opTypes: resolvedPlan.ops.map((op) => op.op),
|
|
1185
|
+
plannerSource: source,
|
|
1186
|
+
modelKey,
|
|
1187
|
+
modelUsed
|
|
1188
|
+
}, "Planner returned more change_log entries than ops; trimmed trailing entries to match");
|
|
1189
|
+
}
|
|
1190
|
+
if (changelogResult.fieldMislabelCount > 0) {
|
|
1191
|
+
ctx.log.warn({
|
|
1192
|
+
event: "planner_mislabeled_changelog_field",
|
|
1193
|
+
chatRequestId,
|
|
1194
|
+
session: body.session,
|
|
1195
|
+
slug: effectiveSlug,
|
|
1196
|
+
fieldMislabelCount: changelogResult.fieldMislabelCount,
|
|
1197
|
+
relabeledEntries: changelogResult.relabeledEntries,
|
|
1198
|
+
plannerSource: source,
|
|
1199
|
+
modelKey,
|
|
1200
|
+
modelUsed
|
|
1201
|
+
}, "Planner named a field its op does not change; relabeled change_log/summary from the actual patch");
|
|
1202
|
+
}
|
|
1203
|
+
// Detect image ops synchronously before making any API calls
|
|
1204
|
+
detectedImageOps = detectImageOps({
|
|
1205
|
+
plan: resolvedPlan,
|
|
1206
|
+
message: plannerMessage,
|
|
1207
|
+
slug: effectiveSlug,
|
|
1208
|
+
currentPage: current,
|
|
1209
|
+
activeBlockId: planningActiveBlockId,
|
|
1210
|
+
activeEditablePath: planningActiveEditablePath
|
|
1211
|
+
});
|
|
1212
|
+
// Filter out image ops targeting blocks whose schema doesn't support imageUrl
|
|
1213
|
+
if (detectedImageOps.length > 0) {
|
|
1214
|
+
const unsupportedBlocks = new Set();
|
|
1215
|
+
const unsupportedImagePaths = [];
|
|
1216
|
+
detectedImageOps = detectedImageOps.filter((imgOp) => {
|
|
1217
|
+
const block = current.blocks.find((b) => b.id === imgOp.blockId);
|
|
1218
|
+
if (!block)
|
|
1219
|
+
return true;
|
|
1220
|
+
const path = imgOp.path || "imageUrl";
|
|
1221
|
+
if (blockSupportsImageAtPath(block.type, path))
|
|
1222
|
+
return true;
|
|
1223
|
+
unsupportedBlocks.add(block.type);
|
|
1224
|
+
unsupportedImagePaths.push({ blockId: imgOp.blockId, pageSlug: imgOp.pageSlug, path, altPath: imgOp.altPath });
|
|
1225
|
+
return false;
|
|
1226
|
+
});
|
|
1227
|
+
// Strip unsupported imageUrl values from ops patches so they don't leak through
|
|
1228
|
+
for (const removed of unsupportedImagePaths) {
|
|
1229
|
+
const op = resolvedPlan.ops.find((o) => o.op === "update_props" && o.blockId === removed.blockId && o.pageSlug === removed.pageSlug);
|
|
1230
|
+
if (!op || op.op !== "update_props")
|
|
1231
|
+
continue;
|
|
1232
|
+
const rawPatch = op.patch;
|
|
1233
|
+
const patchCandidate = rawPatch && typeof rawPatch.props === "object" && rawPatch.props !== null && !Array.isArray(rawPatch.props)
|
|
1234
|
+
? rawPatch.props
|
|
1235
|
+
: rawPatch;
|
|
1236
|
+
deleteValueAtPath(patchCandidate, removed.path);
|
|
1237
|
+
if (removed.altPath)
|
|
1238
|
+
deleteValueAtPath(patchCandidate, removed.altPath);
|
|
1239
|
+
}
|
|
1240
|
+
if (unsupportedBlocks.size > 0) {
|
|
1241
|
+
const names = Array.from(unsupportedBlocks).join(", ");
|
|
1242
|
+
resolvedPlan.change_log = [
|
|
1243
|
+
...resolvedPlan.change_log,
|
|
1244
|
+
`Note: ${names} blocks do not support images. Consider using CardGrid instead.`
|
|
1245
|
+
];
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
if (detectedImageOps.length > 0) {
|
|
1249
|
+
// Decide whether to hold for approval. Image resolution alone is
|
|
1250
|
+
// deterministic background work — if the plan is nothing but image
|
|
1251
|
+
// updates, auto-apply and resolve in the background so the user sees
|
|
1252
|
+
// "Added a spring avocados image" → shimmer → real image without a
|
|
1253
|
+
// redundant "approve" button. Compound plans (structural changes,
|
|
1254
|
+
// text updates, multi-op) keep the approval gate.
|
|
1255
|
+
const imageOnlyPlan = isImageOnlyUpdatePropsPlan(resolvedPlan);
|
|
1256
|
+
if (!imageOnlyPlan) {
|
|
1257
|
+
effectiveApplyMode = "plan_only";
|
|
1258
|
+
}
|
|
1259
|
+
// Strip placeholder imageUrl values from ops so preview shows current image.
|
|
1260
|
+
// For approval flow: values are restored to "pending" before withUnsplashHeroImage.
|
|
1261
|
+
// For auto-apply flow: the deferred resolution path restores them (see below).
|
|
1262
|
+
for (const op of resolvedPlan.ops) {
|
|
1263
|
+
if (op.op !== "update_props")
|
|
1264
|
+
continue;
|
|
1265
|
+
const imgOps = detectedImageOps.filter((io) => io.blockId === op.blockId && io.pageSlug === op.pageSlug);
|
|
1266
|
+
if (imgOps.length === 0)
|
|
1267
|
+
continue;
|
|
1268
|
+
const rawPatch = op.patch;
|
|
1269
|
+
const patchCandidate = rawPatch && typeof rawPatch.props === "object" && rawPatch.props !== null && !Array.isArray(rawPatch.props)
|
|
1270
|
+
? rawPatch.props
|
|
1271
|
+
: rawPatch;
|
|
1272
|
+
for (const imgOp of imgOps) {
|
|
1273
|
+
if (typeof imgOp.path === "string" && imgOp.path.length > 0) {
|
|
1274
|
+
deleteValueAtPath(patchCandidate, imgOp.path);
|
|
1275
|
+
}
|
|
1276
|
+
else {
|
|
1277
|
+
delete patchCandidate.imageUrl;
|
|
1278
|
+
}
|
|
1279
|
+
if (typeof imgOp.altPath === "string" && imgOp.altPath.length > 0) {
|
|
1280
|
+
deleteValueAtPath(patchCandidate, imgOp.altPath);
|
|
1281
|
+
}
|
|
1282
|
+
else {
|
|
1283
|
+
delete patchCandidate.imageAlt;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
// If stripping left the patch empty, write a shimmer placeholder so
|
|
1287
|
+
// applyOpsAtomically doesn't reject the op as a no-op (which aborts
|
|
1288
|
+
// the deferred image resolution before it can swap in the real URL).
|
|
1289
|
+
if (Object.keys(patchCandidate).length === 0) {
|
|
1290
|
+
const primary = imgOps[0];
|
|
1291
|
+
const placeholder = primary.provider === "unsplash" ? SEARCHING_IMAGE_PLACEHOLDER : GENERATING_IMAGE_PLACEHOLDER;
|
|
1292
|
+
if (typeof primary.path === "string" && primary.path.length > 0) {
|
|
1293
|
+
setValueAtPath(patchCandidate, primary.path, placeholder);
|
|
1294
|
+
}
|
|
1295
|
+
else {
|
|
1296
|
+
patchCandidate.imageUrl = placeholder;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
// Annotate change_log with image work.
|
|
1301
|
+
// Phrasing differs: approval flow uses future tense ("Will ..."),
|
|
1302
|
+
// auto-apply uses present tense ("Adding ...") so the summary reads
|
|
1303
|
+
// sensibly even though the image arrives a moment later.
|
|
1304
|
+
// Per prompt rule (prompts.ts: HERO_IMAGE_URL_BASE) we never mention
|
|
1305
|
+
// the image source (Unsplash / AI) in user-facing copy — just "image".
|
|
1306
|
+
for (const imgOp of detectedImageOps) {
|
|
1307
|
+
const query = imgOp.query && imgOp.query.trim().length > 0 ? imgOp.query.trim() : null;
|
|
1308
|
+
const subject = query ? `: "${query}"` : "";
|
|
1309
|
+
const pendingImageMessage = imageOnlyPlan
|
|
1310
|
+
? `Finding an image${subject}…`
|
|
1311
|
+
: `Will find an image${subject}.`;
|
|
1312
|
+
resolvedPlan.change_log = [...resolvedPlan.change_log, pendingImageMessage];
|
|
1313
|
+
}
|
|
1314
|
+
if (imageOnlyPlan) {
|
|
1315
|
+
// Auto-apply path: tag the plan so the post-apply deferred block
|
|
1316
|
+
// resolves the real image and streams it back via onOpApplied.
|
|
1317
|
+
;
|
|
1318
|
+
resolvedPlan._deferredImageResolution = true;
|
|
1319
|
+
resolvedPlan._deferredImageArgs = {
|
|
1320
|
+
message: plannerMessage,
|
|
1321
|
+
slug: effectiveSlug,
|
|
1322
|
+
currentPage: current,
|
|
1323
|
+
activeBlockId: planningActiveBlockId,
|
|
1324
|
+
activeEditablePath: planningActiveEditablePath,
|
|
1325
|
+
chatRequestId,
|
|
1326
|
+
gdriveFolderId,
|
|
1327
|
+
preferredImageOps: detectedImageOps
|
|
1328
|
+
};
|
|
1329
|
+
// Mirror the else-branch's progress markers so the editor gets instant
|
|
1330
|
+
// "plan ready" feedback while images resolve in the background.
|
|
1331
|
+
options?.onPlanMeta?.({
|
|
1332
|
+
intent: resolvedPlan.intent,
|
|
1333
|
+
summary: effectiveApplyMode === "plan_only" ? pastToFutureTense(resolvedPlan.summary_for_user) : resolvedPlan.summary_for_user,
|
|
1334
|
+
estimatedOps: resolvedPlan.ops.length
|
|
1335
|
+
});
|
|
1336
|
+
markFirstStructuredProgress();
|
|
1337
|
+
if (!stageTimeline.some((item) => item.stage === "plan_ready")) {
|
|
1338
|
+
const planReadyAtMs = Date.now() - pipelineStartedAtMs;
|
|
1339
|
+
stageTimeline.push({ stage: "plan_ready", atMs: planReadyAtMs });
|
|
1340
|
+
ctx.chatTelemetry.push({
|
|
1341
|
+
id: chatRequestId,
|
|
1342
|
+
at: new Date().toISOString(),
|
|
1343
|
+
phase: "milestone",
|
|
1344
|
+
timelineStage: "plan_ready",
|
|
1345
|
+
session: body.session,
|
|
1346
|
+
requestedSlug,
|
|
1347
|
+
effectiveSlug,
|
|
1348
|
+
plannerSource: source,
|
|
1349
|
+
modelKey,
|
|
1350
|
+
modelUsed,
|
|
1351
|
+
promptHash,
|
|
1352
|
+
promptExcerpt,
|
|
1353
|
+
promptLength: plannerMessage.length,
|
|
1354
|
+
totalDurationMs: planReadyAtMs
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
else {
|
|
1360
|
+
// Emit plan metadata and progress markers so the UI gets instant feedback
|
|
1361
|
+
// while images resolve in the background after ops are applied.
|
|
1362
|
+
options?.onPlanMeta?.({
|
|
1363
|
+
intent: resolvedPlan.intent,
|
|
1364
|
+
summary: resolvedPlan.summary_for_user,
|
|
1365
|
+
estimatedOps: resolvedPlan.ops.length
|
|
1366
|
+
});
|
|
1367
|
+
markFirstStructuredProgress();
|
|
1368
|
+
if (!stageTimeline.some((item) => item.stage === "plan_ready")) {
|
|
1369
|
+
const planReadyAtMs = Date.now() - pipelineStartedAtMs;
|
|
1370
|
+
stageTimeline.push({ stage: "plan_ready", atMs: planReadyAtMs });
|
|
1371
|
+
ctx.chatTelemetry.push({
|
|
1372
|
+
id: chatRequestId,
|
|
1373
|
+
at: new Date().toISOString(),
|
|
1374
|
+
phase: "milestone",
|
|
1375
|
+
timelineStage: "plan_ready",
|
|
1376
|
+
session: body.session,
|
|
1377
|
+
requestedSlug,
|
|
1378
|
+
effectiveSlug,
|
|
1379
|
+
plannerSource: source,
|
|
1380
|
+
modelKey,
|
|
1381
|
+
modelUsed,
|
|
1382
|
+
promptHash,
|
|
1383
|
+
promptExcerpt,
|
|
1384
|
+
promptLength: plannerMessage.length,
|
|
1385
|
+
totalDurationMs: planReadyAtMs
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
// Defer image resolution: apply text/structural ops immediately, then
|
|
1389
|
+
// resolve images in the background and patch them in via follow-up events.
|
|
1390
|
+
// This avoids blocking the user for 1-15s while images are fetched/generated.
|
|
1391
|
+
const deferImageResolution = !/^(0|false|no|off)$/i.test((process.env.CHAT_DEFER_IMAGE_RESOLUTION ?? "1").trim());
|
|
1392
|
+
if (!usedNativeUnsplashTool && !usedNativeImageTool && deferImageResolution) {
|
|
1393
|
+
// Tag the plan with deferred image metadata so the apply step can
|
|
1394
|
+
// resolve images after text ops are visible.
|
|
1395
|
+
;
|
|
1396
|
+
resolvedPlan._deferredImageResolution = true;
|
|
1397
|
+
resolvedPlan._deferredImageArgs = {
|
|
1398
|
+
message: plannerMessage,
|
|
1399
|
+
slug: effectiveSlug,
|
|
1400
|
+
currentPage: current,
|
|
1401
|
+
activeBlockId: planningActiveBlockId,
|
|
1402
|
+
activeEditablePath: planningActiveEditablePath,
|
|
1403
|
+
chatRequestId,
|
|
1404
|
+
gdriveFolderId
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
else if (!usedNativeUnsplashTool && !usedNativeImageTool) {
|
|
1408
|
+
// Legacy blocking path (deferred disabled via env)
|
|
1409
|
+
const imageResolutionStartMs = Date.now();
|
|
1410
|
+
emitStatusTone("resolving_assets");
|
|
1411
|
+
resolvedPlan = await withUnsplashHeroImage({
|
|
1412
|
+
plan: resolvedPlan,
|
|
1413
|
+
message: plannerMessage,
|
|
1414
|
+
slug: effectiveSlug,
|
|
1415
|
+
currentPage: current,
|
|
1416
|
+
activeBlockId: planningActiveBlockId,
|
|
1417
|
+
activeEditablePath: planningActiveEditablePath,
|
|
1418
|
+
chatRequestId,
|
|
1419
|
+
gdriveFolderId,
|
|
1420
|
+
log: ctx.log,
|
|
1421
|
+
onStatusUpdate: options?.onStatusUpdate,
|
|
1422
|
+
onImageProgress: options?.onImageProgress
|
|
1423
|
+
});
|
|
1424
|
+
imageResolutionDurationMs += Date.now() - imageResolutionStartMs;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
if (resolvedPlan.intent === "needs_clarification" && planningActiveBlockId) {
|
|
1428
|
+
const focusedFallback = compileDeterministicPlan({
|
|
1429
|
+
session: body.session ?? "dev",
|
|
1430
|
+
intent: { action: "clarify" },
|
|
1431
|
+
message: plannerMessage,
|
|
1432
|
+
slug: effectiveSlug ?? "/",
|
|
1433
|
+
currentPage: current,
|
|
1434
|
+
activeBlockId: planningActiveBlockId,
|
|
1435
|
+
activeEditablePath: planningActiveEditablePath,
|
|
1436
|
+
locale: body.locale
|
|
1437
|
+
});
|
|
1438
|
+
if (focusedFallback?.intent === "edit_plan" && focusedFallback.ops.length > 0) {
|
|
1439
|
+
resolvedPlan = focusedFallback;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
if (resolvedPlan.intent === "needs_clarification" && body.message && isBlockCatalogQuery(body.message)) {
|
|
1444
|
+
const forcedInfo = infoResponse({ body, current, plannerSource: source, modelUsed, modelKey });
|
|
1445
|
+
return { done: true, response: forcedInfo };
|
|
1446
|
+
}
|
|
1447
|
+
if (translationScope === "page") {
|
|
1448
|
+
const translationCoverageGap = findFullPageTranslationCoverageGap({
|
|
1449
|
+
plan: resolvedPlan,
|
|
1450
|
+
message: plannerMessage,
|
|
1451
|
+
currentPage: current,
|
|
1452
|
+
slug: effectiveSlug
|
|
1453
|
+
});
|
|
1454
|
+
if (translationCoverageGap)
|
|
1455
|
+
return { done: false, reason: translationCoverageGap };
|
|
1456
|
+
}
|
|
1457
|
+
const explicitCtaCoverageGap = findExplicitCtaTargetCoverageGap({
|
|
1458
|
+
plan: resolvedPlan,
|
|
1459
|
+
message: plannerMessage,
|
|
1460
|
+
currentPage: current,
|
|
1461
|
+
slug: effectiveSlug
|
|
1462
|
+
});
|
|
1463
|
+
if (explicitCtaCoverageGap)
|
|
1464
|
+
return { done: false, reason: explicitCtaCoverageGap };
|
|
1465
|
+
// Tier-1 destructive-action gate. Hold any edit_plan containing destructive
|
|
1466
|
+
// ops (remove_page on a page with content, multi-page scope, bulk deletes)
|
|
1467
|
+
// for explicit approval — undo protects recovery but not accidental intent.
|
|
1468
|
+
// Skip when preResolvedPlan is set: that flag means the plan came from
|
|
1469
|
+
// apply_pending_plan (user already clicked Approve) and must not loop back.
|
|
1470
|
+
let destructiveReasons = [];
|
|
1471
|
+
if (resolvedPlan.intent === "edit_plan" && resolvedPlan.ops.length > 0 && !optionsOverride?.preResolvedPlan) {
|
|
1472
|
+
const destructiveEval = evaluateDestructiveActions(resolvedPlan, (slug) => getPage(body.session, slug));
|
|
1473
|
+
if (destructiveEval.requiresApproval) {
|
|
1474
|
+
effectiveApplyMode = "plan_only";
|
|
1475
|
+
destructiveReasons = destructiveEval.messages;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
// Emit plan metadata if not already emitted (deferred image path and other branches skip the early emit above)
|
|
1479
|
+
if (!stageTimeline.some((item) => item.stage === "first_structured_progress")) {
|
|
1480
|
+
options?.onPlanMeta?.({
|
|
1481
|
+
intent: resolvedPlan.intent,
|
|
1482
|
+
summary: effectiveApplyMode === "plan_only" ? pastToFutureTense(resolvedPlan.summary_for_user) : resolvedPlan.summary_for_user,
|
|
1483
|
+
estimatedOps: resolvedPlan.ops.length
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
markFirstStructuredProgress();
|
|
1487
|
+
if (!stageTimeline.some((item) => item.stage === "plan_ready")) {
|
|
1488
|
+
const planReadyAtMs = Date.now() - pipelineStartedAtMs;
|
|
1489
|
+
stageTimeline.push({ stage: "plan_ready", atMs: planReadyAtMs });
|
|
1490
|
+
ctx.chatTelemetry.push({
|
|
1491
|
+
id: chatRequestId,
|
|
1492
|
+
at: new Date().toISOString(),
|
|
1493
|
+
phase: "milestone",
|
|
1494
|
+
timelineStage: "plan_ready",
|
|
1495
|
+
session: body.session,
|
|
1496
|
+
requestedSlug,
|
|
1497
|
+
effectiveSlug,
|
|
1498
|
+
plannerSource: source,
|
|
1499
|
+
modelKey,
|
|
1500
|
+
modelUsed,
|
|
1501
|
+
promptHash,
|
|
1502
|
+
promptExcerpt,
|
|
1503
|
+
promptLength: plannerMessage.length,
|
|
1504
|
+
totalDurationMs: planReadyAtMs
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
if (resolvedPlan.intent === "content_answer") {
|
|
1508
|
+
ctx.chatTelemetry.push({
|
|
1509
|
+
id: chatRequestId,
|
|
1510
|
+
at: new Date().toISOString(),
|
|
1511
|
+
phase: "result",
|
|
1512
|
+
session: body.session,
|
|
1513
|
+
requestedSlug,
|
|
1514
|
+
effectiveSlug,
|
|
1515
|
+
plannerSource: source,
|
|
1516
|
+
modelKey,
|
|
1517
|
+
modelUsed,
|
|
1518
|
+
promptHash,
|
|
1519
|
+
promptExcerpt,
|
|
1520
|
+
promptLength: plannerMessage.length,
|
|
1521
|
+
outcome: "content_answer",
|
|
1522
|
+
intent: resolvedPlan.intent,
|
|
1523
|
+
opCount: 0,
|
|
1524
|
+
opTypes: [],
|
|
1525
|
+
plannerTier,
|
|
1526
|
+
...plannerContextTelemetryFields,
|
|
1527
|
+
...timingFields()
|
|
1528
|
+
});
|
|
1529
|
+
if (body.message)
|
|
1530
|
+
pushChatHistory(body.session, body.message, resolvedPlan.summary_for_user);
|
|
1531
|
+
return {
|
|
1532
|
+
done: true,
|
|
1533
|
+
response: {
|
|
1534
|
+
code: 200,
|
|
1535
|
+
payload: withDebugPayload({
|
|
1536
|
+
status: "info",
|
|
1537
|
+
summary: resolvedPlan.summary_for_user,
|
|
1538
|
+
changes: resolvedPlan.change_log,
|
|
1539
|
+
mentionedSlugs: collectMentionedSlugsFromPlan(resolvedPlan, effectiveSlug),
|
|
1540
|
+
suggestions: resolvedPlan.suggested_next_actions ?? [],
|
|
1541
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
1542
|
+
plannerSource: source,
|
|
1543
|
+
modelUsed,
|
|
1544
|
+
modelKey
|
|
1545
|
+
}, {
|
|
1546
|
+
outcome: "content_answer",
|
|
1547
|
+
intent: resolvedPlan.intent,
|
|
1548
|
+
opCount: 0,
|
|
1549
|
+
opTypes: [],
|
|
1550
|
+
...usageFields
|
|
1551
|
+
})
|
|
1552
|
+
}
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
if (resolvedPlan.intent === "needs_clarification") {
|
|
1556
|
+
// If the message already had a clarification suffix and still failed, clear the
|
|
1557
|
+
// pending context — the clarification chain is not converging and further stacking
|
|
1558
|
+
// will only produce incoherent prompts.
|
|
1559
|
+
const alreadyHadClarification = plannerMessage.includes("\nClarification from user:");
|
|
1560
|
+
if (alreadyHadClarification) {
|
|
1561
|
+
pendingClarificationBySession.delete(body.session);
|
|
1562
|
+
}
|
|
1563
|
+
else {
|
|
1564
|
+
pendingClarificationBySession.set(body.session, { baseRequest: plannerMessage, updatedAt: new Date().toISOString() });
|
|
1565
|
+
}
|
|
1566
|
+
ctx.chatTelemetry.push({
|
|
1567
|
+
id: chatRequestId,
|
|
1568
|
+
at: new Date().toISOString(),
|
|
1569
|
+
phase: "result",
|
|
1570
|
+
session: body.session,
|
|
1571
|
+
requestedSlug,
|
|
1572
|
+
effectiveSlug,
|
|
1573
|
+
plannerSource: source,
|
|
1574
|
+
modelKey,
|
|
1575
|
+
modelUsed,
|
|
1576
|
+
promptHash,
|
|
1577
|
+
promptExcerpt,
|
|
1578
|
+
promptLength: plannerMessage.length,
|
|
1579
|
+
outcome: "needs_clarification",
|
|
1580
|
+
intent: resolvedPlan.intent,
|
|
1581
|
+
opCount: resolvedPlan.ops.length,
|
|
1582
|
+
opTypes: resolvedPlan.ops.map((op) => op.op),
|
|
1583
|
+
plannerTier,
|
|
1584
|
+
...plannerContextTelemetryFields,
|
|
1585
|
+
...timingFields()
|
|
1586
|
+
});
|
|
1587
|
+
if (body.message)
|
|
1588
|
+
pushChatHistory(body.session, body.message, resolvedPlan.summary_for_user);
|
|
1589
|
+
const selected = planningActiveBlockId && current.blocks.find((b) => b.id === planningActiveBlockId)
|
|
1590
|
+
? current.blocks.find((b) => b.id === planningActiveBlockId)
|
|
1591
|
+
: null;
|
|
1592
|
+
return {
|
|
1593
|
+
done: true,
|
|
1594
|
+
response: {
|
|
1595
|
+
code: 200,
|
|
1596
|
+
payload: withDebugPayload({
|
|
1597
|
+
status: "needs_clarification",
|
|
1598
|
+
summary: resolvedPlan.summary_for_user,
|
|
1599
|
+
changes: resolvedPlan.change_log,
|
|
1600
|
+
mentionedSlugs: collectMentionedSlugsFromPlan(resolvedPlan, effectiveSlug),
|
|
1601
|
+
suggestions: resolvedPlan.suggested_next_actions ?? clarificationSuggestions({ body, current, selected }),
|
|
1602
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
1603
|
+
plannerSource: source,
|
|
1604
|
+
modelUsed,
|
|
1605
|
+
modelKey
|
|
1606
|
+
}, {
|
|
1607
|
+
outcome: "needs_clarification",
|
|
1608
|
+
intent: resolvedPlan.intent,
|
|
1609
|
+
opCount: resolvedPlan.ops.length,
|
|
1610
|
+
opTypes: resolvedPlan.ops.map((op) => op.op),
|
|
1611
|
+
...usageFields
|
|
1612
|
+
})
|
|
1613
|
+
}
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
if (effectiveApplyMode === "plan_only") {
|
|
1617
|
+
// Dry-run the held plan up front to build the structured before→after
|
|
1618
|
+
// preview the approval card renders AND to learn whether it changes anything.
|
|
1619
|
+
// Best-effort: a preview failure must never block showing the plan.
|
|
1620
|
+
let planPreview;
|
|
1621
|
+
try {
|
|
1622
|
+
const dry = await applyOpsAtomically(body.session, resolvedPlan.ops, { componentsManifest, dryRun: true });
|
|
1623
|
+
if (dry.preview && dry.opResults) {
|
|
1624
|
+
planPreview = {
|
|
1625
|
+
opResults: dry.opResults,
|
|
1626
|
+
diff: dry.preview,
|
|
1627
|
+
appliedCount: dry.opResults.filter((r) => r.status === "applied").length,
|
|
1628
|
+
skippedCount: dry.opResults.filter((r) => r.status === "skipped").length,
|
|
1629
|
+
failedCount: dry.opResults.filter((r) => r.status === "failed").length,
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
catch (previewError) {
|
|
1634
|
+
ctx.log.warn({ err: String(previewError) }, "[chat] plan preview dry-run failed; falling back to change_log");
|
|
1635
|
+
}
|
|
1636
|
+
// Every op was a no-op — an unsupported field stripped to an empty patch, or
|
|
1637
|
+
// values already set. The plan changes nothing, so don't offer it for approval
|
|
1638
|
+
// with a "Will change X" summary the draft never honours (#23). Return an
|
|
1639
|
+
// honest no-effective-change response instead.
|
|
1640
|
+
if (planPreview && planPreview.appliedCount === 0 && planPreview.failedCount === 0 && planPreview.skippedCount > 0) {
|
|
1641
|
+
pendingApprovalPlanBySession.delete(body.session);
|
|
1642
|
+
const onlyUnchanged = planPreview.opResults.every((r) => r.status !== "skipped" || r.reason === "unchanged_value");
|
|
1643
|
+
const noopSummary = onlyUnchanged
|
|
1644
|
+
? "No changes needed — that content is already set."
|
|
1645
|
+
: "I couldn't make that change — it isn't supported on this block. Try a different field or wording.";
|
|
1646
|
+
ctx.chatTelemetry.push({
|
|
1647
|
+
id: chatRequestId,
|
|
1648
|
+
at: new Date().toISOString(),
|
|
1649
|
+
phase: "result",
|
|
1650
|
+
session: body.session,
|
|
1651
|
+
requestedSlug,
|
|
1652
|
+
effectiveSlug,
|
|
1653
|
+
plannerSource: source,
|
|
1654
|
+
modelKey,
|
|
1655
|
+
modelUsed,
|
|
1656
|
+
promptHash,
|
|
1657
|
+
promptExcerpt,
|
|
1658
|
+
promptLength: plannerMessage.length,
|
|
1659
|
+
outcome: "no_effective_change",
|
|
1660
|
+
intent: resolvedPlan.intent,
|
|
1661
|
+
opCount: resolvedPlan.ops.length,
|
|
1662
|
+
opTypes: resolvedPlan.ops.map((op) => op.op),
|
|
1663
|
+
skippedOpCount: planPreview.skippedCount,
|
|
1664
|
+
plannerTier,
|
|
1665
|
+
...plannerContextTelemetryFields,
|
|
1666
|
+
...timingFields()
|
|
1667
|
+
});
|
|
1668
|
+
return {
|
|
1669
|
+
done: true,
|
|
1670
|
+
response: {
|
|
1671
|
+
code: 200,
|
|
1672
|
+
payload: withDebugPayload({
|
|
1673
|
+
status: "info",
|
|
1674
|
+
summary: noopSummary,
|
|
1675
|
+
changes: [],
|
|
1676
|
+
mentionedSlugs: [effectiveSlug],
|
|
1677
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
1678
|
+
plannerSource: source,
|
|
1679
|
+
modelUsed,
|
|
1680
|
+
modelKey
|
|
1681
|
+
}, { outcome: "no_effective_change", plannerTier })
|
|
1682
|
+
}
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
const pendingPlanId = randomUUID();
|
|
1686
|
+
// Planner prompts instruct past tense on the assumption ops are applied
|
|
1687
|
+
// immediately, but the approval gate holds the plan. Flip to future tense
|
|
1688
|
+
// so the copy matches the "Approve plan" UX.
|
|
1689
|
+
const approvalSummary = pastToFutureTense(resolvedPlan.summary_for_user);
|
|
1690
|
+
const approvalChangeLog = resolvedPlan.change_log.map(pastToFutureTense);
|
|
1691
|
+
pendingApprovalPlanBySession.set(body.session, {
|
|
1692
|
+
id: pendingPlanId,
|
|
1693
|
+
createdAt: new Date().toISOString(),
|
|
1694
|
+
promptHash,
|
|
1695
|
+
requestedSlug,
|
|
1696
|
+
effectiveSlug,
|
|
1697
|
+
summary: resolvedPlan.summary_for_user,
|
|
1698
|
+
source,
|
|
1699
|
+
modelUsed,
|
|
1700
|
+
modelKey,
|
|
1701
|
+
plan: structuredClone(resolvedPlan),
|
|
1702
|
+
originalMessage: plannerMessage,
|
|
1703
|
+
...(detectedImageOps.length > 0 ? { pendingImageOps: detectedImageOps } : {}),
|
|
1704
|
+
...(destructiveReasons.length > 0 ? { destructiveReasons } : {})
|
|
1705
|
+
});
|
|
1706
|
+
ctx.chatTelemetry.push({
|
|
1707
|
+
id: chatRequestId,
|
|
1708
|
+
at: new Date().toISOString(),
|
|
1709
|
+
phase: "result",
|
|
1710
|
+
session: body.session,
|
|
1711
|
+
requestedSlug,
|
|
1712
|
+
effectiveSlug,
|
|
1713
|
+
plannerSource: source,
|
|
1714
|
+
modelKey,
|
|
1715
|
+
modelUsed,
|
|
1716
|
+
promptHash,
|
|
1717
|
+
promptExcerpt,
|
|
1718
|
+
promptLength: plannerMessage.length,
|
|
1719
|
+
outcome: "plan_ready_for_approval",
|
|
1720
|
+
intent: resolvedPlan.intent,
|
|
1721
|
+
opCount: resolvedPlan.ops.length,
|
|
1722
|
+
opTypes: resolvedPlan.ops.map((op) => op.op),
|
|
1723
|
+
plannerTier,
|
|
1724
|
+
...plannerContextTelemetryFields,
|
|
1725
|
+
...timingFields()
|
|
1726
|
+
});
|
|
1727
|
+
// Filter out slugs that don't exist yet — for plan_only the page hasn't
|
|
1728
|
+
// been applied, so an "Open /season-recipes" navigation chip would 404.
|
|
1729
|
+
// The full mentioned list is restored after the user approves the plan.
|
|
1730
|
+
const planOnlyDraft = getSessionDraft(body.session);
|
|
1731
|
+
const planOnlyMentionedSlugs = collectMentionedSlugsFromPlan(resolvedPlan, effectiveSlug)
|
|
1732
|
+
.filter((slug) => planOnlyDraft.has(slug));
|
|
1733
|
+
// planPreview was computed at the top of this branch (the no-op guard needs it).
|
|
1734
|
+
return {
|
|
1735
|
+
done: true,
|
|
1736
|
+
response: {
|
|
1737
|
+
code: 200,
|
|
1738
|
+
payload: withDebugPayload({
|
|
1739
|
+
status: "plan_ready",
|
|
1740
|
+
summary: approvalSummary,
|
|
1741
|
+
changes: approvalChangeLog,
|
|
1742
|
+
mentionedSlugs: planOnlyMentionedSlugs,
|
|
1743
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
1744
|
+
plannerSource: source,
|
|
1745
|
+
modelUsed,
|
|
1746
|
+
modelKey,
|
|
1747
|
+
pendingPlanId,
|
|
1748
|
+
...(planPreview ? { planPreview } : {}),
|
|
1749
|
+
...(destructiveReasons.length > 0 ? { destructiveReasons } : {})
|
|
1750
|
+
}, {
|
|
1751
|
+
outcome: "plan_ready_for_approval",
|
|
1752
|
+
intent: resolvedPlan.intent,
|
|
1753
|
+
opCount: resolvedPlan.ops.length,
|
|
1754
|
+
opTypes: resolvedPlan.ops.map((op) => op.op),
|
|
1755
|
+
...usageFields
|
|
1756
|
+
})
|
|
1757
|
+
}
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
const previous = getPage(body.session, effectiveSlug);
|
|
1761
|
+
const hasCreatePage = resolvedPlan.ops.some((op) => op.op === "create_page");
|
|
1762
|
+
if (!previous && !hasCreatePage) {
|
|
1763
|
+
return {
|
|
1764
|
+
done: true,
|
|
1765
|
+
response: { code: 404, payload: { error: "page not found" } }
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
if (resolvedPlan.ops.length === 0) {
|
|
1769
|
+
pendingClarificationBySession.delete(body.session);
|
|
1770
|
+
pendingApprovalPlanBySession.delete(body.session);
|
|
1771
|
+
ctx.chatTelemetry.push({
|
|
1772
|
+
id: chatRequestId,
|
|
1773
|
+
at: new Date().toISOString(),
|
|
1774
|
+
phase: "result",
|
|
1775
|
+
session: body.session,
|
|
1776
|
+
requestedSlug,
|
|
1777
|
+
effectiveSlug,
|
|
1778
|
+
plannerSource: source,
|
|
1779
|
+
modelKey,
|
|
1780
|
+
modelUsed,
|
|
1781
|
+
promptHash,
|
|
1782
|
+
promptExcerpt,
|
|
1783
|
+
promptLength: plannerMessage.length,
|
|
1784
|
+
outcome: "no_effective_change",
|
|
1785
|
+
intent: resolvedPlan.intent,
|
|
1786
|
+
opCount: 0,
|
|
1787
|
+
opTypes: [],
|
|
1788
|
+
plannerTier,
|
|
1789
|
+
...plannerContextTelemetryFields,
|
|
1790
|
+
...timingFields()
|
|
1791
|
+
});
|
|
1792
|
+
return {
|
|
1793
|
+
done: true,
|
|
1794
|
+
response: {
|
|
1795
|
+
code: 200,
|
|
1796
|
+
payload: withDebugPayload({
|
|
1797
|
+
status: "applied",
|
|
1798
|
+
summary: "No changes needed. That content is already up to date.",
|
|
1799
|
+
changes: [],
|
|
1800
|
+
mentionedSlugs: [effectiveSlug],
|
|
1801
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
1802
|
+
plannerSource: source,
|
|
1803
|
+
modelUsed,
|
|
1804
|
+
modelKey
|
|
1805
|
+
}, { outcome: "no_effective_change", plannerTier })
|
|
1806
|
+
}
|
|
1807
|
+
};
|
|
1808
|
+
}
|
|
1809
|
+
if (siteCapabilities?.allowStructuralEdits === false && resolvedPlan.ops.some((op) => isStructuralOperation(op))) {
|
|
1810
|
+
const reason = typeof siteCapabilities.reason === "string" && siteCapabilities.reason.trim().length > 0
|
|
1811
|
+
? ` ${siteCapabilities.reason.trim()}`
|
|
1812
|
+
: "";
|
|
1813
|
+
return {
|
|
1814
|
+
done: true,
|
|
1815
|
+
response: {
|
|
1816
|
+
code: 200,
|
|
1817
|
+
payload: withDebugPayload({
|
|
1818
|
+
status: "needs_clarification",
|
|
1819
|
+
summary: `Structural edits are disabled for this site context.${reason}`,
|
|
1820
|
+
changes: ["Expose GET /api/editor/blocks with a valid manifest to enable structural operations."],
|
|
1821
|
+
mentionedSlugs: [effectiveSlug],
|
|
1822
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
1823
|
+
plannerSource: source,
|
|
1824
|
+
modelUsed,
|
|
1825
|
+
modelKey
|
|
1826
|
+
}, { outcome: "blocked_structural_capability" })
|
|
1827
|
+
}
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
// Demo-mode guard: returns a friendly "needs_clarification" before we
|
|
1831
|
+
// hit the ops-engine hard gate. The engine gate still runs as a safety
|
|
1832
|
+
// net (`enforceDemoOps` in ops-engine.ts) — this one exists purely for
|
|
1833
|
+
// UX so the user sees an example prompt instead of a generic error.
|
|
1834
|
+
if (isDemoModeEnabled()) {
|
|
1835
|
+
const stagedForDemo = new Map();
|
|
1836
|
+
for (const [slug, page] of getSessionDraft(body.session)) {
|
|
1837
|
+
stagedForDemo.set(slug, page);
|
|
1838
|
+
}
|
|
1839
|
+
const demoSplit = splitDemoOps(resolvedPlan.ops, stagedForDemo);
|
|
1840
|
+
if (demoSplit.rejected.length > 0) {
|
|
1841
|
+
const allowedBlockLabel = getDemoAllowedBlockTypes().join(" / ");
|
|
1842
|
+
return {
|
|
1843
|
+
done: true,
|
|
1844
|
+
response: {
|
|
1845
|
+
code: 200,
|
|
1846
|
+
payload: withDebugPayload({
|
|
1847
|
+
status: "needs_clarification",
|
|
1848
|
+
summary: `This demo only supports editing the ${allowedBlockLabel} section. Try a hero-focused request — everything else is locked for now.`,
|
|
1849
|
+
changes: [
|
|
1850
|
+
"Try: \"change the hero headline to Welcome to my new site\"",
|
|
1851
|
+
"Try: \"make the hero subheading sound more playful\"",
|
|
1852
|
+
"Try: \"update the hero CTA to Get Started\""
|
|
1853
|
+
],
|
|
1854
|
+
mentionedSlugs: [effectiveSlug],
|
|
1855
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
1856
|
+
plannerSource: source,
|
|
1857
|
+
modelUsed,
|
|
1858
|
+
modelKey
|
|
1859
|
+
}, { outcome: "blocked_demo_mode" })
|
|
1860
|
+
}
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
let applyStartedAtMs = null;
|
|
1865
|
+
let skippedOps = [];
|
|
1866
|
+
// When ops were already applied incrementally during LLM streaming (preApplied),
|
|
1867
|
+
// skip the validation/apply step and go straight to post-apply bookkeeping.
|
|
1868
|
+
const skipApply = Boolean(optionsOverride?.preApplied);
|
|
1869
|
+
try {
|
|
1870
|
+
if (!skipApply) {
|
|
1871
|
+
// Validate ops against the canonical Zod schema at the NLP → ops-engine
|
|
1872
|
+
// boundary. Post-planner transforms (image rewriting, UI normalization)
|
|
1873
|
+
// could theoretically produce malformed ops; this catches them early.
|
|
1874
|
+
validateOperations(resolvedPlan.ops);
|
|
1875
|
+
emitStatusTone("applying");
|
|
1876
|
+
applyStartedAtMs = Date.now();
|
|
1877
|
+
const hasPageStructuralOps = resolvedPlan.ops.some((op) => op.op === "create_page" || op.op === "rename_page" || op.op === "remove_page" || op.op === "move_page" || op.op === "duplicate_page");
|
|
1878
|
+
if (incrementalApplyEnabled && options?.onOpApplied && !hasPageStructuralOps) {
|
|
1879
|
+
// Snapshot the entire session draft so we can restore it wholesale after preflight.
|
|
1880
|
+
const sessionDraft = getSessionDraft(body.session);
|
|
1881
|
+
const preFlightSnapshot = new Map();
|
|
1882
|
+
for (const [slug, page] of sessionDraft) {
|
|
1883
|
+
preFlightSnapshot.set(slug, structuredClone(page));
|
|
1884
|
+
}
|
|
1885
|
+
// Validate the whole plan from the current state before progressive apply.
|
|
1886
|
+
const preflight = await applyOpsAtomically(body.session, resolvedPlan.ops, { componentsManifest });
|
|
1887
|
+
skippedOps = preflight.skippedOps;
|
|
1888
|
+
// Restore pre-apply state wholesale so we can replay ops progressively.
|
|
1889
|
+
sessionDraft.clear();
|
|
1890
|
+
for (const [slug, page] of preFlightSnapshot) {
|
|
1891
|
+
setPage(body.session, structuredClone(page));
|
|
1892
|
+
}
|
|
1893
|
+
const total = resolvedPlan.ops.length;
|
|
1894
|
+
try {
|
|
1895
|
+
for (let index = 0; index < total; index += 1) {
|
|
1896
|
+
throwIfCanceled(options?.signal);
|
|
1897
|
+
const stepStartedAtMs = Date.now();
|
|
1898
|
+
const op = resolvedPlan.ops[index];
|
|
1899
|
+
// allowNoEffectiveChange: applying one op at a time, an op that matches
|
|
1900
|
+
// existing values is a benign skip — not a plan failure. Otherwise the
|
|
1901
|
+
// engine throws no_effective_change, which the catch below treats as a
|
|
1902
|
+
// hard error and rolls back the ENTIRE plan (discarding the ops that did
|
|
1903
|
+
// change). Common with whole-page translate when some blocks are already
|
|
1904
|
+
// in the target language.
|
|
1905
|
+
const stepResult = await applyOpsAtomically(body.session, [op], { componentsManifest, allowNoEffectiveChange: true });
|
|
1906
|
+
if (stepResult.appliedCount === 0 || stepResult.skippedOps.length > 0) {
|
|
1907
|
+
const skipReason = stepResult.skippedOps[0]?.reason ?? "unchanged_value";
|
|
1908
|
+
options?.onOpSkipped?.({
|
|
1909
|
+
index: index + 1,
|
|
1910
|
+
total,
|
|
1911
|
+
op,
|
|
1912
|
+
reason: skipReason
|
|
1913
|
+
});
|
|
1914
|
+
continue;
|
|
1915
|
+
}
|
|
1916
|
+
if (firstApplyMs === null) {
|
|
1917
|
+
firstApplyMs = Date.now() - pipelineStartedAtMs;
|
|
1918
|
+
stageTimeline.push({ stage: "first_op_applied", atMs: firstApplyMs });
|
|
1919
|
+
ctx.chatTelemetry.push({
|
|
1920
|
+
id: chatRequestId,
|
|
1921
|
+
at: new Date().toISOString(),
|
|
1922
|
+
phase: "milestone",
|
|
1923
|
+
timelineStage: "first_op_applied",
|
|
1924
|
+
session: body.session,
|
|
1925
|
+
requestedSlug,
|
|
1926
|
+
effectiveSlug,
|
|
1927
|
+
plannerSource: source,
|
|
1928
|
+
modelKey,
|
|
1929
|
+
modelUsed,
|
|
1930
|
+
promptHash,
|
|
1931
|
+
promptExcerpt,
|
|
1932
|
+
promptLength: plannerMessage.length,
|
|
1933
|
+
totalDurationMs: firstApplyMs
|
|
1934
|
+
});
|
|
1935
|
+
}
|
|
1936
|
+
const previewVersion = bumpVersion(body.session);
|
|
1937
|
+
options.onOpApplied({
|
|
1938
|
+
index: index + 1,
|
|
1939
|
+
total,
|
|
1940
|
+
op,
|
|
1941
|
+
previewVersion,
|
|
1942
|
+
focusBlockId: pickFocusBlockId([op])
|
|
1943
|
+
});
|
|
1944
|
+
if (streamedApplyMinStepMs > 0 && total > 1 && index < total - 1) {
|
|
1945
|
+
const stepElapsedMs = Date.now() - stepStartedAtMs;
|
|
1946
|
+
const remainingMs = streamedApplyMinStepMs - stepElapsedMs;
|
|
1947
|
+
if (remainingMs > 0)
|
|
1948
|
+
await sleepMs(remainingMs);
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
catch (progressiveError) {
|
|
1953
|
+
if (isCancelError(progressiveError))
|
|
1954
|
+
throw progressiveError;
|
|
1955
|
+
options?.onRollbackStarted?.({ appliedCount: total, reason: toErrorDetail(progressiveError) });
|
|
1956
|
+
// Roll back to pre-plan state so we don't leave a partially-applied plan.
|
|
1957
|
+
sessionDraft.clear();
|
|
1958
|
+
for (const [slug, page] of preFlightSnapshot) {
|
|
1959
|
+
setPage(body.session, structuredClone(page));
|
|
1960
|
+
}
|
|
1961
|
+
options?.onRollbackDone?.({ restoredVersion: versions.get(body.session) ?? 0 });
|
|
1962
|
+
throw progressiveError;
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
else {
|
|
1966
|
+
const applyResult = await applyOpsAtomically(body.session, resolvedPlan.ops, { componentsManifest });
|
|
1967
|
+
skippedOps = applyResult.skippedOps;
|
|
1968
|
+
if (applyResult.appliedCount > 0 && firstApplyMs === null) {
|
|
1969
|
+
firstApplyMs = Date.now() - pipelineStartedAtMs;
|
|
1970
|
+
stageTimeline.push({ stage: "first_op_applied", atMs: firstApplyMs });
|
|
1971
|
+
ctx.chatTelemetry.push({
|
|
1972
|
+
id: chatRequestId,
|
|
1973
|
+
at: new Date().toISOString(),
|
|
1974
|
+
phase: "milestone",
|
|
1975
|
+
timelineStage: "first_op_applied",
|
|
1976
|
+
session: body.session,
|
|
1977
|
+
requestedSlug,
|
|
1978
|
+
effectiveSlug,
|
|
1979
|
+
plannerSource: source,
|
|
1980
|
+
modelKey,
|
|
1981
|
+
modelUsed,
|
|
1982
|
+
promptHash,
|
|
1983
|
+
promptExcerpt,
|
|
1984
|
+
promptLength: plannerMessage.length,
|
|
1985
|
+
totalDurationMs: firstApplyMs
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
// Deferred image resolution for create_page: emit op_applied immediately
|
|
1989
|
+
// so the editor navigates to the new page with a shimmer placeholder,
|
|
1990
|
+
// then resolve the real image and patch it in.
|
|
1991
|
+
const deferredImages = resolvedPlan._deferredCreatePageImages;
|
|
1992
|
+
if (deferredImages && deferredImages.length > 0 && options?.onOpApplied) {
|
|
1993
|
+
const deferredUpdatedSlug = pickUpdatedSlug(body.session, effectiveSlug, resolvedPlan.ops);
|
|
1994
|
+
const immediatePreviewVersion = bumpVersion(body.session);
|
|
1995
|
+
const createOp = resolvedPlan.ops.find((op) => op.op === "create_page");
|
|
1996
|
+
options.onOpApplied({
|
|
1997
|
+
index: 1,
|
|
1998
|
+
total: 1,
|
|
1999
|
+
op: createOp ?? resolvedPlan.ops[0],
|
|
2000
|
+
previewVersion: immediatePreviewVersion,
|
|
2001
|
+
focusBlockId: pickFocusBlockId(resolvedPlan.ops),
|
|
2002
|
+
updatedSlug: deferredUpdatedSlug
|
|
2003
|
+
});
|
|
2004
|
+
// Resolve each deferred image and patch the Hero block
|
|
2005
|
+
for (const deferred of deferredImages) {
|
|
2006
|
+
throwIfCanceled(options?.signal);
|
|
2007
|
+
try {
|
|
2008
|
+
const deferredImageStart = Date.now();
|
|
2009
|
+
const imageResult = await raceCancel(resolveHeroImageForCreatePage({
|
|
2010
|
+
query: deferred.query,
|
|
2011
|
+
pageTitle: deferred.pageTitle,
|
|
2012
|
+
pageSlug: deferred.pageSlug,
|
|
2013
|
+
sectionContext: deferred.sectionContext,
|
|
2014
|
+
chatRequestId,
|
|
2015
|
+
gdriveFolderId,
|
|
2016
|
+
log: ctx.log,
|
|
2017
|
+
onStatusUpdate: options.onStatusUpdate,
|
|
2018
|
+
onImageProgress: options?.onImageProgress
|
|
2019
|
+
}), options?.signal);
|
|
2020
|
+
const deferredImageDurationMs = Date.now() - deferredImageStart;
|
|
2021
|
+
if (imageResult) {
|
|
2022
|
+
const patchOp = {
|
|
2023
|
+
op: "update_props",
|
|
2024
|
+
pageSlug: deferred.pageSlug,
|
|
2025
|
+
blockId: deferred.blockId,
|
|
2026
|
+
patch: { props: { imageUrl: imageResult.url, imageAlt: imageResult.alt } }
|
|
2027
|
+
};
|
|
2028
|
+
await applyOpsAtomically(body.session, [patchOp], { componentsManifest });
|
|
2029
|
+
const patchVersion = bumpVersion(body.session);
|
|
2030
|
+
options.onOpApplied({
|
|
2031
|
+
index: 1,
|
|
2032
|
+
total: 1,
|
|
2033
|
+
op: patchOp,
|
|
2034
|
+
previewVersion: patchVersion,
|
|
2035
|
+
focusBlockId: deferred.blockId
|
|
2036
|
+
});
|
|
2037
|
+
const deferredBlockType = getPage(body.session, deferred.pageSlug)?.blocks?.find((b) => b.id === deferred.blockId)?.type ?? "block";
|
|
2038
|
+
resolvedPlan.change_log = [...resolvedPlan.change_log, `Generated AI image for ${deferredBlockType} on ${fmtSlug(deferred.pageSlug)}`];
|
|
2039
|
+
ctx.log.info({ event: "deferred_hero_image_resolved", chatRequestId, pageSlug: deferred.pageSlug, blockId: deferred.blockId, source: imageResult.source, durationMs: deferredImageDurationMs }, `Deferred hero image resolved and applied in ${deferredImageDurationMs}ms`);
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
catch (err) {
|
|
2043
|
+
cleanupImagePlaceholders(body.session);
|
|
2044
|
+
if (isCancelError(err))
|
|
2045
|
+
throw err;
|
|
2046
|
+
ctx.log.warn({ event: "deferred_hero_image_failed", chatRequestId, pageSlug: deferred.pageSlug, blockId: deferred.blockId, error: toErrorDetail(err) }, "Deferred hero image resolution failed — page remains with placeholder");
|
|
2047
|
+
options?.onStatusUpdate?.("Image generation failed — you can try again or set an image manually.");
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
// Deferred image resolution for non-create_page ops: resolve images after
|
|
2053
|
+
// text/structural ops are already visible in the preview, then patch them in.
|
|
2054
|
+
const deferredImageArgs = resolvedPlan._deferredImageArgs;
|
|
2055
|
+
if (resolvedPlan._deferredImageResolution && deferredImageArgs && options?.onOpApplied && deferredNativeImageCalls.length === 0) {
|
|
2056
|
+
try {
|
|
2057
|
+
const imageResolutionStartMs = Date.now();
|
|
2058
|
+
emitStatusTone("resolving_assets");
|
|
2059
|
+
const preferredImageOps = deferredImageArgs.preferredImageOps ?? [];
|
|
2060
|
+
// When auto-apply runs for an image-only plan, the original patches
|
|
2061
|
+
// had their imageUrl/imageAlt stripped. Apply shimmer placeholders
|
|
2062
|
+
// so the preview shows "Searching…" / "Generating…" while we resolve.
|
|
2063
|
+
// This mirrors the approval-flow UX in chat-pipeline.ts (pending plan block).
|
|
2064
|
+
if (preferredImageOps.length > 0) {
|
|
2065
|
+
const shimmerOps = preferredImageOps.map((imgOp) => {
|
|
2066
|
+
const placeholder = imgOp.provider === "unsplash" ? SEARCHING_IMAGE_PLACEHOLDER : GENERATING_IMAGE_PLACEHOLDER;
|
|
2067
|
+
const patchProps = {};
|
|
2068
|
+
if (typeof imgOp.path === "string" && imgOp.path.length > 0) {
|
|
2069
|
+
setValueAtPath(patchProps, imgOp.path, placeholder);
|
|
2070
|
+
}
|
|
2071
|
+
else {
|
|
2072
|
+
patchProps.imageUrl = placeholder;
|
|
2073
|
+
}
|
|
2074
|
+
return { op: "update_props", pageSlug: imgOp.pageSlug, blockId: imgOp.blockId, patch: { props: patchProps } };
|
|
2075
|
+
});
|
|
2076
|
+
try {
|
|
2077
|
+
await applyOpsAtomically(body.session, shimmerOps, { componentsManifest });
|
|
2078
|
+
const shimmerVersion = bumpVersion(body.session);
|
|
2079
|
+
const firstShimmer = shimmerOps[0];
|
|
2080
|
+
options.onOpApplied({
|
|
2081
|
+
index: 1,
|
|
2082
|
+
total: 1,
|
|
2083
|
+
op: firstShimmer,
|
|
2084
|
+
previewVersion: shimmerVersion,
|
|
2085
|
+
focusBlockId: firstShimmer.op === "update_props" ? firstShimmer.blockId : undefined
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
catch (shimmerErr) {
|
|
2089
|
+
ctx.log.debug({ event: "deferred_image_shimmer_failed", chatRequestId, error: toErrorDetail(shimmerErr) }, "Shimmer placeholder injection failed; continuing with resolution");
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
// Restore "pending" imageUrl/imageAlt into ops whose patch was stripped,
|
|
2093
|
+
// so withUnsplashHeroImage's `hasImageUrlInPatch` check passes and image
|
|
2094
|
+
// resolution actually runs for these ops.
|
|
2095
|
+
const planForResolve = structuredClone(resolvedPlan);
|
|
2096
|
+
for (const imgOp of preferredImageOps) {
|
|
2097
|
+
const op = planForResolve.ops.find((o) => o.op === "update_props" && o.blockId === imgOp.blockId && o.pageSlug === imgOp.pageSlug);
|
|
2098
|
+
if (!op || op.op !== "update_props")
|
|
2099
|
+
continue;
|
|
2100
|
+
const rawPatch = op.patch;
|
|
2101
|
+
const patchTarget = rawPatch && typeof rawPatch.props === "object" && rawPatch.props !== null && !Array.isArray(rawPatch.props)
|
|
2102
|
+
? rawPatch.props
|
|
2103
|
+
: rawPatch;
|
|
2104
|
+
if (typeof imgOp.path === "string" && imgOp.path.length > 0) {
|
|
2105
|
+
if (getValueAtPath(patchTarget, imgOp.path) === undefined) {
|
|
2106
|
+
setValueAtPath(patchTarget, imgOp.path, "pending");
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
else if (!Object.prototype.hasOwnProperty.call(patchTarget, "imageUrl")) {
|
|
2110
|
+
patchTarget.imageUrl = "pending";
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
const resolvedImagePlan = await raceCancel(withUnsplashHeroImage({
|
|
2114
|
+
plan: preferredImageOps.length > 0 ? planForResolve : resolvedPlan,
|
|
2115
|
+
message: deferredImageArgs.message,
|
|
2116
|
+
slug: deferredImageArgs.slug,
|
|
2117
|
+
currentPage: deferredImageArgs.currentPage,
|
|
2118
|
+
preferredImageOps: preferredImageOps.length > 0 ? preferredImageOps : undefined,
|
|
2119
|
+
activeBlockId: deferredImageArgs.activeBlockId,
|
|
2120
|
+
activeEditablePath: deferredImageArgs.activeEditablePath,
|
|
2121
|
+
chatRequestId: deferredImageArgs.chatRequestId,
|
|
2122
|
+
gdriveFolderId: deferredImageArgs.gdriveFolderId,
|
|
2123
|
+
log: ctx.log,
|
|
2124
|
+
onStatusUpdate: options?.onStatusUpdate,
|
|
2125
|
+
onImageProgress: options?.onImageProgress
|
|
2126
|
+
}), options?.signal);
|
|
2127
|
+
imageResolutionDurationMs += Date.now() - imageResolutionStartMs;
|
|
2128
|
+
// Find image-bearing ops that changed between the original and resolved plan
|
|
2129
|
+
for (const resolvedOp of resolvedImagePlan.ops) {
|
|
2130
|
+
if (resolvedOp.op !== "update_props")
|
|
2131
|
+
continue;
|
|
2132
|
+
const origOp = resolvedPlan.ops.find((o) => o.op === "update_props" && o.blockId === resolvedOp.blockId && o.pageSlug === resolvedOp.pageSlug);
|
|
2133
|
+
const resolvedPatch = resolvedOp.patch;
|
|
2134
|
+
const origPatch = origOp && origOp.op === "update_props" ? origOp.patch : {};
|
|
2135
|
+
const resolvedProps = (typeof resolvedPatch.props === "object" && resolvedPatch.props !== null && !Array.isArray(resolvedPatch.props))
|
|
2136
|
+
? resolvedPatch.props : resolvedPatch;
|
|
2137
|
+
const origProps = (typeof origPatch.props === "object" && origPatch.props !== null && !Array.isArray(origPatch.props))
|
|
2138
|
+
? origPatch.props : origPatch;
|
|
2139
|
+
const imageUrl = resolvedProps.imageUrl;
|
|
2140
|
+
if (typeof imageUrl === "string" && imageUrl.length > 0 && imageUrl !== origProps.imageUrl) {
|
|
2141
|
+
const patchOp = {
|
|
2142
|
+
op: "update_props",
|
|
2143
|
+
pageSlug: resolvedOp.pageSlug,
|
|
2144
|
+
blockId: resolvedOp.blockId,
|
|
2145
|
+
patch: {
|
|
2146
|
+
props: {
|
|
2147
|
+
imageUrl,
|
|
2148
|
+
...(typeof resolvedProps.imageAlt === "string" ? { imageAlt: resolvedProps.imageAlt } : {})
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
};
|
|
2152
|
+
await applyOpsAtomically(body.session, [patchOp], { componentsManifest });
|
|
2153
|
+
const patchVersion = bumpVersion(body.session);
|
|
2154
|
+
options.onOpApplied({
|
|
2155
|
+
index: 1,
|
|
2156
|
+
total: 1,
|
|
2157
|
+
op: patchOp,
|
|
2158
|
+
previewVersion: patchVersion,
|
|
2159
|
+
focusBlockId: resolvedOp.blockId
|
|
2160
|
+
});
|
|
2161
|
+
const resolvedBlockType = getPage(body.session, resolvedOp.pageSlug)?.blocks?.find((b) => b.id === resolvedOp.blockId)?.type ?? "block";
|
|
2162
|
+
resolvedPlan.change_log = [...resolvedPlan.change_log, `Resolved image for ${resolvedBlockType} on ${fmtSlug(resolvedOp.pageSlug)}`];
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
catch (deferredErr) {
|
|
2167
|
+
cleanupImagePlaceholders(body.session);
|
|
2168
|
+
if (isCancelError(deferredErr))
|
|
2169
|
+
throw deferredErr;
|
|
2170
|
+
ctx.log.warn({ event: "deferred_image_resolution_failed", chatRequestId, error: toErrorDetail(deferredErr) }, "Deferred image resolution failed — preview remains without images");
|
|
2171
|
+
options?.onStatusUpdate?.("Image resolution failed — you can try again or set images manually.");
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
// Deferred native image tool execution: the LLM called image.generate or
|
|
2175
|
+
// unsplash.search during planning, but we returned placeholders to unblock
|
|
2176
|
+
// text streaming. Now execute the real tools and patch results into the preview.
|
|
2177
|
+
if (deferredNativeImageCalls.length > 0 && options?.onOpApplied) {
|
|
2178
|
+
// Helper: clear shimmer placeholders left behind by a failed deferred
|
|
2179
|
+
// tool call so the UI doesn't get stuck on "Generating image…".
|
|
2180
|
+
const emitPlaceholderClearPatches = async (reason) => {
|
|
2181
|
+
for (const op of resolvedPlan.ops) {
|
|
2182
|
+
if (op.op !== "update_props")
|
|
2183
|
+
continue;
|
|
2184
|
+
const patch = op.patch;
|
|
2185
|
+
const props = (typeof patch.props === "object" && patch.props !== null && !Array.isArray(patch.props))
|
|
2186
|
+
? patch.props
|
|
2187
|
+
: patch;
|
|
2188
|
+
if (!isGeneratingPlaceholder(String(props.imageUrl ?? "")))
|
|
2189
|
+
continue;
|
|
2190
|
+
const clearOp = {
|
|
2191
|
+
op: "update_props",
|
|
2192
|
+
pageSlug: op.pageSlug,
|
|
2193
|
+
blockId: op.blockId,
|
|
2194
|
+
patch: { props: { imageUrl: "" } }
|
|
2195
|
+
};
|
|
2196
|
+
try {
|
|
2197
|
+
await applyOpsAtomically(body.session, [clearOp], { componentsManifest });
|
|
2198
|
+
const clearVersion = bumpVersion(body.session);
|
|
2199
|
+
options.onOpApplied({
|
|
2200
|
+
index: 1,
|
|
2201
|
+
total: 1,
|
|
2202
|
+
op: clearOp,
|
|
2203
|
+
previewVersion: clearVersion,
|
|
2204
|
+
focusBlockId: op.blockId
|
|
2205
|
+
});
|
|
2206
|
+
}
|
|
2207
|
+
catch (clearErr) {
|
|
2208
|
+
ctx.log.warn({ event: "deferred_native_image_clear_failed", reason, chatRequestId, error: toErrorDetail(clearErr) }, "Failed to clear shimmer placeholder after deferred image tool failure");
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
// Also clear any stray placeholders still in session state (e.g. nested in arrays)
|
|
2212
|
+
cleanupImagePlaceholders(body.session);
|
|
2213
|
+
};
|
|
2214
|
+
try {
|
|
2215
|
+
const imageResolutionStartMs = Date.now();
|
|
2216
|
+
emitStatusTone("resolving_assets");
|
|
2217
|
+
for (const deferred of deferredNativeImageCalls) {
|
|
2218
|
+
try {
|
|
2219
|
+
const result = await raceCancel(executeToolCall({
|
|
2220
|
+
runtime: ctx.toolRuntime,
|
|
2221
|
+
toolName: deferred.toolName,
|
|
2222
|
+
input: deferred.input,
|
|
2223
|
+
context: {
|
|
2224
|
+
siteId: body.siteId ?? "default",
|
|
2225
|
+
sessionId: body.session ?? "dev",
|
|
2226
|
+
traceId: chatRequestId,
|
|
2227
|
+
plannerProvider: "anthropic",
|
|
2228
|
+
onStatusUpdate: options?.onStatusUpdate,
|
|
2229
|
+
onImageProgress: options?.onImageProgress
|
|
2230
|
+
},
|
|
2231
|
+
policy: ctx.toolRuntime?.defaultPolicy
|
|
2232
|
+
}), options?.signal);
|
|
2233
|
+
if (!result.ok) {
|
|
2234
|
+
ctx.log.warn({ event: "deferred_native_image_tool_error", toolName: deferred.toolName, chatRequestId, errorCode: result.error?.code, errorMessage: result.error?.message }, `Deferred ${deferred.toolName} returned error — clearing placeholder`);
|
|
2235
|
+
options?.onStatusUpdate?.("Image generation failed — you can try again or set images manually.");
|
|
2236
|
+
await emitPlaceholderClearPatches("tool_error");
|
|
2237
|
+
continue;
|
|
2238
|
+
}
|
|
2239
|
+
// Extract the real imageUrl from the tool result
|
|
2240
|
+
const data = result.data;
|
|
2241
|
+
const realImageUrl = deferred.toolName === "image.generate"
|
|
2242
|
+
? data.imageUrl
|
|
2243
|
+
: (data.items?.[0]?.imageUrl);
|
|
2244
|
+
if (!realImageUrl) {
|
|
2245
|
+
ctx.log.warn({ event: "deferred_native_image_empty_result", toolName: deferred.toolName, chatRequestId }, `Deferred ${deferred.toolName} returned no imageUrl — clearing placeholder`);
|
|
2246
|
+
await emitPlaceholderClearPatches("empty_result");
|
|
2247
|
+
continue;
|
|
2248
|
+
}
|
|
2249
|
+
const realAlt = deferred.toolName === "image.generate"
|
|
2250
|
+
? data.alt
|
|
2251
|
+
: (data.items?.[0]?.alt);
|
|
2252
|
+
// Find ops that used the placeholder URL and patch the real image in
|
|
2253
|
+
for (const op of resolvedPlan.ops) {
|
|
2254
|
+
if (op.op !== "update_props")
|
|
2255
|
+
continue;
|
|
2256
|
+
const patch = op.patch;
|
|
2257
|
+
const props = (typeof patch.props === "object" && patch.props !== null && !Array.isArray(patch.props))
|
|
2258
|
+
? patch.props
|
|
2259
|
+
: patch;
|
|
2260
|
+
if (isGeneratingPlaceholder(String(props.imageUrl ?? ""))) {
|
|
2261
|
+
const patchOp = {
|
|
2262
|
+
op: "update_props",
|
|
2263
|
+
pageSlug: op.pageSlug,
|
|
2264
|
+
blockId: op.blockId,
|
|
2265
|
+
patch: {
|
|
2266
|
+
props: {
|
|
2267
|
+
imageUrl: realImageUrl,
|
|
2268
|
+
...(realAlt ? { imageAlt: realAlt } : {})
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
};
|
|
2272
|
+
await applyOpsAtomically(body.session, [patchOp], { componentsManifest });
|
|
2273
|
+
const patchVersion = bumpVersion(body.session);
|
|
2274
|
+
options.onOpApplied({
|
|
2275
|
+
index: 1,
|
|
2276
|
+
total: 1,
|
|
2277
|
+
op: patchOp,
|
|
2278
|
+
previewVersion: patchVersion,
|
|
2279
|
+
focusBlockId: op.blockId
|
|
2280
|
+
});
|
|
2281
|
+
const nativeBlockType = getPage(body.session, op.pageSlug)?.blocks?.find((b) => b.id === op.blockId)?.type ?? "block";
|
|
2282
|
+
resolvedPlan.change_log = [...resolvedPlan.change_log, `Generated image for ${nativeBlockType} on ${fmtSlug(op.pageSlug)}`];
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
catch (singleErr) {
|
|
2287
|
+
if (isCancelError(singleErr))
|
|
2288
|
+
throw singleErr;
|
|
2289
|
+
ctx.log.warn({ event: "deferred_native_image_failed", toolName: deferred.toolName, chatRequestId, error: toErrorDetail(singleErr) }, `Deferred ${deferred.toolName} failed — clearing placeholder`);
|
|
2290
|
+
options?.onStatusUpdate?.("Image generation failed — you can try again or set images manually.");
|
|
2291
|
+
await emitPlaceholderClearPatches("exception");
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
imageResolutionDurationMs += Date.now() - imageResolutionStartMs;
|
|
2295
|
+
}
|
|
2296
|
+
catch (deferredErr) {
|
|
2297
|
+
if (isCancelError(deferredErr))
|
|
2298
|
+
throw deferredErr;
|
|
2299
|
+
ctx.log.warn({ event: "deferred_native_image_resolution_failed", chatRequestId, error: toErrorDetail(deferredErr) }, "Deferred native image resolution failed");
|
|
2300
|
+
options?.onStatusUpdate?.("Image generation failed — you can try again or set images manually.");
|
|
2301
|
+
await emitPlaceholderClearPatches("outer_exception");
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
} // end if (!skipApply)
|
|
2305
|
+
const undoSnapshot = skipApply && optionsOverride?.undoSnapshot
|
|
2306
|
+
? optionsOverride.undoSnapshot
|
|
2307
|
+
: previous;
|
|
2308
|
+
// For create_page: push null under the new page's slug (undo = delete it).
|
|
2309
|
+
// For remove_page: previous was captured before apply, push it under effectiveSlug.
|
|
2310
|
+
// For normal edits: push previous under effectiveSlug.
|
|
2311
|
+
// undoTargetSlug tracks where the undo snapshot lives so the editor sends the right slug.
|
|
2312
|
+
let undoTargetSlug = effectiveSlug;
|
|
2313
|
+
if (hasCreatePage) {
|
|
2314
|
+
for (const op of resolvedPlan.ops) {
|
|
2315
|
+
if (op.op === "create_page") {
|
|
2316
|
+
pushUndo(body.session, op.page.slug, null);
|
|
2317
|
+
undoTargetSlug = op.page.slug;
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
else {
|
|
2322
|
+
pushUndo(body.session, effectiveSlug, undoSnapshot);
|
|
2323
|
+
}
|
|
2324
|
+
pendingClarificationBySession.delete(body.session);
|
|
2325
|
+
pendingApprovalPlanBySession.delete(body.session);
|
|
2326
|
+
const planUpdatedSlug = pickUpdatedSlug(body.session, effectiveSlug, resolvedPlan.ops);
|
|
2327
|
+
const updatedSlug = planUpdatedSlug ?? (effectiveSlug !== requestedSlug ? effectiveSlug : undefined);
|
|
2328
|
+
pushRecentEdit(body.session, { slug: updatedSlug ?? effectiveSlug, summary: futureToPastTense(resolvedPlan.summary_for_user), ops: resolvedPlan.ops });
|
|
2329
|
+
if (body.message)
|
|
2330
|
+
pushChatHistory(body.session, body.message, futureToPastTense(resolvedPlan.summary_for_user));
|
|
2331
|
+
const previewVersion = options?.onOpApplied ? (versions.get(body.session) ?? 0) : bumpVersion(body.session);
|
|
2332
|
+
const versionEntrySlug = updatedSlug ?? effectiveSlug;
|
|
2333
|
+
const versionSnapshot = getPage(body.session, versionEntrySlug);
|
|
2334
|
+
pushVersionEntry(body.session, {
|
|
2335
|
+
version: previewVersion,
|
|
2336
|
+
slug: versionEntrySlug,
|
|
2337
|
+
summary: futureToPastTense(resolvedPlan.summary_for_user),
|
|
2338
|
+
opTypes: resolvedPlan.ops.map((op) => op.op),
|
|
2339
|
+
opCount: resolvedPlan.ops.length,
|
|
2340
|
+
source: "chat",
|
|
2341
|
+
snapshot: versionSnapshot ? structuredClone(versionSnapshot) : null
|
|
2342
|
+
});
|
|
2343
|
+
schedulePersistState(ctx.log);
|
|
2344
|
+
const focusBlockId = pickFocusBlockId(resolvedPlan.ops);
|
|
2345
|
+
const aiInsightChanges = buildAiInsightChanges({ plan: resolvedPlan, message: plannerMessage });
|
|
2346
|
+
const metaChangeLogEntries = buildMetaChangeLogEntries(resolvedPlan.ops);
|
|
2347
|
+
const opChangeLogEntries = buildOpChangeLogEntries(resolvedPlan.ops, {
|
|
2348
|
+
getBlockType: (slug, blockId) => {
|
|
2349
|
+
const page = getPage(body.session, slug);
|
|
2350
|
+
return page?.blocks?.find((b) => b.id === blockId)?.type;
|
|
2351
|
+
}
|
|
2352
|
+
});
|
|
2353
|
+
const skippedSummary = skippedOps.length > 0
|
|
2354
|
+
? [`Skipped ${skippedOps.length} unchanged operation${skippedOps.length === 1 ? "" : "s"}.`]
|
|
2355
|
+
: [];
|
|
2356
|
+
applyDurationMs = applyStartedAtMs !== null ? Date.now() - applyStartedAtMs : undefined;
|
|
2357
|
+
ctx.chatTelemetry.push({
|
|
2358
|
+
id: chatRequestId,
|
|
2359
|
+
at: new Date().toISOString(),
|
|
2360
|
+
phase: "result",
|
|
2361
|
+
session: body.session,
|
|
2362
|
+
requestedSlug,
|
|
2363
|
+
effectiveSlug,
|
|
2364
|
+
plannerSource: source,
|
|
2365
|
+
modelKey,
|
|
2366
|
+
modelUsed,
|
|
2367
|
+
promptHash,
|
|
2368
|
+
promptExcerpt,
|
|
2369
|
+
promptLength: plannerMessage.length,
|
|
2370
|
+
outcome: "applied",
|
|
2371
|
+
intent: resolvedPlan.intent,
|
|
2372
|
+
opCount: resolvedPlan.ops.length,
|
|
2373
|
+
opTypes: resolvedPlan.ops.map((op) => op.op),
|
|
2374
|
+
skippedOpCount: skippedOps.length > 0 ? skippedOps.length : undefined,
|
|
2375
|
+
plannerTier,
|
|
2376
|
+
...plannerContextTelemetryFields,
|
|
2377
|
+
...usageFields,
|
|
2378
|
+
...timingFields()
|
|
2379
|
+
});
|
|
2380
|
+
return {
|
|
2381
|
+
done: true,
|
|
2382
|
+
response: {
|
|
2383
|
+
code: 200,
|
|
2384
|
+
payload: withDebugPayload({
|
|
2385
|
+
status: "applied",
|
|
2386
|
+
// plan-first "soft draft": applied + revertible. The editor renders an
|
|
2387
|
+
// inline Keep / Revert affordance (Revert = one-step undo on undoSlug).
|
|
2388
|
+
...(planFirstApplyAsDraft ? { planApplied: true } : {}),
|
|
2389
|
+
summary: futureToPastTense(resolvedPlan.summary_for_user),
|
|
2390
|
+
changes: [...opChangeLogEntries, ...metaChangeLogEntries, ...aiInsightChanges, ...skippedSummary],
|
|
2391
|
+
mentionedSlugs: collectMentionedSlugsFromPlan(resolvedPlan, updatedSlug ?? effectiveSlug),
|
|
2392
|
+
suggestions: resolvedPlan.suggested_next_actions ?? postEditSuggestions({ plan: resolvedPlan, current, body }),
|
|
2393
|
+
previewVersion,
|
|
2394
|
+
focusBlockId,
|
|
2395
|
+
updatedSlug,
|
|
2396
|
+
undoSlug: undoTargetSlug,
|
|
2397
|
+
plannerSource: source,
|
|
2398
|
+
modelUsed,
|
|
2399
|
+
modelKey
|
|
2400
|
+
}, {
|
|
2401
|
+
outcome: "applied",
|
|
2402
|
+
intent: resolvedPlan.intent,
|
|
2403
|
+
opCount: resolvedPlan.ops.length,
|
|
2404
|
+
opTypes: resolvedPlan.ops.map((op) => op.op),
|
|
2405
|
+
skippedOpCount: skippedOps.length,
|
|
2406
|
+
skippedOps,
|
|
2407
|
+
plannerTier,
|
|
2408
|
+
...usageFields
|
|
2409
|
+
})
|
|
2410
|
+
}
|
|
2411
|
+
};
|
|
2412
|
+
}
|
|
2413
|
+
catch (error) {
|
|
2414
|
+
if (isCancelError(error))
|
|
2415
|
+
throw error;
|
|
2416
|
+
if (applyDurationMs === undefined && applyStartedAtMs !== null) {
|
|
2417
|
+
applyDurationMs = Date.now() - applyStartedAtMs;
|
|
2418
|
+
}
|
|
2419
|
+
const reason = toErrorDetail(error);
|
|
2420
|
+
if (isNoEffectiveChangeError(reason)) {
|
|
2421
|
+
pendingApprovalPlanBySession.delete(body.session);
|
|
2422
|
+
ctx.chatTelemetry.push({
|
|
2423
|
+
id: chatRequestId,
|
|
2424
|
+
at: new Date().toISOString(),
|
|
2425
|
+
phase: "result",
|
|
2426
|
+
session: body.session,
|
|
2427
|
+
requestedSlug,
|
|
2428
|
+
effectiveSlug,
|
|
2429
|
+
plannerSource: source,
|
|
2430
|
+
modelKey,
|
|
2431
|
+
modelUsed,
|
|
2432
|
+
promptHash,
|
|
2433
|
+
promptExcerpt,
|
|
2434
|
+
promptLength: plannerMessage.length,
|
|
2435
|
+
outcome: "no_effective_change",
|
|
2436
|
+
reason: reason.slice(0, 300),
|
|
2437
|
+
plannerTier,
|
|
2438
|
+
...plannerContextTelemetryFields,
|
|
2439
|
+
...timingFields()
|
|
2440
|
+
});
|
|
2441
|
+
return {
|
|
2442
|
+
done: true,
|
|
2443
|
+
response: {
|
|
2444
|
+
code: 200,
|
|
2445
|
+
payload: withDebugPayload({
|
|
2446
|
+
status: "applied",
|
|
2447
|
+
summary: "No changes needed. That content is already up to date.",
|
|
2448
|
+
changes: [],
|
|
2449
|
+
mentionedSlugs: [effectiveSlug],
|
|
2450
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
2451
|
+
plannerSource: source,
|
|
2452
|
+
modelUsed,
|
|
2453
|
+
modelKey
|
|
2454
|
+
}, { outcome: "no_effective_change", plannerTier })
|
|
2455
|
+
}
|
|
2456
|
+
};
|
|
2457
|
+
}
|
|
2458
|
+
ctx.chatTelemetry.push({
|
|
2459
|
+
id: chatRequestId,
|
|
2460
|
+
at: new Date().toISOString(),
|
|
2461
|
+
phase: "plan_apply_failed",
|
|
2462
|
+
session: body.session,
|
|
2463
|
+
requestedSlug,
|
|
2464
|
+
effectiveSlug,
|
|
2465
|
+
plannerSource: source,
|
|
2466
|
+
modelKey,
|
|
2467
|
+
modelUsed,
|
|
2468
|
+
promptHash,
|
|
2469
|
+
promptExcerpt,
|
|
2470
|
+
promptLength: plannerMessage.length,
|
|
2471
|
+
outcome: "apply_failed",
|
|
2472
|
+
reason: reason.slice(0, 300),
|
|
2473
|
+
reasonCategory: classifyGuardrailError(reason),
|
|
2474
|
+
plannerTier,
|
|
2475
|
+
...timingFields()
|
|
2476
|
+
});
|
|
2477
|
+
return { done: false, reason };
|
|
2478
|
+
}
|
|
2479
|
+
};
|
|
2480
|
+
if (executionMode === "apply_pending_plan") {
|
|
2481
|
+
const pending = pendingApprovalPlanBySession.get(body.session);
|
|
2482
|
+
if (!pending) {
|
|
2483
|
+
const fallbackMessage = typeof body.message === "string" ? body.message.trim() : "";
|
|
2484
|
+
if (fallbackMessage.length > 0) {
|
|
2485
|
+
return runChatPipeline(ctx, { ...body, executionMode: "auto" }, options);
|
|
2486
|
+
}
|
|
2487
|
+
return {
|
|
2488
|
+
code: 409,
|
|
2489
|
+
payload: withDebugPayload({
|
|
2490
|
+
status: "needs_clarification",
|
|
2491
|
+
summary: "No pending plan is waiting for approval. Ask for a change first.",
|
|
2492
|
+
changes: [],
|
|
2493
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
2494
|
+
plannerSource,
|
|
2495
|
+
modelUsed,
|
|
2496
|
+
modelKey
|
|
2497
|
+
}, { outcome: "pending_plan_missing" })
|
|
2498
|
+
};
|
|
2499
|
+
}
|
|
2500
|
+
if (body.pendingPlanId && body.pendingPlanId !== pending.id) {
|
|
2501
|
+
return {
|
|
2502
|
+
code: 409,
|
|
2503
|
+
payload: withDebugPayload({
|
|
2504
|
+
status: "validation_error",
|
|
2505
|
+
summary: "Pending plan does not match the latest reviewed plan.",
|
|
2506
|
+
changes: [],
|
|
2507
|
+
validationErrors: ["Pending plan id mismatch. Refresh and approve again."],
|
|
2508
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
2509
|
+
plannerSource,
|
|
2510
|
+
modelUsed,
|
|
2511
|
+
modelKey
|
|
2512
|
+
}, { outcome: "pending_plan_mismatch" })
|
|
2513
|
+
};
|
|
2514
|
+
}
|
|
2515
|
+
// Run deferred image generation if the pending plan has image ops
|
|
2516
|
+
let approvalPlan = pending.plan;
|
|
2517
|
+
try {
|
|
2518
|
+
if (pending.pendingImageOps && pending.pendingImageOps.length > 0) {
|
|
2519
|
+
const imageResolutionStartMs = Date.now();
|
|
2520
|
+
emitStatusTone("resolving_assets");
|
|
2521
|
+
const imageMessage = pending.originalMessage ?? (typeof body.message === "string" ? body.message : "");
|
|
2522
|
+
const planClone = structuredClone(approvalPlan);
|
|
2523
|
+
// Show shimmer placeholders in the live preview while images are being generated.
|
|
2524
|
+
const placeholderOps = pending.pendingImageOps.map((imgOp) => {
|
|
2525
|
+
const placeholder = imgOp.provider === "unsplash" ? SEARCHING_IMAGE_PLACEHOLDER : GENERATING_IMAGE_PLACEHOLDER;
|
|
2526
|
+
const patchProps = {};
|
|
2527
|
+
if (typeof imgOp.path === "string" && imgOp.path.length > 0) {
|
|
2528
|
+
setValueAtPath(patchProps, imgOp.path, placeholder);
|
|
2529
|
+
}
|
|
2530
|
+
else {
|
|
2531
|
+
patchProps.imageUrl = placeholder;
|
|
2532
|
+
}
|
|
2533
|
+
return { op: "update_props", pageSlug: imgOp.pageSlug, blockId: imgOp.blockId, patch: { props: patchProps } };
|
|
2534
|
+
});
|
|
2535
|
+
try {
|
|
2536
|
+
await applyOpsAtomically(body.session, placeholderOps, { componentsManifest });
|
|
2537
|
+
const ver = versions.get(body.session) ?? 0;
|
|
2538
|
+
options?.onOpApplied?.({ index: 0, total: placeholderOps.length, op: placeholderOps[0], previewVersion: ver });
|
|
2539
|
+
}
|
|
2540
|
+
catch (e) {
|
|
2541
|
+
ctx.log.debug({ error: e instanceof Error ? e.message : String(e) }, "Shimmer placeholder injection failed, continuing");
|
|
2542
|
+
}
|
|
2543
|
+
// Restore placeholder imageUrl in ops that had it stripped during detection.
|
|
2544
|
+
// withUnsplashHeroImage checks requestedImageUrl.length > 0 to decide whether
|
|
2545
|
+
// to resolve an image — without this, the stripped patch causes it to skip.
|
|
2546
|
+
for (const imgOp of pending.pendingImageOps) {
|
|
2547
|
+
const op = planClone.ops.find((o) => o.op === "update_props" && o.blockId === imgOp.blockId && o.pageSlug === imgOp.pageSlug);
|
|
2548
|
+
if (!op || op.op !== "update_props")
|
|
2549
|
+
continue;
|
|
2550
|
+
const rawPatch = op.patch;
|
|
2551
|
+
const patchTarget = rawPatch && typeof rawPatch.props === "object" && rawPatch.props !== null && !Array.isArray(rawPatch.props)
|
|
2552
|
+
? rawPatch.props
|
|
2553
|
+
: rawPatch;
|
|
2554
|
+
if (typeof imgOp.path === "string" && imgOp.path.length > 0) {
|
|
2555
|
+
if (getValueAtPath(patchTarget, imgOp.path) === undefined) {
|
|
2556
|
+
setValueAtPath(patchTarget, imgOp.path, "pending");
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
else if (!Object.prototype.hasOwnProperty.call(patchTarget, "imageUrl")) {
|
|
2560
|
+
patchTarget.imageUrl = "pending";
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
approvalPlan = await withUnsplashHeroImage({
|
|
2564
|
+
plan: planClone,
|
|
2565
|
+
message: imageMessage,
|
|
2566
|
+
slug: pending.effectiveSlug,
|
|
2567
|
+
currentPage: current,
|
|
2568
|
+
preferredImageOps: pending.pendingImageOps,
|
|
2569
|
+
activeBlockId: body.activeBlockId,
|
|
2570
|
+
activeEditablePath: body.activeEditablePath,
|
|
2571
|
+
chatRequestId,
|
|
2572
|
+
gdriveFolderId,
|
|
2573
|
+
log: ctx.log,
|
|
2574
|
+
onStatusUpdate: options?.onStatusUpdate,
|
|
2575
|
+
onImageProgress: options?.onImageProgress
|
|
2576
|
+
});
|
|
2577
|
+
imageResolutionDurationMs += Date.now() - imageResolutionStartMs;
|
|
2578
|
+
}
|
|
2579
|
+
const approvedOutcome = await respondFromPlan(approvalPlan, pending.source, "apply_now", { preResolvedPlan: true });
|
|
2580
|
+
if (approvedOutcome.done)
|
|
2581
|
+
return approvedOutcome.response;
|
|
2582
|
+
return guardrailFailureResponse({ reason: approvedOutcome.reason, source: pending.source });
|
|
2583
|
+
}
|
|
2584
|
+
catch (error) {
|
|
2585
|
+
const reason = toErrorDetail(error);
|
|
2586
|
+
ctx.log.error({ event: "apply_pending_plan_error", chatRequestId, error: reason }, "Pending plan execution failed");
|
|
2587
|
+
pendingApprovalPlanBySession.delete(body.session);
|
|
2588
|
+
ctx.chatTelemetry.push({
|
|
2589
|
+
id: chatRequestId,
|
|
2590
|
+
at: new Date().toISOString(),
|
|
2591
|
+
phase: "result",
|
|
2592
|
+
session: body.session,
|
|
2593
|
+
requestedSlug,
|
|
2594
|
+
effectiveSlug,
|
|
2595
|
+
plannerSource: pending.source,
|
|
2596
|
+
modelKey: pending.modelKey,
|
|
2597
|
+
modelUsed: pending.modelUsed,
|
|
2598
|
+
promptHash,
|
|
2599
|
+
promptExcerpt,
|
|
2600
|
+
promptLength: plannerMessage.length,
|
|
2601
|
+
outcome: "apply_pending_plan_error",
|
|
2602
|
+
reason: reason.slice(0, 300),
|
|
2603
|
+
reasonCategory: classifyGuardrailError(reason),
|
|
2604
|
+
...plannerContextTelemetryFields,
|
|
2605
|
+
...timingFields()
|
|
2606
|
+
});
|
|
2607
|
+
return {
|
|
2608
|
+
code: 500,
|
|
2609
|
+
payload: withDebugPayload({
|
|
2610
|
+
status: "error",
|
|
2611
|
+
summary: "Failed to execute the approved plan. Please try again.",
|
|
2612
|
+
changes: [reason.slice(0, 300)],
|
|
2613
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
2614
|
+
plannerSource: pending.source,
|
|
2615
|
+
modelUsed: pending.modelUsed,
|
|
2616
|
+
modelKey: pending.modelKey
|
|
2617
|
+
}, { outcome: "apply_pending_plan_error" })
|
|
2618
|
+
};
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
// plan-first applies as a revertible draft (see planFirstApplyAsDraft); the
|
|
2622
|
+
// destructive/complex gates inside respondFromPlan can still upgrade to a hold.
|
|
2623
|
+
const applyMode = (executionMode === "plan_only" && !planFirstApplyAsDraft) ? "plan_only" : "apply_now";
|
|
2624
|
+
markPlanningStart();
|
|
2625
|
+
emitStatusTone("planning");
|
|
2626
|
+
const forcedDuplicatePlan = deterministicDuplicatePagePlan({ session: body.session, message: plannerMessage, effectiveSlug });
|
|
2627
|
+
if (forcedDuplicatePlan) {
|
|
2628
|
+
markPlanningFinish();
|
|
2629
|
+
ctx.chatTelemetry.push({
|
|
2630
|
+
id: chatRequestId,
|
|
2631
|
+
at: new Date().toISOString(),
|
|
2632
|
+
phase: "forced_plan",
|
|
2633
|
+
session: body.session,
|
|
2634
|
+
requestedSlug,
|
|
2635
|
+
effectiveSlug,
|
|
2636
|
+
plannerSource,
|
|
2637
|
+
modelKey,
|
|
2638
|
+
modelUsed,
|
|
2639
|
+
promptHash,
|
|
2640
|
+
promptExcerpt,
|
|
2641
|
+
promptLength: plannerMessage.length,
|
|
2642
|
+
outcome: "forced_duplicate_page",
|
|
2643
|
+
intent: forcedDuplicatePlan.intent,
|
|
2644
|
+
opCount: forcedDuplicatePlan.ops.length,
|
|
2645
|
+
opTypes: forcedDuplicatePlan.ops.map((op) => op.op),
|
|
2646
|
+
plannerTier: "forced_deterministic",
|
|
2647
|
+
...timingFields()
|
|
2648
|
+
});
|
|
2649
|
+
const forcedOutcome = await respondFromPlan(forcedDuplicatePlan, plannerSource, applyMode, undefined, "forced_deterministic");
|
|
2650
|
+
if (forcedOutcome.done)
|
|
2651
|
+
return forcedOutcome.response;
|
|
2652
|
+
}
|
|
2653
|
+
// Deterministic selected-text rewrite disabled — let AI planner handle creative rewrites.
|
|
2654
|
+
const forcedCreatePlan = deterministicCreatePagePlan({ session: body.session, message: plannerMessage, hasPageTemplates: !!siteContextBlock?.includes("Page templates:\n") });
|
|
2655
|
+
if (forcedCreatePlan) {
|
|
2656
|
+
markPlanningFinish();
|
|
2657
|
+
ctx.chatTelemetry.push({
|
|
2658
|
+
id: chatRequestId,
|
|
2659
|
+
at: new Date().toISOString(),
|
|
2660
|
+
phase: "forced_plan",
|
|
2661
|
+
session: body.session,
|
|
2662
|
+
requestedSlug,
|
|
2663
|
+
effectiveSlug,
|
|
2664
|
+
plannerSource,
|
|
2665
|
+
modelKey,
|
|
2666
|
+
modelUsed,
|
|
2667
|
+
promptHash,
|
|
2668
|
+
promptExcerpt,
|
|
2669
|
+
promptLength: plannerMessage.length,
|
|
2670
|
+
outcome: "forced_create_page",
|
|
2671
|
+
intent: forcedCreatePlan.intent,
|
|
2672
|
+
opCount: forcedCreatePlan.ops.length,
|
|
2673
|
+
opTypes: forcedCreatePlan.ops.map((op) => op.op),
|
|
2674
|
+
plannerTier: "forced_deterministic",
|
|
2675
|
+
...timingFields()
|
|
2676
|
+
});
|
|
2677
|
+
const forcedOutcome = await respondFromPlan(forcedCreatePlan, plannerSource, applyMode, undefined, "forced_deterministic");
|
|
2678
|
+
if (forcedOutcome.done)
|
|
2679
|
+
return forcedOutcome.response;
|
|
2680
|
+
}
|
|
2681
|
+
if (plannerSource === "demo") {
|
|
2682
|
+
markPlanningStart();
|
|
2683
|
+
try {
|
|
2684
|
+
emitStatusTone("planning");
|
|
2685
|
+
const demoPlan = demoPlanFromMessageImpl(plannerMessage, effectiveSlug, planningActiveBlockId, body.activeBlockType);
|
|
2686
|
+
markPlanningFinish();
|
|
2687
|
+
const outcome = await respondFromPlan(demoPlan, "demo", applyMode, undefined, "demo");
|
|
2688
|
+
if (outcome.done)
|
|
2689
|
+
return outcome.response;
|
|
2690
|
+
return guardrailFailureResponse({ reason: outcome.reason, source: "demo" });
|
|
2691
|
+
}
|
|
2692
|
+
catch (error) {
|
|
2693
|
+
markPlanningFinish();
|
|
2694
|
+
const reason = toErrorDetail(error);
|
|
2695
|
+
ctx.chatTelemetry.push({
|
|
2696
|
+
id: chatRequestId,
|
|
2697
|
+
at: new Date().toISOString(),
|
|
2698
|
+
phase: "result",
|
|
2699
|
+
session: body.session,
|
|
2700
|
+
requestedSlug,
|
|
2701
|
+
effectiveSlug,
|
|
2702
|
+
plannerSource: "demo",
|
|
2703
|
+
modelKey,
|
|
2704
|
+
modelUsed,
|
|
2705
|
+
promptHash,
|
|
2706
|
+
promptExcerpt,
|
|
2707
|
+
promptLength: plannerMessage.length,
|
|
2708
|
+
outcome: "planner_exception",
|
|
2709
|
+
reason: reason.slice(0, 300),
|
|
2710
|
+
reasonCategory: classifyGuardrailError(reason),
|
|
2711
|
+
plannerTier: "demo",
|
|
2712
|
+
...plannerContextTelemetryFields,
|
|
2713
|
+
...timingFields()
|
|
2714
|
+
});
|
|
2715
|
+
return {
|
|
2716
|
+
code: 500,
|
|
2717
|
+
payload: withDebugPayload({
|
|
2718
|
+
status: "error",
|
|
2719
|
+
summary: "Could not generate an edit plan.",
|
|
2720
|
+
changes: [reason.slice(0, 300)],
|
|
2721
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
2722
|
+
plannerSource: "demo",
|
|
2723
|
+
modelUsed,
|
|
2724
|
+
modelKey
|
|
2725
|
+
}, { outcome: "planner_exception", reasonCategory: classifyGuardrailError(reason), reason: reason.slice(0, 300), plannerTier: "demo" })
|
|
2726
|
+
};
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
// --- Vision alt-text short-circuit ---
|
|
2730
|
+
// Inline "Generate alt text" / "Improve accessibility" / "Make more descriptive"
|
|
2731
|
+
// wand on imageAlt fields. The full planner reliably misroutes this to
|
|
2732
|
+
// needs_clarification when the existing alt-text value is empty or looks like a
|
|
2733
|
+
// user instruction (a frequent shape of "previously corrupted" alt). Bypass the
|
|
2734
|
+
// planner with a direct vision call to get clean alt text deterministically.
|
|
2735
|
+
if (!contentQuery &&
|
|
2736
|
+
planningActiveBlockId &&
|
|
2737
|
+
planningActiveEditablePath &&
|
|
2738
|
+
isVisionAltGenerationRequest({
|
|
2739
|
+
message: plannerMessage,
|
|
2740
|
+
activeEditablePath: planningActiveEditablePath,
|
|
2741
|
+
activeBlockId: planningActiveBlockId,
|
|
2742
|
+
currentPage: current
|
|
2743
|
+
})) {
|
|
2744
|
+
const block = current.blocks.find((b) => b.id === planningActiveBlockId);
|
|
2745
|
+
const pathParse = parseAltPathForOp(planningActiveEditablePath);
|
|
2746
|
+
const imageUrl = block
|
|
2747
|
+
? resolveImageUrlForAltField(block.props, planningActiveEditablePath)
|
|
2748
|
+
: undefined;
|
|
2749
|
+
if (block && pathParse && imageUrl) {
|
|
2750
|
+
emitStatusTone("planning");
|
|
2751
|
+
markPlanningStart();
|
|
2752
|
+
const altText = await generateAltTextFromVision({
|
|
2753
|
+
imageUrl,
|
|
2754
|
+
preferredProvider: plannerSource,
|
|
2755
|
+
log: ctx.log
|
|
2756
|
+
});
|
|
2757
|
+
markPlanningFinish();
|
|
2758
|
+
if (altText) {
|
|
2759
|
+
const op = pathParse.kind === "props"
|
|
2760
|
+
? {
|
|
2761
|
+
op: "update_props",
|
|
2762
|
+
pageSlug: effectiveSlug,
|
|
2763
|
+
blockId: planningActiveBlockId,
|
|
2764
|
+
patch: { [pathParse.key]: altText }
|
|
2765
|
+
}
|
|
2766
|
+
: {
|
|
2767
|
+
op: "update_item",
|
|
2768
|
+
pageSlug: effectiveSlug,
|
|
2769
|
+
blockId: planningActiveBlockId,
|
|
2770
|
+
listKey: pathParse.listKey,
|
|
2771
|
+
index: pathParse.index,
|
|
2772
|
+
patch: { [pathParse.itemKey]: altText }
|
|
2773
|
+
};
|
|
2774
|
+
const visionPlan = {
|
|
2775
|
+
intent: "edit_plan",
|
|
2776
|
+
summary_for_user: "Generated alt text from the image.",
|
|
2777
|
+
change_log: ["Updated the image alt text."],
|
|
2778
|
+
ops: [op]
|
|
2779
|
+
};
|
|
2780
|
+
const visionOutcome = await respondFromPlan(visionPlan, plannerSource, applyMode, undefined, "deterministic");
|
|
2781
|
+
if (visionOutcome.done)
|
|
2782
|
+
return visionOutcome.response;
|
|
2783
|
+
}
|
|
2784
|
+
// Fall through to the regular planner if the direct vision call failed.
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
const isHighConfidence = !contentQuery && isHighConfidenceDeterministicCase({
|
|
2788
|
+
message: plannerMessage,
|
|
2789
|
+
currentPage: current,
|
|
2790
|
+
activeBlockId: planningActiveBlockId,
|
|
2791
|
+
activeEditablePath: planningActiveEditablePath
|
|
2792
|
+
});
|
|
2793
|
+
if (isHighConfidence) {
|
|
2794
|
+
const deterministicIntent = inferDeterministicIntent({
|
|
2795
|
+
message: plannerMessage,
|
|
2796
|
+
currentPage: current,
|
|
2797
|
+
activeBlockId: planningActiveBlockId,
|
|
2798
|
+
activeEditablePath: planningActiveEditablePath
|
|
2799
|
+
});
|
|
2800
|
+
if (deterministicIntent) {
|
|
2801
|
+
emitStatusTone("planning");
|
|
2802
|
+
const deterministicPlan = compileDeterministicPlan({
|
|
2803
|
+
session: body.session,
|
|
2804
|
+
intent: deterministicIntent,
|
|
2805
|
+
message: plannerMessage,
|
|
2806
|
+
slug: effectiveSlug,
|
|
2807
|
+
currentPage: current,
|
|
2808
|
+
activeBlockId: planningActiveBlockId,
|
|
2809
|
+
activeEditablePath: planningActiveEditablePath,
|
|
2810
|
+
locale: body.locale
|
|
2811
|
+
});
|
|
2812
|
+
if (deterministicPlan?.intent === "edit_plan" && deterministicPlan.ops.length > 0) {
|
|
2813
|
+
markPlanningFinish();
|
|
2814
|
+
ctx.chatTelemetry.push({
|
|
2815
|
+
id: chatRequestId,
|
|
2816
|
+
at: new Date().toISOString(),
|
|
2817
|
+
phase: "deterministic_plan_generated",
|
|
2818
|
+
session: body.session,
|
|
2819
|
+
requestedSlug,
|
|
2820
|
+
effectiveSlug,
|
|
2821
|
+
plannerSource,
|
|
2822
|
+
modelKey,
|
|
2823
|
+
modelUsed,
|
|
2824
|
+
promptHash,
|
|
2825
|
+
promptExcerpt,
|
|
2826
|
+
promptLength: plannerMessage.length,
|
|
2827
|
+
outcome: "deterministic_plan_ready",
|
|
2828
|
+
intent: deterministicPlan.intent,
|
|
2829
|
+
opCount: deterministicPlan.ops.length,
|
|
2830
|
+
opTypes: deterministicPlan.ops.map((op) => op.op),
|
|
2831
|
+
plannerTier: "deterministic"
|
|
2832
|
+
});
|
|
2833
|
+
const deterministicOutcome = await respondFromPlan(deterministicPlan, plannerSource, applyMode, undefined, "deterministic");
|
|
2834
|
+
if (deterministicOutcome.done)
|
|
2835
|
+
return deterministicOutcome.response;
|
|
2836
|
+
}
|
|
2837
|
+
if (deterministicPlan?.intent === "needs_clarification" &&
|
|
2838
|
+
shouldReturnDeterministicClarification(plannerMessage)) {
|
|
2839
|
+
markPlanningFinish();
|
|
2840
|
+
const deterministicOutcome = await respondFromPlan(deterministicPlan, plannerSource, applyMode, undefined, "deterministic");
|
|
2841
|
+
if (deterministicOutcome.done)
|
|
2842
|
+
return deterministicOutcome.response;
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
// --- Compound deterministic decomposition ---
|
|
2847
|
+
// When a single deterministic check fails due to compound actions
|
|
2848
|
+
// ("remove the hero and add a CTA"), try decomposing into sub-intents.
|
|
2849
|
+
if (!contentQuery && !isHighConfidence) {
|
|
2850
|
+
const compoundPlan = tryCompoundDeterministicPlan({
|
|
2851
|
+
session: body.session,
|
|
2852
|
+
message: plannerMessage,
|
|
2853
|
+
slug: effectiveSlug,
|
|
2854
|
+
currentPage: current,
|
|
2855
|
+
activeBlockId: planningActiveBlockId,
|
|
2856
|
+
activeEditablePath: planningActiveEditablePath
|
|
2857
|
+
});
|
|
2858
|
+
if (compoundPlan?.intent === "edit_plan" && compoundPlan.ops.length > 0) {
|
|
2859
|
+
markPlanningFinish();
|
|
2860
|
+
ctx.chatTelemetry.push({
|
|
2861
|
+
id: chatRequestId,
|
|
2862
|
+
at: new Date().toISOString(),
|
|
2863
|
+
phase: "deterministic_plan_generated",
|
|
2864
|
+
session: body.session,
|
|
2865
|
+
requestedSlug,
|
|
2866
|
+
effectiveSlug,
|
|
2867
|
+
plannerSource,
|
|
2868
|
+
modelKey,
|
|
2869
|
+
modelUsed,
|
|
2870
|
+
promptHash,
|
|
2871
|
+
promptExcerpt,
|
|
2872
|
+
promptLength: plannerMessage.length,
|
|
2873
|
+
outcome: "compound_deterministic_plan_ready",
|
|
2874
|
+
intent: compoundPlan.intent,
|
|
2875
|
+
opCount: compoundPlan.ops.length,
|
|
2876
|
+
opTypes: compoundPlan.ops.map((op) => op.op),
|
|
2877
|
+
plannerTier: "deterministic"
|
|
2878
|
+
});
|
|
2879
|
+
const compoundOutcome = await respondFromPlan(compoundPlan, plannerSource, applyMode, undefined, "deterministic");
|
|
2880
|
+
if (compoundOutcome.done)
|
|
2881
|
+
return compoundOutcome.response;
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
const llmIntentRouterEnabled = !/^(0|false|no|off)$/i.test((process.env.CHAT_LLM_INTENT_ROUTER ?? "1").trim());
|
|
2885
|
+
// Skip the intent router for batch-add requests — the router's deterministic
|
|
2886
|
+
// compile can't generate meaningful content for "add 3 blocks" without specific
|
|
2887
|
+
// block types, so go straight to the full planner which can handle it.
|
|
2888
|
+
const batchAddSkipsRouter = isBatchAddRequest(plannerMessage);
|
|
2889
|
+
// The router schema has no "duplicate" action (only add|move|update|remove|
|
|
2890
|
+
// info|clarify), so duplicate/clone/copy requests get misrouted to update_props.
|
|
2891
|
+
// Full planner supports `duplicate_block` natively.
|
|
2892
|
+
const duplicateSkipsRouter = isDuplicateBlockRequest(plannerMessage);
|
|
2893
|
+
// Vision alt-text generation (inline "Generate alt text" / "Improve accessibility"
|
|
2894
|
+
// wand on imageAlt fields) needs the full planner so the LLM can actually look
|
|
2895
|
+
// at the companion image. The fast router is JSON-only and can't see images —
|
|
2896
|
+
// it would hallucinate alt text or echo the user's prompt verbatim.
|
|
2897
|
+
const visionAltSkipsRouter = isVisionAltGenerationRequest({
|
|
2898
|
+
message: plannerMessage,
|
|
2899
|
+
activeEditablePath: planningActiveEditablePath,
|
|
2900
|
+
activeBlockId: planningActiveBlockId,
|
|
2901
|
+
currentPage: current
|
|
2902
|
+
});
|
|
2903
|
+
const shouldTryLlmIntentRouter = !contentQuery &&
|
|
2904
|
+
!batchAddSkipsRouter &&
|
|
2905
|
+
!duplicateSkipsRouter &&
|
|
2906
|
+
!visionAltSkipsRouter &&
|
|
2907
|
+
llmIntentRouterEnabled &&
|
|
2908
|
+
shouldUseLlmIntentRouter(plannerMessage) &&
|
|
2909
|
+
(plannerSource === "openai" || plannerSource === "anthropic" || plannerSource === "gemini");
|
|
2910
|
+
// --- Parallel intent router + full planner ---
|
|
2911
|
+
// When intent routing is enabled, launch both the fast intent router and the
|
|
2912
|
+
// full planner concurrently. The router gets a ~200ms head start. If it succeeds
|
|
2913
|
+
// and produces a valid plan, we abort the full planner and return immediately.
|
|
2914
|
+
// If it fails, the full planner is already running — no wasted time.
|
|
2915
|
+
const parallelPlannerEnabled = !/^(0|false|no|off)$/i.test((process.env.CHAT_PARALLEL_PLANNER ?? "1").trim());
|
|
2916
|
+
const routerHeadStartMs = Math.max(0, Math.min(Number(process.env.CHAT_ROUTER_HEAD_START_MS ?? 200), 1000));
|
|
2917
|
+
// Request-scoped registry. Closures over *Impl vars so test hooks
|
|
2918
|
+
// (setGeneratePlanWith*ForTests) still take effect.
|
|
2919
|
+
const plannerRegistry = createPlannerRegistry({
|
|
2920
|
+
openai: {
|
|
2921
|
+
source: "openai",
|
|
2922
|
+
supportsNativeTools: false,
|
|
2923
|
+
parseIntent: (a) => {
|
|
2924
|
+
const { log: _log, ...rest } = a;
|
|
2925
|
+
return parseIntentWithOpenAIImpl(rest);
|
|
2926
|
+
},
|
|
2927
|
+
generatePlan: (a) => {
|
|
2928
|
+
const { onStatusUpdate: _s, onImageProgress: _i, log: _l, ...rest } = a;
|
|
2929
|
+
return generatePlanWithOpenAIImpl(rest);
|
|
2930
|
+
},
|
|
2931
|
+
},
|
|
2932
|
+
anthropic: {
|
|
2933
|
+
source: "anthropic",
|
|
2934
|
+
supportsNativeTools: true,
|
|
2935
|
+
parseIntent: (a) => parseIntentWithAnthropicImpl(a),
|
|
2936
|
+
generatePlan: (a) => generatePlanWithAnthropicImpl(a),
|
|
2937
|
+
},
|
|
2938
|
+
gemini: {
|
|
2939
|
+
source: "gemini",
|
|
2940
|
+
supportsNativeTools: true,
|
|
2941
|
+
parseIntent: (a) => parseIntentWithGeminiImpl(a),
|
|
2942
|
+
generatePlan: (a) => generatePlanWithGeminiImpl(a),
|
|
2943
|
+
},
|
|
2944
|
+
});
|
|
2945
|
+
// `plannerSource === "demo"` is handled earlier; here it's always a real planner.
|
|
2946
|
+
const planner = plannerRegistry.get(plannerSource);
|
|
2947
|
+
const supportsNativeTools = planner.supportsNativeTools;
|
|
2948
|
+
const generatePlanImpl = planner.generatePlan;
|
|
2949
|
+
const maxPlanningAttempts = 3;
|
|
2950
|
+
let initialPlan = null;
|
|
2951
|
+
let routerDetectedInfo = false;
|
|
2952
|
+
const planningErrors = [];
|
|
2953
|
+
if (shouldTryLlmIntentRouter && parallelPlannerEnabled) {
|
|
2954
|
+
const routerModel = ctx.modelLookup[provider]?.fast ??
|
|
2955
|
+
ctx.modelLookup[provider]?.balanced ??
|
|
2956
|
+
modelUsed;
|
|
2957
|
+
// AbortController for the full planner — allows router success to cancel it
|
|
2958
|
+
const fullPlannerAbort = new AbortController();
|
|
2959
|
+
const combinedSignal = options?.signal
|
|
2960
|
+
? AbortSignal.any([options.signal, fullPlannerAbort.signal])
|
|
2961
|
+
: fullPlannerAbort.signal;
|
|
2962
|
+
// Track router completion for complexity downgrade
|
|
2963
|
+
let routerComplexity = null;
|
|
2964
|
+
// Intent router promise
|
|
2965
|
+
emitStatusTone("understanding");
|
|
2966
|
+
const routerPromise = (async () => {
|
|
2967
|
+
const routedIntent = await planner.parseIntent({
|
|
2968
|
+
message: plannerMessage,
|
|
2969
|
+
slug: effectiveSlug,
|
|
2970
|
+
currentPage: current,
|
|
2971
|
+
activeBlockId: planningActiveBlockId,
|
|
2972
|
+
activeBlockType: body.activeBlockType,
|
|
2973
|
+
activeEditablePath: planningActiveEditablePath,
|
|
2974
|
+
model: routerModel,
|
|
2975
|
+
log: ctx.log
|
|
2976
|
+
});
|
|
2977
|
+
const routedPlan = compileDeterministicPlan({
|
|
2978
|
+
session: body.session,
|
|
2979
|
+
intent: routedIntent,
|
|
2980
|
+
message: plannerMessage,
|
|
2981
|
+
slug: effectiveSlug,
|
|
2982
|
+
currentPage: current,
|
|
2983
|
+
activeBlockId: planningActiveBlockId,
|
|
2984
|
+
activeEditablePath: planningActiveEditablePath,
|
|
2985
|
+
locale: body.locale
|
|
2986
|
+
});
|
|
2987
|
+
routerComplexity = routedIntent.complexity ?? null;
|
|
2988
|
+
if (routedIntent.action === "info") {
|
|
2989
|
+
routerDetectedInfo = true;
|
|
2990
|
+
}
|
|
2991
|
+
if (routedPlan?.intent === "edit_plan" && routedPlan.ops.length > 0) {
|
|
2992
|
+
return { plan: routedPlan, outcome: "llm_router_plan_ready" };
|
|
2993
|
+
}
|
|
2994
|
+
if (routedPlan?.intent === "needs_clarification" && shouldReturnDeterministicClarification(plannerMessage)) {
|
|
2995
|
+
return { plan: routedPlan, outcome: "llm_router_needs_clarification" };
|
|
2996
|
+
}
|
|
2997
|
+
return null; // Router didn't produce a usable result
|
|
2998
|
+
})();
|
|
2999
|
+
// Full planner promise — starts after head-start delay.
|
|
3000
|
+
// When the regex heuristic already suspects a simple edit, we await the
|
|
3001
|
+
// router fully (adds ~200-500ms) to get the LLM complexity signal — worth
|
|
3002
|
+
// it because the fast model saves 5-10s on the main plan.
|
|
3003
|
+
// Exception: if the model is already "fast" (heuristic already selected it),
|
|
3004
|
+
// skip the await — the router can't downgrade further.
|
|
3005
|
+
const likelySimple = shouldPreferFastModelForMessage(plannerMessage);
|
|
3006
|
+
const alreadyFastModel = modelKey === "fast";
|
|
3007
|
+
const fullPlannerPromise = (async () => {
|
|
3008
|
+
if (likelySimple && !alreadyFastModel) {
|
|
3009
|
+
// Await the router to get its complexity verdict before choosing model
|
|
3010
|
+
try {
|
|
3011
|
+
await routerPromise;
|
|
3012
|
+
}
|
|
3013
|
+
catch { /* router failure is non-fatal */ }
|
|
3014
|
+
}
|
|
3015
|
+
else if (routerHeadStartMs > 0) {
|
|
3016
|
+
throwIfCanceled(combinedSignal);
|
|
3017
|
+
await sleepMs(routerHeadStartMs);
|
|
3018
|
+
}
|
|
3019
|
+
throwIfCanceled(combinedSignal);
|
|
3020
|
+
// Use router's complexity signal to downgrade model when appropriate
|
|
3021
|
+
let plannerModel = modelUsed;
|
|
3022
|
+
if (!forceSonnet && routerComplexity === "simple" && ctx.modelLookup[provider]?.fast) {
|
|
3023
|
+
plannerModel = ctx.modelLookup[provider].fast;
|
|
3024
|
+
ctx.log.info({ event: "complexity_downgrade", from: modelUsed, to: plannerModel }, "Router signaled simple — downgrading planner to fast model");
|
|
3025
|
+
routingDecision = {
|
|
3026
|
+
from: modelKey,
|
|
3027
|
+
to: "fast",
|
|
3028
|
+
reason: "simple_downgrade",
|
|
3029
|
+
complexity: "simple"
|
|
3030
|
+
};
|
|
3031
|
+
}
|
|
3032
|
+
else if (routerComplexity === "complex") {
|
|
3033
|
+
// PHASE 1: shadow-only. Log what we *would* upgrade to but don't
|
|
3034
|
+
// change the model. Gated behind CHAT_AUTO_UPGRADE_COMPLEX in a
|
|
3035
|
+
// follow-up PR. Lets us validate router accuracy before paying for
|
|
3036
|
+
// the reasoning tier.
|
|
3037
|
+
const reasoningModel = ctx.modelLookup[provider]?.reasoning;
|
|
3038
|
+
if (reasoningModel) {
|
|
3039
|
+
ctx.log.info({
|
|
3040
|
+
event: "complexity_upgrade_shadow",
|
|
3041
|
+
from: modelUsed,
|
|
3042
|
+
wouldUpgradeTo: reasoningModel,
|
|
3043
|
+
modelKey,
|
|
3044
|
+
wouldUpgradeToKey: "reasoning",
|
|
3045
|
+
provider,
|
|
3046
|
+
promptLength: plannerMessage.length
|
|
3047
|
+
}, "Router signaled complex — shadow-logging would-be upgrade to reasoning model (PR1: not applied)");
|
|
3048
|
+
routingDecision = {
|
|
3049
|
+
from: modelKey,
|
|
3050
|
+
to: "reasoning",
|
|
3051
|
+
reason: "shadow",
|
|
3052
|
+
complexity: "complex"
|
|
3053
|
+
};
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
markPlanningStart();
|
|
3057
|
+
emitStatusTone("thinking");
|
|
3058
|
+
const result = await raceCancel(generatePlanImpl({
|
|
3059
|
+
message: plannerMessage,
|
|
3060
|
+
slug: effectiveSlug,
|
|
3061
|
+
currentPage: current,
|
|
3062
|
+
contextPack: plannerContext,
|
|
3063
|
+
model: plannerModel,
|
|
3064
|
+
locale: body.locale,
|
|
3065
|
+
imageSourceChoiceOpen,
|
|
3066
|
+
history: isLightweightEdit ? [] : plannerChatHistory,
|
|
3067
|
+
siteContextBlock: isLightweightEdit ? undefined : siteContextBlock,
|
|
3068
|
+
toolRuntime: supportsNativeTools && !isLightweightEdit ? ctx.toolRuntime : undefined,
|
|
3069
|
+
toolCallContext: supportsNativeTools && !isLightweightEdit
|
|
3070
|
+
? {
|
|
3071
|
+
siteId: body.siteId ?? "default",
|
|
3072
|
+
sessionId: body.session ?? "dev",
|
|
3073
|
+
traceId: chatRequestId,
|
|
3074
|
+
gdriveFolderId
|
|
3075
|
+
}
|
|
3076
|
+
: undefined,
|
|
3077
|
+
onStatusUpdate: options?.onStatusUpdate,
|
|
3078
|
+
onImageProgress: options?.onImageProgress,
|
|
3079
|
+
onToolExecution: supportsNativeTools && !isLightweightEdit
|
|
3080
|
+
? (event) => {
|
|
3081
|
+
if (event.ok && !event.deferred && event.toolName === "unsplash.search")
|
|
3082
|
+
usedNativeUnsplashTool = true;
|
|
3083
|
+
if (event.ok && !event.deferred && event.toolName === "image.generate")
|
|
3084
|
+
usedNativeImageTool = true;
|
|
3085
|
+
ctx.chatTelemetry.push({
|
|
3086
|
+
id: chatRequestId,
|
|
3087
|
+
at: new Date().toISOString(),
|
|
3088
|
+
phase: "tool_call",
|
|
3089
|
+
session: body.session ?? "dev",
|
|
3090
|
+
requestedSlug,
|
|
3091
|
+
effectiveSlug,
|
|
3092
|
+
plannerSource,
|
|
3093
|
+
modelKey,
|
|
3094
|
+
modelUsed,
|
|
3095
|
+
promptHash,
|
|
3096
|
+
promptExcerpt,
|
|
3097
|
+
promptLength: plannerMessage.length,
|
|
3098
|
+
toolName: event.toolName,
|
|
3099
|
+
toolOk: event.ok,
|
|
3100
|
+
toolLatencyMs: event.latencyMs,
|
|
3101
|
+
toolAttempts: event.attempts,
|
|
3102
|
+
toolErrorCode: event.errorCode,
|
|
3103
|
+
correlationId: event.traceId,
|
|
3104
|
+
outcome: event.ok ? "tool_ok" : "tool_error",
|
|
3105
|
+
...timingFields()
|
|
3106
|
+
});
|
|
3107
|
+
}
|
|
3108
|
+
: undefined,
|
|
3109
|
+
log: ctx.log,
|
|
3110
|
+
onToken: onPlanningToken,
|
|
3111
|
+
onThinking: options?.onThinking,
|
|
3112
|
+
onFieldDraft: options?.onFieldDraft,
|
|
3113
|
+
onSummaryChunk: options?.onSummaryChunk,
|
|
3114
|
+
onChangeLogEntry: options?.onChangeLogEntry,
|
|
3115
|
+
onPlannedOp: incrementalPlanStreamEnabled
|
|
3116
|
+
? (op, index) => {
|
|
3117
|
+
markFirstStructuredProgress();
|
|
3118
|
+
options?.onPlannedOp?.({ op, index });
|
|
3119
|
+
}
|
|
3120
|
+
: undefined,
|
|
3121
|
+
componentsManifest,
|
|
3122
|
+
lightweight: routerComplexity === "simple" || likelySimple,
|
|
3123
|
+
signal: combinedSignal,
|
|
3124
|
+
// Disable thinking on router-downgraded simple plans — thinking only makes
|
|
3125
|
+
// sense on the full planner path.
|
|
3126
|
+
thinking: routerComplexity === "simple" ? undefined : thinkingConfig
|
|
3127
|
+
}), combinedSignal);
|
|
3128
|
+
return result;
|
|
3129
|
+
})();
|
|
3130
|
+
// Helper to extract plan result from the full planner promise
|
|
3131
|
+
const consumeFullPlannerResult = (plannerResult) => {
|
|
3132
|
+
initialPlan = plannerResult.plan;
|
|
3133
|
+
planUsage = plannerResult.usage;
|
|
3134
|
+
if (plannerResult.deferredNativeImageCalls?.length) {
|
|
3135
|
+
deferredNativeImageCalls = plannerResult.deferredNativeImageCalls;
|
|
3136
|
+
}
|
|
3137
|
+
if (plannerResult.schemaContext) {
|
|
3138
|
+
plannerContextTelemetryFields.contractMode = plannerResult.schemaContext.contractMode;
|
|
3139
|
+
plannerContextTelemetryFields.contractBytes = plannerResult.schemaContext.contractBytes;
|
|
3140
|
+
plannerContextTelemetryFields.contractBlockCount = plannerResult.schemaContext.contractBlockCount;
|
|
3141
|
+
plannerContextTelemetryFields.strictJsonEnabled = plannerResult.schemaContext.strictJsonEnabled;
|
|
3142
|
+
}
|
|
3143
|
+
markPlanningFinish();
|
|
3144
|
+
emitStatusTone("validating");
|
|
3145
|
+
};
|
|
3146
|
+
// Race: router vs full planner
|
|
3147
|
+
try {
|
|
3148
|
+
const routerResult = await Promise.race([
|
|
3149
|
+
routerPromise.then((r) => ({ source: "router", result: r })),
|
|
3150
|
+
fullPlannerPromise.then((r) => ({ source: "planner", result: r }))
|
|
3151
|
+
]);
|
|
3152
|
+
if (routerResult.source === "router" && routerResult.result) {
|
|
3153
|
+
// Router won with a usable plan — quality gate before using it
|
|
3154
|
+
const { plan: routedPlan, outcome } = routerResult.result;
|
|
3155
|
+
// Quality gate: if the message requests content generation but the
|
|
3156
|
+
// router plan only has default/shallow props, wait briefly for the
|
|
3157
|
+
// full planner which can produce richer content.
|
|
3158
|
+
if (isRouterPlanTooShallow(plannerMessage, routedPlan)) {
|
|
3159
|
+
ctx.log.info({ event: "router_plan_too_shallow" }, "Router plan shallow for content-generating message — waiting for full planner");
|
|
3160
|
+
try {
|
|
3161
|
+
const betterResult = await Promise.race([
|
|
3162
|
+
fullPlannerPromise.then((r) => ({ got: "planner", result: r })),
|
|
3163
|
+
sleepMs(routerHeadStartMs).then(() => ({ got: "timeout", result: null }))
|
|
3164
|
+
]);
|
|
3165
|
+
if (betterResult.got === "planner" && betterResult.result) {
|
|
3166
|
+
consumeFullPlannerResult(betterResult.result);
|
|
3167
|
+
suppressCancelOnly(routerPromise, ctx.log, "router_after_quality_gate");
|
|
3168
|
+
// Skip to post-race handling with initialPlan set
|
|
3169
|
+
}
|
|
3170
|
+
else {
|
|
3171
|
+
// Planner didn't finish in time — fall through to use router plan below
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
catch (qualityGateErr) {
|
|
3175
|
+
if (isCancelError(qualityGateErr))
|
|
3176
|
+
throw qualityGateErr;
|
|
3177
|
+
ctx.log.warn({ event: "quality_gate_planner_error", error: toErrorDetail(qualityGateErr) }, "Quality gate: full planner errored — using router plan");
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
// If full planner didn't beat the router (or quality gate didn't fire), use router plan
|
|
3181
|
+
if (!initialPlan) {
|
|
3182
|
+
markPlanningFinish();
|
|
3183
|
+
emitStatusTone("planning");
|
|
3184
|
+
ctx.chatTelemetry.push({
|
|
3185
|
+
id: chatRequestId,
|
|
3186
|
+
at: new Date().toISOString(),
|
|
3187
|
+
phase: "deterministic_plan_generated",
|
|
3188
|
+
session: body.session,
|
|
3189
|
+
requestedSlug,
|
|
3190
|
+
effectiveSlug,
|
|
3191
|
+
plannerSource,
|
|
3192
|
+
modelKey,
|
|
3193
|
+
modelUsed,
|
|
3194
|
+
promptHash,
|
|
3195
|
+
promptExcerpt,
|
|
3196
|
+
promptLength: plannerMessage.length,
|
|
3197
|
+
outcome,
|
|
3198
|
+
intent: routedPlan.intent,
|
|
3199
|
+
opCount: routedPlan.ops.length,
|
|
3200
|
+
opTypes: routedPlan.ops.map((op) => op.op),
|
|
3201
|
+
plannerTier: "llm_intent_router",
|
|
3202
|
+
...timingFields()
|
|
3203
|
+
});
|
|
3204
|
+
const routedOutcome = await respondFromPlan(routedPlan, plannerSource, applyMode, undefined, "llm_intent_router");
|
|
3205
|
+
if (routedOutcome.done) {
|
|
3206
|
+
// For needs_clarification: only short-circuit for deterministic scenarios
|
|
3207
|
+
// (page deletes, renames, etc.). Otherwise fall through to the full planner —
|
|
3208
|
+
// the router may have refused a valid batch-add request like "populate this page".
|
|
3209
|
+
if (routedPlan.intent === "needs_clarification" && !shouldReturnDeterministicClarification(plannerMessage)) {
|
|
3210
|
+
// Fall through — wait for the full planner result below
|
|
3211
|
+
}
|
|
3212
|
+
else {
|
|
3213
|
+
// Success — abort the full planner (fire and forget)
|
|
3214
|
+
fullPlannerAbort.abort("router_succeeded");
|
|
3215
|
+
suppressCancelOnly(fullPlannerPromise, ctx.log, "planner_after_router_success");
|
|
3216
|
+
return routedOutcome.response;
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3220
|
+
// Router plan failed validation or was a non-deterministic clarification — fall through to wait for full planner
|
|
3221
|
+
}
|
|
3222
|
+
if (routerResult.source === "router") {
|
|
3223
|
+
if (routerDetectedInfo) {
|
|
3224
|
+
// Info query — abort the in-flight planner (it has wrong context)
|
|
3225
|
+
fullPlannerAbort.abort("router_info_rebuild");
|
|
3226
|
+
suppressCancelOnly(fullPlannerPromise, ctx.log, "planner_after_router_info");
|
|
3227
|
+
// Fall through to post-race context rebuild below
|
|
3228
|
+
}
|
|
3229
|
+
else {
|
|
3230
|
+
// Router returned null or failed validation — await the full planner
|
|
3231
|
+
try {
|
|
3232
|
+
const plannerResult = await fullPlannerPromise;
|
|
3233
|
+
consumeFullPlannerResult(plannerResult);
|
|
3234
|
+
}
|
|
3235
|
+
catch (plannerFallbackError) {
|
|
3236
|
+
if (isCancelError(plannerFallbackError))
|
|
3237
|
+
throw plannerFallbackError;
|
|
3238
|
+
// Full planner also failed — fall through to retry loop
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
}
|
|
3242
|
+
else {
|
|
3243
|
+
// Full planner won the race — use its result
|
|
3244
|
+
consumeFullPlannerResult(routerResult.result);
|
|
3245
|
+
// Let router finish in the background (no side effects)
|
|
3246
|
+
suppressCancelOnly(routerPromise, ctx.log, "router_after_planner_won");
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
catch (parallelError) {
|
|
3250
|
+
if (isCancelError(parallelError))
|
|
3251
|
+
throw parallelError;
|
|
3252
|
+
// Both failed — fall through to sequential full planner retry loop
|
|
3253
|
+
fullPlannerAbort.abort("parallel_error");
|
|
3254
|
+
suppressCancelOnly(fullPlannerPromise, ctx.log, "planner_after_parallel_error");
|
|
3255
|
+
suppressCancelOnly(routerPromise, ctx.log, "router_after_parallel_error");
|
|
3256
|
+
ctx.log.warn({ err: toErrorDetail(parallelError), event: "parallel_planner_error" }, "Parallel intent router + planner failed, falling back to sequential planner");
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
else if (shouldTryLlmIntentRouter) {
|
|
3260
|
+
// Sequential intent router (parallel disabled via env)
|
|
3261
|
+
try {
|
|
3262
|
+
emitStatusTone("understanding");
|
|
3263
|
+
const routerModel = ctx.modelLookup[provider]?.fast ??
|
|
3264
|
+
ctx.modelLookup[provider]?.balanced ??
|
|
3265
|
+
modelUsed;
|
|
3266
|
+
const routedIntent = await planner.parseIntent({
|
|
3267
|
+
message: plannerMessage,
|
|
3268
|
+
slug: effectiveSlug,
|
|
3269
|
+
currentPage: current,
|
|
3270
|
+
activeBlockId: planningActiveBlockId,
|
|
3271
|
+
activeBlockType: body.activeBlockType,
|
|
3272
|
+
activeEditablePath: planningActiveEditablePath,
|
|
3273
|
+
model: routerModel,
|
|
3274
|
+
log: ctx.log
|
|
3275
|
+
});
|
|
3276
|
+
emitStatusTone("planning");
|
|
3277
|
+
const routedPlan = compileDeterministicPlan({
|
|
3278
|
+
session: body.session,
|
|
3279
|
+
intent: routedIntent,
|
|
3280
|
+
message: plannerMessage,
|
|
3281
|
+
slug: effectiveSlug,
|
|
3282
|
+
currentPage: current,
|
|
3283
|
+
activeBlockId: planningActiveBlockId,
|
|
3284
|
+
activeEditablePath: planningActiveEditablePath,
|
|
3285
|
+
locale: body.locale
|
|
3286
|
+
});
|
|
3287
|
+
if (routedIntent.action === "info") {
|
|
3288
|
+
routerDetectedInfo = true;
|
|
3289
|
+
}
|
|
3290
|
+
if (routedPlan?.intent === "edit_plan" && routedPlan.ops.length > 0) {
|
|
3291
|
+
markPlanningFinish();
|
|
3292
|
+
ctx.chatTelemetry.push({
|
|
3293
|
+
id: chatRequestId,
|
|
3294
|
+
at: new Date().toISOString(),
|
|
3295
|
+
phase: "deterministic_plan_generated",
|
|
3296
|
+
session: body.session,
|
|
3297
|
+
requestedSlug,
|
|
3298
|
+
effectiveSlug,
|
|
3299
|
+
plannerSource,
|
|
3300
|
+
modelKey,
|
|
3301
|
+
modelUsed,
|
|
3302
|
+
promptHash,
|
|
3303
|
+
promptExcerpt,
|
|
3304
|
+
promptLength: plannerMessage.length,
|
|
3305
|
+
outcome: "llm_router_plan_ready",
|
|
3306
|
+
intent: routedPlan.intent,
|
|
3307
|
+
opCount: routedPlan.ops.length,
|
|
3308
|
+
opTypes: routedPlan.ops.map((op) => op.op),
|
|
3309
|
+
plannerTier: "llm_intent_router",
|
|
3310
|
+
...timingFields()
|
|
3311
|
+
});
|
|
3312
|
+
const routedOutcome = await respondFromPlan(routedPlan, plannerSource, applyMode, undefined, "llm_intent_router");
|
|
3313
|
+
if (routedOutcome.done)
|
|
3314
|
+
return routedOutcome.response;
|
|
3315
|
+
}
|
|
3316
|
+
if (routedPlan?.intent === "needs_clarification" && shouldReturnDeterministicClarification(plannerMessage)) {
|
|
3317
|
+
markPlanningFinish();
|
|
3318
|
+
ctx.chatTelemetry.push({
|
|
3319
|
+
id: chatRequestId,
|
|
3320
|
+
at: new Date().toISOString(),
|
|
3321
|
+
phase: "deterministic_plan_generated",
|
|
3322
|
+
session: body.session,
|
|
3323
|
+
requestedSlug,
|
|
3324
|
+
effectiveSlug,
|
|
3325
|
+
plannerSource,
|
|
3326
|
+
modelKey,
|
|
3327
|
+
modelUsed,
|
|
3328
|
+
promptHash,
|
|
3329
|
+
promptExcerpt,
|
|
3330
|
+
promptLength: plannerMessage.length,
|
|
3331
|
+
outcome: "llm_router_needs_clarification",
|
|
3332
|
+
intent: routedPlan.intent,
|
|
3333
|
+
opCount: 0,
|
|
3334
|
+
opTypes: [],
|
|
3335
|
+
plannerTier: "llm_intent_router",
|
|
3336
|
+
...timingFields()
|
|
3337
|
+
});
|
|
3338
|
+
const routedOutcome = await respondFromPlan(routedPlan, plannerSource, applyMode, undefined, "llm_intent_router");
|
|
3339
|
+
if (routedOutcome.done)
|
|
3340
|
+
return routedOutcome.response;
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
catch {
|
|
3344
|
+
// If router fails, fall back to the full planner.
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
// Router detected a content query the regex missed — rebuild context with full props
|
|
3348
|
+
// so the full planner retry loop has the page content it needs for content_answer.
|
|
3349
|
+
if (routerDetectedInfo && !initialPlan && !contentQuery) {
|
|
3350
|
+
plannerContext = plannerContextPack({
|
|
3351
|
+
session: body.session,
|
|
3352
|
+
slug: effectiveSlug,
|
|
3353
|
+
message: plannerMessage,
|
|
3354
|
+
currentPage: current,
|
|
3355
|
+
activeBlockId: planningActiveBlockId,
|
|
3356
|
+
activeBlockType: body.activeBlockType,
|
|
3357
|
+
activeEditablePath: planningActiveEditablePath,
|
|
3358
|
+
includeFullProps: true,
|
|
3359
|
+
attachments: body.attachments
|
|
3360
|
+
});
|
|
3361
|
+
isLightweightEdit = false;
|
|
3362
|
+
}
|
|
3363
|
+
// --- Continuation chain decomposition ---
|
|
3364
|
+
// If the message looks like a multi-step request and we haven't already entered a chain,
|
|
3365
|
+
// decompose it into steps and execute only the first one through the normal planner.
|
|
3366
|
+
if (!initialPlan &&
|
|
3367
|
+
executionMode === "auto" &&
|
|
3368
|
+
!continuationChainBySession.has(body.session) &&
|
|
3369
|
+
isMultiStepCandidate(plannerMessage) &&
|
|
3370
|
+
(plannerSource === "openai" || plannerSource === "anthropic" || plannerSource === "gemini")) {
|
|
3371
|
+
try {
|
|
3372
|
+
emitStatusTone("breaking_down");
|
|
3373
|
+
// Always use OpenAI for decomposition — it's a lightweight JSON task that
|
|
3374
|
+
// doesn't need the full Anthropic planner, and avoids model/provider mismatch.
|
|
3375
|
+
const decomposerModel = ctx.modelLookup.openai?.fast ??
|
|
3376
|
+
ctx.modelLookup.openai?.balanced ??
|
|
3377
|
+
ctx.modelLookup[provider]?.fast ??
|
|
3378
|
+
modelUsed;
|
|
3379
|
+
const decomposition = await decomposeRequest({
|
|
3380
|
+
message: plannerMessage,
|
|
3381
|
+
currentPage: current,
|
|
3382
|
+
slug: effectiveSlug,
|
|
3383
|
+
model: decomposerModel,
|
|
3384
|
+
siteContextBlock,
|
|
3385
|
+
locale: body.locale
|
|
3386
|
+
});
|
|
3387
|
+
if (decomposition.steps.length > 1) {
|
|
3388
|
+
const chain = {
|
|
3389
|
+
id: randomUUID(),
|
|
3390
|
+
steps: decomposition.steps,
|
|
3391
|
+
stepLabels: decomposition.labels,
|
|
3392
|
+
currentStep: 0,
|
|
3393
|
+
totalSteps: decomposition.steps.length,
|
|
3394
|
+
originalMessage: plannerMessage,
|
|
3395
|
+
effectiveSlug,
|
|
3396
|
+
siteContextBlock
|
|
3397
|
+
};
|
|
3398
|
+
continuationChainBySession.set(body.session, chain);
|
|
3399
|
+
// Execute step 0 through normal planner
|
|
3400
|
+
chain.currentStep = 1;
|
|
3401
|
+
const stepResult = await runChatPipeline(ctx, {
|
|
3402
|
+
...body,
|
|
3403
|
+
message: decomposition.steps[0],
|
|
3404
|
+
executionMode: "auto"
|
|
3405
|
+
}, options);
|
|
3406
|
+
// Attach continuation info for remaining steps
|
|
3407
|
+
if (stepResult.code === 200 && "status" in stepResult.payload && stepResult.payload.status !== "error") {
|
|
3408
|
+
const payload = stepResult.payload;
|
|
3409
|
+
payload.continuation = {
|
|
3410
|
+
chainId: chain.id,
|
|
3411
|
+
currentStep: 2, // 1-indexed for display
|
|
3412
|
+
totalSteps: chain.totalSteps,
|
|
3413
|
+
nextStepLabel: chain.stepLabels[1]
|
|
3414
|
+
};
|
|
3415
|
+
}
|
|
3416
|
+
return stepResult;
|
|
3417
|
+
}
|
|
3418
|
+
// If 1 step, fall through to normal flow
|
|
3419
|
+
}
|
|
3420
|
+
catch (decompositionError) {
|
|
3421
|
+
if (isCancelError(decompositionError))
|
|
3422
|
+
throw decompositionError;
|
|
3423
|
+
ctx.log.warn({ err: toErrorDetail(decompositionError), event: "decomposition_failed" }, "Request decomposition failed, falling back to normal planner");
|
|
3424
|
+
// Fall through to normal flow
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
// Guard: if the planner returned content_answer for a message that clearly
|
|
3428
|
+
// requests an edit, discard the plan so the retry loop runs with the
|
|
3429
|
+
// original (non-downgraded) model. This prevents the fast model from
|
|
3430
|
+
// hallucinating "already set" when the user explicitly asks to update text.
|
|
3431
|
+
if (initialPlan?.intent === "content_answer" &&
|
|
3432
|
+
/\b(update|change|set|replace|make|rewrite|edit|rename|translate|fix)\b/i.test(plannerMessage)) {
|
|
3433
|
+
ctx.log.info({ event: "content_answer_rejected_for_edit", model: modelUsed }, "Rejected content_answer for an edit request — retrying with full model");
|
|
3434
|
+
initialPlan = null;
|
|
3435
|
+
}
|
|
3436
|
+
markPlanningStart();
|
|
3437
|
+
// Streamed per-op apply: apply ops as they stream from the LLM, so the user
|
|
3438
|
+
// sees changes at ~800ms intervals instead of waiting for the full plan.
|
|
3439
|
+
const streamedPerOpApplyEnabled = incrementalApplyEnabled &&
|
|
3440
|
+
incrementalPlanStreamEnabled &&
|
|
3441
|
+
!/^(0|false|no|off)$/i.test((process.env.CHAT_STREAMED_OP_APPLY ?? "1").trim()) &&
|
|
3442
|
+
Boolean(options?.onOpApplied);
|
|
3443
|
+
const streamApplyState = {
|
|
3444
|
+
appliedCount: 0,
|
|
3445
|
+
failedAtIndex: null,
|
|
3446
|
+
hasStructuralOps: false,
|
|
3447
|
+
/** Snapshots per slug — `null` means the page didn't exist before streaming (needs removal on rollback). */
|
|
3448
|
+
rollbackSnapshots: new Map()
|
|
3449
|
+
};
|
|
3450
|
+
// Skip the planning loop if the parallel race already produced an initialPlan
|
|
3451
|
+
for (let attempt = initialPlan ? maxPlanningAttempts + 1 : 1; attempt <= maxPlanningAttempts; attempt += 1) {
|
|
3452
|
+
throwIfCanceled(options?.signal);
|
|
3453
|
+
planningAttempts = attempt;
|
|
3454
|
+
try {
|
|
3455
|
+
if (attempt === 1)
|
|
3456
|
+
emitStatusTone("thinking");
|
|
3457
|
+
else
|
|
3458
|
+
emitStatus(`Retrying plan generation (${attempt}/${maxPlanningAttempts})...`);
|
|
3459
|
+
const result = await raceCancel(generatePlanImpl({
|
|
3460
|
+
message: plannerMessage,
|
|
3461
|
+
slug: effectiveSlug,
|
|
3462
|
+
currentPage: current,
|
|
3463
|
+
contextPack: plannerContext,
|
|
3464
|
+
model: modelUsed,
|
|
3465
|
+
locale: body.locale,
|
|
3466
|
+
imageSourceChoiceOpen,
|
|
3467
|
+
history: isLightweightEdit ? [] : plannerChatHistory,
|
|
3468
|
+
siteContextBlock: isLightweightEdit ? undefined : siteContextBlock,
|
|
3469
|
+
toolRuntime: supportsNativeTools && !isLightweightEdit ? ctx.toolRuntime : undefined,
|
|
3470
|
+
toolCallContext: supportsNativeTools && !isLightweightEdit
|
|
3471
|
+
? {
|
|
3472
|
+
siteId: body.siteId ?? "default",
|
|
3473
|
+
sessionId: body.session ?? "dev",
|
|
3474
|
+
traceId: chatRequestId,
|
|
3475
|
+
gdriveFolderId
|
|
3476
|
+
}
|
|
3477
|
+
: undefined,
|
|
3478
|
+
onStatusUpdate: options?.onStatusUpdate,
|
|
3479
|
+
onImageProgress: options?.onImageProgress,
|
|
3480
|
+
onToolExecution: supportsNativeTools && !isLightweightEdit
|
|
3481
|
+
? (event) => {
|
|
3482
|
+
if (event.ok && !event.deferred && event.toolName === "unsplash.search")
|
|
3483
|
+
usedNativeUnsplashTool = true;
|
|
3484
|
+
if (event.ok && !event.deferred && event.toolName === "image.generate")
|
|
3485
|
+
usedNativeImageTool = true;
|
|
3486
|
+
ctx.chatTelemetry.push({
|
|
3487
|
+
id: chatRequestId,
|
|
3488
|
+
at: new Date().toISOString(),
|
|
3489
|
+
phase: "tool_call",
|
|
3490
|
+
session: body.session ?? "dev",
|
|
3491
|
+
requestedSlug,
|
|
3492
|
+
effectiveSlug,
|
|
3493
|
+
plannerSource,
|
|
3494
|
+
modelKey,
|
|
3495
|
+
modelUsed,
|
|
3496
|
+
promptHash,
|
|
3497
|
+
promptExcerpt,
|
|
3498
|
+
promptLength: plannerMessage.length,
|
|
3499
|
+
toolName: event.toolName,
|
|
3500
|
+
toolOk: event.ok,
|
|
3501
|
+
toolLatencyMs: event.latencyMs,
|
|
3502
|
+
toolAttempts: event.attempts,
|
|
3503
|
+
toolErrorCode: event.errorCode,
|
|
3504
|
+
correlationId: event.traceId,
|
|
3505
|
+
outcome: event.ok ? "tool_ok" : "tool_error",
|
|
3506
|
+
...timingFields()
|
|
3507
|
+
});
|
|
3508
|
+
}
|
|
3509
|
+
: undefined,
|
|
3510
|
+
log: ctx.log,
|
|
3511
|
+
onToken: onPlanningToken,
|
|
3512
|
+
onThinking: options?.onThinking,
|
|
3513
|
+
thinking: thinkingConfig,
|
|
3514
|
+
onFieldDraft: options?.onFieldDraft,
|
|
3515
|
+
onSummaryChunk: options?.onSummaryChunk,
|
|
3516
|
+
onChangeLogEntry: options?.onChangeLogEntry,
|
|
3517
|
+
onPlannedOp: incrementalPlanStreamEnabled
|
|
3518
|
+
? async (op, index) => {
|
|
3519
|
+
markFirstStructuredProgress();
|
|
3520
|
+
options?.onPlannedOp?.({ op, index });
|
|
3521
|
+
// Streamed per-op apply: validate and apply each op as it streams
|
|
3522
|
+
// in from the LLM, so the user sees changes progressively instead
|
|
3523
|
+
// of waiting for the entire plan.
|
|
3524
|
+
if (streamedPerOpApplyEnabled && options?.onOpApplied) {
|
|
3525
|
+
try {
|
|
3526
|
+
// Snapshot the target page before we touch it (once per slug)
|
|
3527
|
+
const opSlug = ("pageSlug" in op && typeof op.pageSlug === "string") ? op.pageSlug : effectiveSlug;
|
|
3528
|
+
if (!streamApplyState.rollbackSnapshots.has(opSlug)) {
|
|
3529
|
+
const existingPage = getPage(body.session, opSlug);
|
|
3530
|
+
streamApplyState.rollbackSnapshots.set(opSlug, existingPage ? structuredClone(existingPage) : null);
|
|
3531
|
+
}
|
|
3532
|
+
// Skip ops that need the full plan context before applying:
|
|
3533
|
+
// - Page-level structural ops (create_page, rename_page, etc.) can't
|
|
3534
|
+
// be applied piecemeal because they affect navigation/history state.
|
|
3535
|
+
// - remove_block requires the complete op list so the tier-1
|
|
3536
|
+
// destructive-action gate (bulk deletes, majority-wipe) can evaluate
|
|
3537
|
+
// the full scope before any blocks are deleted from the preview.
|
|
3538
|
+
// Applying remove_block mid-stream would erase blocks before the gate
|
|
3539
|
+
// runs, then surface an "Approve" card for already-deleted content.
|
|
3540
|
+
const isStructural = op.op === "create_page" || op.op === "rename_page" || op.op === "remove_page" || op.op === "move_page" || op.op === "duplicate_page" || op.op === "remove_block";
|
|
3541
|
+
if (isStructural) {
|
|
3542
|
+
streamApplyState.hasStructuralOps = true;
|
|
3543
|
+
return;
|
|
3544
|
+
}
|
|
3545
|
+
// Once a page-affecting structural op (create/duplicate/rename/remove/move
|
|
3546
|
+
// on a page) has been deferred earlier in this stream, defer ALL subsequent
|
|
3547
|
+
// ops too. Downstream update_props/add_block ops may target a page slug that
|
|
3548
|
+
// hasn't materialized yet (e.g. update_props on /season-recipes after a
|
|
3549
|
+
// deferred duplicate_page /recipes → /season-recipes); applying them
|
|
3550
|
+
// mid-stream would throw "Page not found", trigger a costly rollback +
|
|
3551
|
+
// full re-apply + repair loop, and frequently leave only the structural op
|
|
3552
|
+
// surviving the LLM's repair attempt.
|
|
3553
|
+
if (streamApplyState.hasStructuralOps) {
|
|
3554
|
+
return;
|
|
3555
|
+
}
|
|
3556
|
+
validateOperations([op]);
|
|
3557
|
+
// allowNoEffectiveChange: an op that matches existing values (e.g. a
|
|
3558
|
+
// block already in the target translation language) is a benign skip,
|
|
3559
|
+
// not a failure. Without it the engine throws no_effective_change, which
|
|
3560
|
+
// would set failedAtIndex and force a full rollback + re-apply of the
|
|
3561
|
+
// whole plan — discarding the blocks that genuinely changed.
|
|
3562
|
+
const stepResult = await applyOpsAtomically(body.session, [op], { componentsManifest, allowNoEffectiveChange: true });
|
|
3563
|
+
if (stepResult.appliedCount === 0 || stepResult.skippedOps.length > 0) {
|
|
3564
|
+
options?.onOpSkipped?.({
|
|
3565
|
+
index,
|
|
3566
|
+
total: index, // total unknown during streaming
|
|
3567
|
+
op,
|
|
3568
|
+
reason: stepResult.skippedOps[0]?.reason ?? "unchanged_value"
|
|
3569
|
+
});
|
|
3570
|
+
return;
|
|
3571
|
+
}
|
|
3572
|
+
streamApplyState.appliedCount += 1;
|
|
3573
|
+
if (firstApplyMs === null) {
|
|
3574
|
+
firstApplyMs = Date.now() - pipelineStartedAtMs;
|
|
3575
|
+
stageTimeline.push({ stage: "first_op_applied", atMs: firstApplyMs });
|
|
3576
|
+
}
|
|
3577
|
+
const previewVersion = bumpVersion(body.session);
|
|
3578
|
+
options.onOpApplied({
|
|
3579
|
+
index,
|
|
3580
|
+
total: index, // total unknown during streaming
|
|
3581
|
+
op,
|
|
3582
|
+
previewVersion,
|
|
3583
|
+
focusBlockId: pickFocusBlockId([op])
|
|
3584
|
+
});
|
|
3585
|
+
}
|
|
3586
|
+
catch (streamApplyErr) {
|
|
3587
|
+
// If an op fails, mark for full rollback+re-apply after streaming
|
|
3588
|
+
streamApplyState.failedAtIndex = index;
|
|
3589
|
+
ctx.log.warn({ event: "streamed_op_apply_failed", chatRequestId, opIndex: index, error: toErrorDetail(streamApplyErr) }, "Streamed op apply failed — will re-apply full plan after streaming");
|
|
3590
|
+
}
|
|
3591
|
+
}
|
|
3592
|
+
}
|
|
3593
|
+
: undefined,
|
|
3594
|
+
componentsManifest,
|
|
3595
|
+
lightweight: shouldPreferFastModelForMessage(plannerMessage),
|
|
3596
|
+
signal: options?.signal
|
|
3597
|
+
}), options?.signal);
|
|
3598
|
+
initialPlan = result.plan;
|
|
3599
|
+
planUsage = result.usage;
|
|
3600
|
+
if (result.schemaContext) {
|
|
3601
|
+
plannerContextTelemetryFields.contractMode = result.schemaContext.contractMode;
|
|
3602
|
+
plannerContextTelemetryFields.contractBytes = result.schemaContext.contractBytes;
|
|
3603
|
+
plannerContextTelemetryFields.contractBlockCount = result.schemaContext.contractBlockCount;
|
|
3604
|
+
plannerContextTelemetryFields.strictJsonEnabled = result.schemaContext.strictJsonEnabled;
|
|
3605
|
+
}
|
|
3606
|
+
markPlanningFinish();
|
|
3607
|
+
emitStatusTone("validating");
|
|
3608
|
+
break;
|
|
3609
|
+
}
|
|
3610
|
+
catch (error) {
|
|
3611
|
+
if (isCancelError(error))
|
|
3612
|
+
throw error;
|
|
3613
|
+
const reason = toErrorDetail(error);
|
|
3614
|
+
// API-level errors (rate limits, auth, quota) — surface immediately, don't retry
|
|
3615
|
+
const errorMsg = error instanceof Error ? error.message : "";
|
|
3616
|
+
const isApiError = /usage limit|rate limit|quota|unauthorized|billing/i.test(errorMsg) || /\b40[0-13]\b/.test(errorMsg);
|
|
3617
|
+
if (isApiError) {
|
|
3618
|
+
markPlanningFinish();
|
|
3619
|
+
const providerLabel = plannerSource === "anthropic" ? "Anthropic" : plannerSource === "openai" ? "OpenAI" : plannerSource === "gemini" ? "Google Gemini" : "The AI provider";
|
|
3620
|
+
const consoleUrl = plannerSource === "anthropic" ? "https://console.anthropic.com/settings/billing" : plannerSource === "openai" ? "https://platform.openai.com/usage" : plannerSource === "gemini" ? "https://console.cloud.google.com/billing" : "";
|
|
3621
|
+
const consoleLink = consoleUrl ? ` [Check your ${providerLabel} console](${consoleUrl}).` : "";
|
|
3622
|
+
const userSummary = /usage limit/i.test(errorMsg)
|
|
3623
|
+
? `${providerLabel} API usage limit has been reached.${consoleLink} You can also switch to a different provider.`
|
|
3624
|
+
: /rate limit/i.test(errorMsg)
|
|
3625
|
+
? `${providerLabel} is rate-limiting requests. Please wait a moment and try again.`
|
|
3626
|
+
: /unauthorized|api.key|authentication/i.test(errorMsg)
|
|
3627
|
+
? `${providerLabel} API key is invalid or expired.${consoleLink} Please check your API key configuration.`
|
|
3628
|
+
: `${providerLabel} error: ${errorMsg.slice(0, 200)}`;
|
|
3629
|
+
ctx.log.error({ event: "api_error", model: modelUsed, error: errorMsg.slice(0, 300) }, userSummary);
|
|
3630
|
+
return {
|
|
3631
|
+
code: 503,
|
|
3632
|
+
payload: withDebugPayload({
|
|
3633
|
+
status: "error",
|
|
3634
|
+
summary: userSummary,
|
|
3635
|
+
changes: [],
|
|
3636
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
3637
|
+
plannerSource,
|
|
3638
|
+
modelUsed,
|
|
3639
|
+
modelKey
|
|
3640
|
+
}, { outcome: "api_error", reason: errorMsg.slice(0, 300) })
|
|
3641
|
+
};
|
|
3642
|
+
}
|
|
3643
|
+
const reasonCategory = isPlannerOutputError(error) ? error.reasonCategory : classifyGuardrailError(reason);
|
|
3644
|
+
ctx.log.warn({ event: "plan_attempt_failed", attempt, model: modelUsed, reason: reason.slice(0, 300) }, `Planning attempt ${attempt} failed`);
|
|
3645
|
+
ctx.chatTelemetry.push({
|
|
3646
|
+
id: chatRequestId,
|
|
3647
|
+
at: new Date().toISOString(),
|
|
3648
|
+
phase: "plan_attempt_failed",
|
|
3649
|
+
session: body.session,
|
|
3650
|
+
requestedSlug,
|
|
3651
|
+
effectiveSlug,
|
|
3652
|
+
plannerSource,
|
|
3653
|
+
modelKey,
|
|
3654
|
+
modelUsed,
|
|
3655
|
+
promptHash,
|
|
3656
|
+
promptExcerpt,
|
|
3657
|
+
promptLength: plannerMessage.length,
|
|
3658
|
+
outcome: `attempt_${attempt}_failed`,
|
|
3659
|
+
reason: reason.slice(0, 300),
|
|
3660
|
+
reasonCategory,
|
|
3661
|
+
plannerRefusal: reasonCategory === "planner_refusal" ? true : undefined,
|
|
3662
|
+
plannerIncomplete: reasonCategory === "incomplete_output" ? true : undefined,
|
|
3663
|
+
...timingFields()
|
|
3664
|
+
});
|
|
3665
|
+
if (isPlannerOutputError(error) && !error.retryable) {
|
|
3666
|
+
markPlanningFinish();
|
|
3667
|
+
ctx.chatTelemetry.push({
|
|
3668
|
+
id: chatRequestId,
|
|
3669
|
+
at: new Date().toISOString(),
|
|
3670
|
+
phase: "result",
|
|
3671
|
+
session: body.session,
|
|
3672
|
+
requestedSlug,
|
|
3673
|
+
effectiveSlug,
|
|
3674
|
+
plannerSource,
|
|
3675
|
+
modelKey,
|
|
3676
|
+
modelUsed,
|
|
3677
|
+
promptHash,
|
|
3678
|
+
promptExcerpt,
|
|
3679
|
+
promptLength: plannerMessage.length,
|
|
3680
|
+
outcome: reasonCategory === "planner_refusal" ? "planning_refusal" : "planning_incomplete",
|
|
3681
|
+
reason: reason.slice(0, 300),
|
|
3682
|
+
reasonCategory,
|
|
3683
|
+
plannerRefusal: reasonCategory === "planner_refusal" ? true : undefined,
|
|
3684
|
+
plannerIncomplete: reasonCategory === "incomplete_output" ? true : undefined,
|
|
3685
|
+
plannerTier: "full_llm",
|
|
3686
|
+
...plannerContextTelemetryFields,
|
|
3687
|
+
...timingFields()
|
|
3688
|
+
});
|
|
3689
|
+
if (reasonCategory === "planner_refusal") {
|
|
3690
|
+
return {
|
|
3691
|
+
code: 200,
|
|
3692
|
+
payload: withDebugPayload({
|
|
3693
|
+
status: "needs_clarification",
|
|
3694
|
+
summary: "I can’t help with that request as written. Please rephrase and I can try a safe alternative.",
|
|
3695
|
+
changes: [],
|
|
3696
|
+
suggestions: [
|
|
3697
|
+
"Describe the same goal in safer, neutral terms.",
|
|
3698
|
+
"Limit the request to website content edits only.",
|
|
3699
|
+
"Ask for a high-level rewrite without sensitive details."
|
|
3700
|
+
],
|
|
3701
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
3702
|
+
plannerSource,
|
|
3703
|
+
modelUsed,
|
|
3704
|
+
modelKey
|
|
3705
|
+
}, { outcome: "planning_refusal", reasonCategory })
|
|
3706
|
+
};
|
|
3707
|
+
}
|
|
3708
|
+
return {
|
|
3709
|
+
code: 500,
|
|
3710
|
+
payload: withDebugPayload({
|
|
3711
|
+
status: "error",
|
|
3712
|
+
summary: "I couldn’t finish generating a plan. Please try again.",
|
|
3713
|
+
changes: [],
|
|
3714
|
+
validationErrors: [reason.slice(0, 300)],
|
|
3715
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
3716
|
+
plannerSource,
|
|
3717
|
+
modelUsed,
|
|
3718
|
+
modelKey
|
|
3719
|
+
}, { outcome: "planning_incomplete", reasonCategory, reason: reason.slice(0, 300), plannerTier: "full_llm" })
|
|
3720
|
+
};
|
|
3721
|
+
}
|
|
3722
|
+
planningErrors.push(`Attempt ${attempt} planning failed: ${reason}`);
|
|
3723
|
+
if (attempt === maxPlanningAttempts) {
|
|
3724
|
+
markPlanningFinish();
|
|
3725
|
+
ctx.chatTelemetry.push({
|
|
3726
|
+
id: chatRequestId,
|
|
3727
|
+
at: new Date().toISOString(),
|
|
3728
|
+
phase: "result",
|
|
3729
|
+
session: body.session,
|
|
3730
|
+
requestedSlug,
|
|
3731
|
+
effectiveSlug,
|
|
3732
|
+
plannerSource,
|
|
3733
|
+
modelKey,
|
|
3734
|
+
modelUsed,
|
|
3735
|
+
promptHash,
|
|
3736
|
+
promptExcerpt,
|
|
3737
|
+
promptLength: plannerMessage.length,
|
|
3738
|
+
outcome: "planning_exhausted",
|
|
3739
|
+
reason: reason.slice(0, 300),
|
|
3740
|
+
reasonCategory,
|
|
3741
|
+
plannerRefusal: reasonCategory === "planner_refusal" ? true : undefined,
|
|
3742
|
+
plannerIncomplete: reasonCategory === "incomplete_output" ? true : undefined,
|
|
3743
|
+
plannerTier: "full_llm",
|
|
3744
|
+
...plannerContextTelemetryFields,
|
|
3745
|
+
...timingFields()
|
|
3746
|
+
});
|
|
3747
|
+
return {
|
|
3748
|
+
code: 500,
|
|
3749
|
+
payload: withDebugPayload({
|
|
3750
|
+
status: "error",
|
|
3751
|
+
summary: "Could not generate an edit plan.",
|
|
3752
|
+
changes: [reason.slice(0, 300)],
|
|
3753
|
+
validationErrors: planningErrors.slice(-3),
|
|
3754
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
3755
|
+
plannerSource,
|
|
3756
|
+
modelUsed,
|
|
3757
|
+
modelKey
|
|
3758
|
+
}, { outcome: "planning_exhausted", reasonCategory, reason: reason.slice(0, 300), plannerTier: "full_llm" })
|
|
3759
|
+
};
|
|
3760
|
+
}
|
|
3761
|
+
}
|
|
3762
|
+
}
|
|
3763
|
+
if (!initialPlan) {
|
|
3764
|
+
markPlanningFinish();
|
|
3765
|
+
ctx.chatTelemetry.push({
|
|
3766
|
+
id: chatRequestId,
|
|
3767
|
+
at: new Date().toISOString(),
|
|
3768
|
+
phase: "result",
|
|
3769
|
+
session: body.session,
|
|
3770
|
+
requestedSlug,
|
|
3771
|
+
effectiveSlug,
|
|
3772
|
+
plannerSource,
|
|
3773
|
+
modelKey,
|
|
3774
|
+
modelUsed,
|
|
3775
|
+
promptHash,
|
|
3776
|
+
promptExcerpt,
|
|
3777
|
+
promptLength: plannerMessage.length,
|
|
3778
|
+
outcome: "planning_missing",
|
|
3779
|
+
plannerTier: "full_llm",
|
|
3780
|
+
...plannerContextTelemetryFields,
|
|
3781
|
+
...timingFields()
|
|
3782
|
+
});
|
|
3783
|
+
return {
|
|
3784
|
+
code: 500,
|
|
3785
|
+
payload: withDebugPayload({
|
|
3786
|
+
status: "error",
|
|
3787
|
+
summary: "Could not generate an edit plan.",
|
|
3788
|
+
changes: [],
|
|
3789
|
+
validationErrors: planningErrors.slice(-3),
|
|
3790
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
3791
|
+
plannerSource,
|
|
3792
|
+
modelUsed,
|
|
3793
|
+
modelKey
|
|
3794
|
+
}, { outcome: "planning_missing", plannerTier: "full_llm" })
|
|
3795
|
+
};
|
|
3796
|
+
}
|
|
3797
|
+
// If streamed per-op apply already applied all ops successfully and no structural
|
|
3798
|
+
// ops are pending, skip the normal apply step in respondFromPlan.
|
|
3799
|
+
const streamedApplyComplete = streamedPerOpApplyEnabled &&
|
|
3800
|
+
streamApplyState.appliedCount > 0 &&
|
|
3801
|
+
streamApplyState.failedAtIndex === null &&
|
|
3802
|
+
!streamApplyState.hasStructuralOps;
|
|
3803
|
+
// If streamed apply partially failed, roll back and let respondFromPlan re-apply everything
|
|
3804
|
+
if (streamedPerOpApplyEnabled && streamApplyState.failedAtIndex !== null && streamApplyState.rollbackSnapshots.size > 0) {
|
|
3805
|
+
const rolledBackSlugs = [];
|
|
3806
|
+
for (const [slug, snapshot] of streamApplyState.rollbackSnapshots) {
|
|
3807
|
+
if (snapshot) {
|
|
3808
|
+
setPage(body.session, { ...snapshot, slug });
|
|
3809
|
+
}
|
|
3810
|
+
else {
|
|
3811
|
+
removePage(body.session, slug);
|
|
3812
|
+
}
|
|
3813
|
+
rolledBackSlugs.push(slug);
|
|
3814
|
+
}
|
|
3815
|
+
ctx.log.warn({ event: "streamed_op_rollback", chatRequestId, appliedCount: streamApplyState.appliedCount, failedAtIndex: streamApplyState.failedAtIndex, rolledBackSlugs }, `Rolled back ${rolledBackSlugs.length} page(s) after streamed op failure at index ${streamApplyState.failedAtIndex}`);
|
|
3816
|
+
}
|
|
3817
|
+
const initialOutcome = await respondFromPlan(initialPlan, plannerSource, applyMode, streamedApplyComplete
|
|
3818
|
+
? { preApplied: true, undoSnapshot: streamApplyState.rollbackSnapshots.get(effectiveSlug) ?? undefined }
|
|
3819
|
+
: undefined, "full_llm");
|
|
3820
|
+
if (initialOutcome.done)
|
|
3821
|
+
return initialOutcome.response;
|
|
3822
|
+
if (!isDeterministicRepairEligible(initialOutcome.reason)) {
|
|
3823
|
+
return guardrailFailureResponse({ reason: initialOutcome.reason, source: plannerSource });
|
|
3824
|
+
}
|
|
3825
|
+
let repairedPlan;
|
|
3826
|
+
try {
|
|
3827
|
+
throwIfCanceled(options?.signal);
|
|
3828
|
+
emitStatusTone("repairing");
|
|
3829
|
+
ctx.chatTelemetry.push({
|
|
3830
|
+
id: chatRequestId,
|
|
3831
|
+
at: new Date().toISOString(),
|
|
3832
|
+
phase: "repair_attempt",
|
|
3833
|
+
session: body.session,
|
|
3834
|
+
requestedSlug,
|
|
3835
|
+
effectiveSlug,
|
|
3836
|
+
plannerSource,
|
|
3837
|
+
modelKey,
|
|
3838
|
+
modelUsed,
|
|
3839
|
+
promptHash,
|
|
3840
|
+
promptExcerpt,
|
|
3841
|
+
promptLength: plannerMessage.length,
|
|
3842
|
+
outcome: "repair_started",
|
|
3843
|
+
reason: initialOutcome.reason.slice(0, 300),
|
|
3844
|
+
reasonCategory: classifyGuardrailError(initialOutcome.reason),
|
|
3845
|
+
...timingFields()
|
|
3846
|
+
});
|
|
3847
|
+
planningAttempts += 1;
|
|
3848
|
+
plannerContextTelemetryFields.schemaRetryUsed = true;
|
|
3849
|
+
const repairFeedback = /full-page translation coverage/i.test(initialOutcome.reason)
|
|
3850
|
+
? `${initialOutcome.reason}. Repair for translation completeness: include missing translated text fields for list children across all affected blocks. Preserve links/hrefs unchanged.`
|
|
3851
|
+
: /explicit cta target coverage/i.test(initialOutcome.reason)
|
|
3852
|
+
? `${initialOutcome.reason}. Repair for multi-target CTA completeness: update both hero and footer CTA text targets requested by the user, keep all CTA links/hrefs unchanged, and preserve user constraints such as punctuation bans.`
|
|
3853
|
+
: buildDeterministicRepairFeedback(initialOutcome.reason);
|
|
3854
|
+
const repairResult = await raceCancel(generatePlanImpl({
|
|
3855
|
+
message: plannerMessage,
|
|
3856
|
+
slug: effectiveSlug,
|
|
3857
|
+
currentPage: current,
|
|
3858
|
+
contextPack: plannerContext,
|
|
3859
|
+
model: modelUsed,
|
|
3860
|
+
locale: body.locale,
|
|
3861
|
+
imageSourceChoiceOpen,
|
|
3862
|
+
history: plannerChatHistory,
|
|
3863
|
+
feedback: repairFeedback,
|
|
3864
|
+
forceFullSchemaContracts: true,
|
|
3865
|
+
siteContextBlock,
|
|
3866
|
+
onToken: onPlanningToken,
|
|
3867
|
+
onThinking: options?.onThinking,
|
|
3868
|
+
thinking: thinkingConfig,
|
|
3869
|
+
onFieldDraft: options?.onFieldDraft,
|
|
3870
|
+
onSummaryChunk: options?.onSummaryChunk,
|
|
3871
|
+
onChangeLogEntry: options?.onChangeLogEntry,
|
|
3872
|
+
onPlannedOp: incrementalPlanStreamEnabled
|
|
3873
|
+
? (op, index) => {
|
|
3874
|
+
markFirstStructuredProgress();
|
|
3875
|
+
options?.onPlannedOp?.({ op, index });
|
|
3876
|
+
}
|
|
3877
|
+
: undefined,
|
|
3878
|
+
signal: options?.signal
|
|
3879
|
+
}), options?.signal);
|
|
3880
|
+
repairedPlan = repairResult.plan;
|
|
3881
|
+
planUsage = repairResult.usage;
|
|
3882
|
+
if (repairResult.schemaContext) {
|
|
3883
|
+
plannerContextTelemetryFields.contractMode = repairResult.schemaContext.contractMode;
|
|
3884
|
+
plannerContextTelemetryFields.contractBytes = repairResult.schemaContext.contractBytes;
|
|
3885
|
+
plannerContextTelemetryFields.contractBlockCount = repairResult.schemaContext.contractBlockCount;
|
|
3886
|
+
plannerContextTelemetryFields.strictJsonEnabled = repairResult.schemaContext.strictJsonEnabled;
|
|
3887
|
+
}
|
|
3888
|
+
markPlanningFinish();
|
|
3889
|
+
ctx.chatTelemetry.push({
|
|
3890
|
+
id: chatRequestId,
|
|
3891
|
+
at: new Date().toISOString(),
|
|
3892
|
+
phase: "repair_generated",
|
|
3893
|
+
session: body.session,
|
|
3894
|
+
requestedSlug,
|
|
3895
|
+
effectiveSlug,
|
|
3896
|
+
plannerSource,
|
|
3897
|
+
modelKey,
|
|
3898
|
+
modelUsed,
|
|
3899
|
+
promptHash,
|
|
3900
|
+
promptExcerpt,
|
|
3901
|
+
promptLength: plannerMessage.length,
|
|
3902
|
+
outcome: "repair_plan_generated",
|
|
3903
|
+
intent: repairedPlan.intent,
|
|
3904
|
+
opCount: repairedPlan.ops.length,
|
|
3905
|
+
opTypes: repairedPlan.ops.map((op) => op.op),
|
|
3906
|
+
...timingFields()
|
|
3907
|
+
});
|
|
3908
|
+
}
|
|
3909
|
+
catch (error) {
|
|
3910
|
+
if (isCancelError(error))
|
|
3911
|
+
throw error;
|
|
3912
|
+
markPlanningFinish();
|
|
3913
|
+
const reason = toErrorDetail(error);
|
|
3914
|
+
ctx.chatTelemetry.push({
|
|
3915
|
+
id: chatRequestId,
|
|
3916
|
+
at: new Date().toISOString(),
|
|
3917
|
+
phase: "result",
|
|
3918
|
+
session: body.session,
|
|
3919
|
+
requestedSlug,
|
|
3920
|
+
effectiveSlug,
|
|
3921
|
+
plannerSource,
|
|
3922
|
+
modelKey,
|
|
3923
|
+
modelUsed,
|
|
3924
|
+
promptHash,
|
|
3925
|
+
promptExcerpt,
|
|
3926
|
+
promptLength: plannerMessage.length,
|
|
3927
|
+
outcome: "repair_failed",
|
|
3928
|
+
reason: reason.slice(0, 300),
|
|
3929
|
+
reasonCategory: classifyGuardrailError(reason),
|
|
3930
|
+
...plannerContextTelemetryFields,
|
|
3931
|
+
...timingFields()
|
|
3932
|
+
});
|
|
3933
|
+
return {
|
|
3934
|
+
code: 400,
|
|
3935
|
+
payload: withDebugPayload({
|
|
3936
|
+
status: "validation_error",
|
|
3937
|
+
summary: "I could not apply that change safely.",
|
|
3938
|
+
changes: [],
|
|
3939
|
+
validationErrors: [formatValidationError(reason)],
|
|
3940
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
3941
|
+
plannerSource,
|
|
3942
|
+
modelUsed,
|
|
3943
|
+
modelKey
|
|
3944
|
+
}, { outcome: "repair_failed", reasonCategory: classifyGuardrailError(reason), reason: reason.slice(0, 300), plannerTier: "full_llm" })
|
|
3945
|
+
};
|
|
3946
|
+
}
|
|
3947
|
+
const repairedOutcome = await respondFromPlan(repairedPlan, plannerSource, applyMode);
|
|
3948
|
+
if (repairedOutcome.done)
|
|
3949
|
+
return repairedOutcome.response;
|
|
3950
|
+
const repairedReason = repairedOutcome.reason;
|
|
3951
|
+
const ctaCoverageRepairFailed = /explicit cta target coverage/i.test(repairedReason);
|
|
3952
|
+
ctx.chatTelemetry.push({
|
|
3953
|
+
id: chatRequestId,
|
|
3954
|
+
at: new Date().toISOString(),
|
|
3955
|
+
phase: "result",
|
|
3956
|
+
session: body.session,
|
|
3957
|
+
requestedSlug,
|
|
3958
|
+
effectiveSlug,
|
|
3959
|
+
plannerSource,
|
|
3960
|
+
modelKey,
|
|
3961
|
+
modelUsed,
|
|
3962
|
+
promptHash,
|
|
3963
|
+
promptExcerpt,
|
|
3964
|
+
promptLength: plannerMessage.length,
|
|
3965
|
+
outcome: "repair_failed",
|
|
3966
|
+
reason: repairedReason.slice(0, 300),
|
|
3967
|
+
reasonCategory: "schema_violation",
|
|
3968
|
+
...plannerContextTelemetryFields,
|
|
3969
|
+
...timingFields()
|
|
3970
|
+
});
|
|
3971
|
+
if (ctaCoverageRepairFailed) {
|
|
3972
|
+
return {
|
|
3973
|
+
code: 200,
|
|
3974
|
+
payload: withDebugPayload({
|
|
3975
|
+
status: "needs_clarification",
|
|
3976
|
+
summary: "Please confirm the exact CTA wording for both the hero CTA and footer CTA so I can apply both safely.",
|
|
3977
|
+
changes: [],
|
|
3978
|
+
mentionedSlugs: [effectiveSlug],
|
|
3979
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
3980
|
+
plannerSource,
|
|
3981
|
+
modelUsed,
|
|
3982
|
+
modelKey
|
|
3983
|
+
}, { outcome: "needs_clarification", reasonCategory: "schema_violation" })
|
|
3984
|
+
};
|
|
3985
|
+
}
|
|
3986
|
+
return {
|
|
3987
|
+
code: 400,
|
|
3988
|
+
payload: withDebugPayload({
|
|
3989
|
+
status: "validation_error",
|
|
3990
|
+
summary: "I could not apply that change safely.",
|
|
3991
|
+
changes: [],
|
|
3992
|
+
validationErrors: [formatValidationError(repairedReason)],
|
|
3993
|
+
previewVersion: versions.get(body.session) ?? 0,
|
|
3994
|
+
plannerSource,
|
|
3995
|
+
modelUsed,
|
|
3996
|
+
modelKey
|
|
3997
|
+
}, { outcome: "repair_failed", reasonCategory: "schema_violation", reason: repairedReason.slice(0, 300), plannerTier: "full_llm" })
|
|
3998
|
+
};
|
|
3999
|
+
}
|