diff --git a/seo_mode/seo_mode/app/src/App.tsx b/seo_mode/seo_mode/app/src/App.tsx index 3978324..ce285e7 100644 --- a/seo_mode/seo_mode/app/src/App.tsx +++ b/seo_mode/seo_mode/app/src/App.tsx @@ -54,6 +54,7 @@ import { ScatterChart as BklitScatterChart } from "@bklitui/charts/scatter-chart import { XAxis as BklitXAxis } from "@bklitui/charts/x-axis"; import { applyMaterializationAction, + buildPatchArtifact, approveKeywordMapDecisions, copyProject, deleteProject, @@ -65,6 +66,7 @@ import { fetchLatestKeywordMap, fetchLatestMaterialization, fetchLatestMarketEnrichment, + fetchLatestPatchArtifact, fetchLatestProjectOntology, fetchLatestRewriteDiff, fetchLatestRewritePlan, @@ -77,6 +79,7 @@ import { fetchLatestStrategySynthesis, fetchLatestYandexEvidence, fetchProjects, + generateVisualComposerVariants, getPreviewPageUrl, importBrowserFolder, openPageWorkspace, @@ -86,18 +89,22 @@ import { runMarketEnrichment, runProjectScan, runSemanticAnalysis, + runPatchDryRun, runYandexEvidence, runSerpInterpretation, runStrategyQualityReview, runStrategySynthesis, runSeoContextReview, runSeoModelTask, + savePageSemanticBlocks, + saveContentFieldDraft, savePageWorkspace, updateExternalServiceSettings, updateProjectOntologyApproval, updateProjectOntologyConfirmations, updateProjectPageSelection, type BrowserFolderFileInput, + type ContentFieldDraft, type ExternalServiceSettings, type ExternalServiceUpdateInput, type HealthResponse, @@ -107,6 +114,10 @@ import { type MaterializationContract, type MarketEnrichmentContract, type PageWorkspace, + type PatchArtifactContract, + type PatchDryRunContract, + type VisualComposerSeedInput, + type VisualComposerVariantsContract, type YandexAiStudioProbe, type ProjectScanSummary, type ProjectSummary, @@ -120,6 +131,7 @@ import { type SeoModelTaskRun, type SeoModelTaskType, type SeoNormalizationContract, + type SemanticBlockModel, type SerpInterpretationContract, type SemanticAnalysisRun, type SemanticExportFormat, @@ -160,7 +172,7 @@ const stageMeta = [ { title: "Рабочая зона страницы", sectionTitle: "Рабочая зона страниц", - description: "Открываем preview, секции страницы и draft-редактор без изменения исходного сайта." + description: "Открываем preview, секции страницы и patch-черновик без изменения исходников сайта." }, { title: "Контекст сайта", @@ -190,7 +202,7 @@ const stageMeta = [ { title: "Применение", sectionTitle: "Применение", - description: "Будущее безопасное применение правок с контролем конфликтов." + description: "Будущий запуск подтверждённого patch-run с контролем конфликтов." } ]; @@ -220,9 +232,9 @@ const stageDecisionFrames: Partial< result: "Страница, роль фразы, причина, доказательства и ограничения по данным." }, 7: { - gate: "Применение невозможно без зелёного вердикта проверки.", - question: "Можно ли применять будущие изменения?", - result: "Критичные блокеры, предупреждения, проверка смысла/стиля и готовность к применению." + gate: "Patch-run невозможен без зелёного вердикта проверки.", + question: "Можно ли запускать будущий патч?", + result: "Критичные блокеры, предупреждения, проверка смысла/стиля и готовность к patch-run." } }; @@ -360,6 +372,27 @@ function getReadablePathProductLabel(targetPath: string | null | undefined) { .join(" "); } +function normalizePortfolioMatchKey(value: string | null | undefined) { + const trimmedValue = value?.split(/[?#]/)[0]?.trim() ?? ""; + + if (!trimmedValue || /url не выбран|посадочная не выбрана/i.test(trimmedValue)) { + return ""; + } + + const withoutIndex = trimmedValue.replace(/\/index\.html?$/i, "/"); + const withoutTrailingSlash = withoutIndex !== "/" ? withoutIndex.replace(/\/+$/g, "") : withoutIndex; + + return withoutTrailingSlash.toLocaleLowerCase("ru-RU"); +} + +function addPortfolioMatchKey(keys: Set, value: string | null | undefined) { + const key = normalizePortfolioMatchKey(value); + + if (key) { + keys.add(key); + } +} + function formatStrategyDisplayText(value: string) { return value .replace(/\bbusiness-process automation\b/gi, "автоматизации бизнес-процессов") @@ -401,6 +434,24 @@ type SeoChartDatum = { tone?: SeoChartTone; }; +type VisualComposerConfig = { + id: string; + label: string; + semanticBlockOverridesByWorkspace?: Record; + sectionTextsByPageId?: Record; + sectionTextsByWorkspace?: Record; + source: "generated" | "system" | "user"; + savedAt: string | null; + summary?: string; +}; + +type VisualComposerMetric = { + detail: string; + label: string; + tone: SeoChartTone; + value: string; +}; + type SeoScatterPoint = { detail?: string; id: string; @@ -439,14 +490,24 @@ type StrategyKeywordDecisionRole = type StrategyForecastCtrModel = "base" | "optimistic" | "safe"; type StrategyForecastRiskMode = "aggressive" | "balanced" | "safe"; type StrategyDecisionLevel = "high" | "low" | "medium"; +type StrategyNextStageAction = "create_or_prepare_landing" | "rewrite_existing_landing" | "select_landing"; +type StrategyDemandEvidenceTier = "manual_review" | "serp_wordstat" | "wordstat_support"; type StrategyForecastSettings = { conversionRate: number; ctrModel: StrategyForecastCtrModel; horizonMonths: number; riskMode: StrategyForecastRiskMode; }; +type StrategyDemandEvidenceCompositionItem = { + demand: number; + detail: string; + label: string; + phrases: string[]; + tier: StrategyDemandEvidenceTier; +}; type StrategyKeywordDecisionRow = { clusterTitle: string; + evidenceStatus: KeywordMapContract["items"][number]["evidenceStatus"] | null; evidenceRefs: string[]; frequency: number | null; id: string; @@ -457,6 +518,7 @@ type StrategyKeywordDecisionRow = { role: StrategyKeywordDecisionRole; targetFit: SerpInterpretationContract["phraseInterpretations"][number]["targetFit"] | null; targetPath: string | null; + wordstatResultId: string | null; }; type StrategyDecisionScenario = { businessFit: StrategyDecisionLevel; @@ -514,6 +576,7 @@ type StrategyDecisionContract = { phrases: string[]; role: "differentiator" | "primary" | "support"; }>; + demandEvidenceComposition: StrategyDemandEvidenceCompositionItem[]; differentiatorClusters: string[]; forecastModel: { conversionRate: number; @@ -526,7 +589,9 @@ type StrategyDecisionContract = { }; horizonMonths: number; landing: string | null; + landingPageExistsInScan: boolean; nextStage: string; + nextStageAction: StrategyNextStageAction; notPrimaryPhrases: string[]; primaryCluster: string; scenarioId: string; @@ -534,9 +599,26 @@ type StrategyDecisionContract = { selectedScenarioId: string; selectedDemand: number; supportClusters: string[]; + supportThemes: string[]; visibilityMax: number; visibilityMin: number; }; +type StrategySitePortfolioRow = { + action: NonNullable[number]["action"]; + clusters: string[]; + demand: number; + id: string; + path: string; + queryExamples: string[]; + readinessLabel: string; + readinessScore: number | null; + semanticAnchors: string[]; + sourceKind: "page" | "section"; + sourceSignals: string[]; + status: string; + subtitle: string; + title: string; +}; type StrategyExportFormat = "markdown" | "json" | "pdf"; type StrategyExportSource = { detail: string; @@ -580,15 +662,19 @@ type StrategyExportSnapshot = { deferredDemand: number; forecast: string; landing: string | null; + landingPageExistsInScan: boolean; nextStage: string; + nextStageAction: StrategyNextStageAction; primaryCluster: string; scenario: string; selectedScenarioId: string; selectedDemand: number; demandComposition: StrategyDecisionContract["demandComposition"]; + demandEvidenceComposition: StrategyDecisionContract["demandEvidenceComposition"]; differentiatorClusters: string[]; forecastModel: StrategyDecisionContract["forecastModel"]; supportClusters: string[]; + supportThemes: string[]; }; domMirror?: { blocks: StrategyExportDomBlock[]; @@ -610,6 +696,7 @@ type StrategyExportSnapshot = { name: string; pageCount?: number; }; + sitePortfolio: StrategySitePortfolioRow[]; raw: { keywordRows: StrategyKeywordDecisionRow[]; notPrimaryPhrases: string[]; @@ -636,6 +723,27 @@ const STRATEGY_CTR_RANGES: Record = { + serp_wordstat: { + detail: "Фразы, где есть внешняя частотность и интерпретация выдачи.", + label: "SERP + Wordstat подтверждено", + order: 0, + tone: "good" + }, + wordstat_support: { + detail: "Фразы с Wordstat или related-сигналом без полного разбора выдачи.", + label: "Wordstat / related поддержка", + order: 1, + tone: "warning" + }, + manual_review: { + detail: "Фразы, которые нельзя считать одинаково доказанными без ручной или модельной проверки.", + label: "Manual / needs model", + order: 2, + tone: "warning" + } +}; + function clampChartValue(value: number, min: number, max: number) { return Math.min(max, Math.max(min, Number.isFinite(value) ? value : min)); } @@ -2055,11 +2163,12 @@ function getStrategyKeywordRows( const role = getStrategyKeywordDecisionRole(item, serpPhrase); const reason = formatStrategyDisplayText(serpPhrase?.risk ?? serpPhrase?.opportunity ?? item.reason); const usesDirectTarget = role !== "article" && role !== "defer" && role !== "secondary_candidate"; - const targetPath = usesDirectTarget ? (item.targetPath ?? serpPhrase?.targetPath ?? null) : item.targetPath; + const targetPath = usesDirectTarget ? (item.targetPath ?? null) : item.targetPath; const relatedLanding = item.relatedLanding ?? (!targetPath ? (serpPhrase?.targetPath ?? null) : null); return { clusterTitle: item.clusterTitle, + evidenceStatus: item.evidenceStatus, evidenceRefs: serpPhrase?.evidenceRefs ?? [], frequency: item.frequency ?? serpPhrase?.totalDemand ?? null, id: item.id, @@ -2069,7 +2178,8 @@ function getStrategyKeywordRows( relatedLanding, role, targetFit: serpPhrase?.targetFit ?? null, - targetPath + targetPath, + wordstatResultId: item.wordstatResultId }; }) .filter((row) => row.role !== "trash") @@ -2097,13 +2207,58 @@ function getStrategyTopCluster(rows: StrategyKeywordDecisionRow[]) { return Array.from(clusterTotals.entries()).sort((left, right) => right[1] - left[1])[0]?.[0] ?? ""; } -function getStrategyPrimaryTargetPath( - keywordRows: StrategyKeywordDecisionRow[], - seoStrategy: SeoStrategyContract | null -) { - const usableRoles = new Set(["primary", "secondary", "support"]); +function getStrategyCheckedDemandTotal(input: { + keywordRows: StrategyKeywordDecisionRow[]; + serpInterpretation: SerpInterpretationContract | null; + yandexEvidence: YandexEvidenceContract | null; +}) { + return ( + [ + input.yandexEvidence?.summary.totalDemand, + input.serpInterpretation?.yandexEvidence.totalDemand + ].find((value) => typeof value === "number" && value > 0) ?? sumStrategyKeywordDemand(input.keywordRows) + ); +} + +function getStrategyScanPageId(scanSummary: ProjectScanSummary | null | undefined, targetPath: string | null | undefined) { + const targetKey = normalizePortfolioMatchKey(targetPath); + + if (!targetKey) { + return null; + } + + return ( + (scanSummary?.pages ?? []).find((page) => normalizePortfolioMatchKey(page.urlPath) === targetKey)?.pageId ?? + null + ); +} + +function getStrategyNextStageAction(landing: string | null, landingPageExistsInScan: boolean): StrategyNextStageAction { + if (!landing) { + return "select_landing"; + } + + return landingPageExistsInScan ? "rewrite_existing_landing" : "create_or_prepare_landing"; +} + +function getStrategyNextStageLabel(landing: string | null, landingPageExistsInScan: boolean) { + const action = getStrategyNextStageAction(landing, landingPageExistsInScan); + + if (action === "rewrite_existing_landing") { + return `план правок для ${landing}`; + } + + if (action === "create_or_prepare_landing") { + return `подготовить ${landing} как коммерческую посадочную`; + } + + return "выбрать посадочную"; +} + +function getStrategyPrimaryTargetPath(keywordRows: StrategyKeywordDecisionRow[]) { + const usableRoles = new Set(["primary", "secondary", "support", "differentiator"]); const targetTotals = keywordRows.reduce>((accumulator, row) => { - if (!row.targetPath || !usableRoles.has(row.role)) { + if (!row.targetPath || !usableRoles.has(row.role) || row.targetFit === "mismatch" || row.intent === "informational") { return accumulator; } @@ -2111,9 +2266,7 @@ function getStrategyPrimaryTargetPath( return accumulator; }, new Map()); - const keywordTarget = Array.from(targetTotals.entries()).sort((left, right) => right[1] - left[1])[0]?.[0]; - - return keywordTarget ?? seoStrategy?.opportunities.find((opportunity) => opportunity.targetPath)?.targetPath ?? null; + return Array.from(targetTotals.entries()).sort((left, right) => right[1] - left[1])[0]?.[0] ?? null; } function isStrategySelectedPlanRole(role: StrategyKeywordDecisionRole) { @@ -2124,6 +2277,119 @@ function isStrategyDeferredRole(role: StrategyKeywordDecisionRole) { return role === "article" || role === "defer" || role === "secondary_candidate"; } +function getStrategyPortfolioFallbackClusters(rows: StrategyKeywordDecisionRow[]) { + const clusterTotals = rows.reduce>((accumulator, row) => { + if (!row.clusterTitle || isStrategyDeferredRole(row.role)) { + return accumulator; + } + + accumulator.set(row.clusterTitle, (accumulator.get(row.clusterTitle) ?? 0) + (row.frequency ?? 0)); + return accumulator; + }, new Map()); + + return Array.from(clusterTotals.entries()) + .sort((left, right) => right[1] - left[1]) + .map(([clusterTitle]) => clusterTitle) + .slice(0, 4); +} + +function getUniqueLimitedStrings(values: Array, limit: number) { + const normalizedValues = values + .map((value) => value?.replace(/\s+/g, " ").trim() ?? "") + .filter((value) => value.length > 0 && !/\{\{[^}]+}}/.test(value)); + + return Array.from(new Set(normalizedValues)).slice(0, limit); +} + +function getStrategyPortfolioItemHints(item: WorkspaceScopeItem) { + const text = `${item.title} ${item.sourcePath} ${item.pathLabel} ${item.headings.join(" ")}`.toLocaleLowerCase("ru-RU"); + const hints = new Set(); + + for (const token of text.split(/[^a-zа-яё0-9]+/iu)) { + if (token.length >= 4 && !/^(html|templates|pages|home|blocks|project|video|detail|first)$/i.test(token)) { + hints.add(token); + } + } + + if (/\bhub\b|хаб|workspace|роль|доступ|rbac/i.test(text)) { + ["hub", "workspace", "rbac", "роль", "доступ", "команда", "рабочее пространство"].forEach((hint) => hints.add(hint)); + } + + if (/\bengine\b|оркестр|инфраструкт|mcp|api/i.test(text)) { + ["engine", "оркестрация", "инфраструктура", "mcp", "api", "интеграции", "сервис"].forEach((hint) => hints.add(hint)); + } + + if (/\bops\b|tasker|операцион|задач|проект/i.test(text)) { + ["ops", "tasker", "операционный", "задача", "проект", "контроль", "статус"].forEach((hint) => hints.add(hint)); + } + + if (/тезис|состав|demo|демо|заявк|cta|request/i.test(text)) { + ["платформа", "процесс", "автоматизация", "агент", "заявка", "демо"].forEach((hint) => hints.add(hint)); + } + + if (/faq|вопрос|ответ/i.test(text)) { + ["вопрос", "поддержка", "документация", "проверка"].forEach((hint) => hints.add(hint)); + } + + if (/модул/i.test(text)) { + ["модуль", "hub", "engine", "ops", "оркестрация", "операционный"].forEach((hint) => hints.add(hint)); + } + + return Array.from(hints); +} + +function getStrategyPortfolioContextClusters( + item: WorkspaceScopeItem, + semanticClusters: NonNullable, + matchKeys: Set +) { + const hints = getStrategyPortfolioItemHints(item); + + return semanticClusters + .map((cluster) => { + const clusterText = [ + cluster.title, + cluster.targetIntent, + ...cluster.matchedTerms, + ...cluster.queryExamples, + ...cluster.sourceRefs.map((ref) => `${ref.title} ${ref.sourcePath}`) + ] + .join(" ") + .toLocaleLowerCase("ru-RU"); + const sourceScore = cluster.sourceRefs.some((ref) => semanticSourceMatchesPortfolioKeys(ref, matchKeys)) ? 18 : 0; + const hintScore = hints.reduce((score, hint) => score + (clusterText.includes(hint.toLocaleLowerCase("ru-RU")) ? 5 : 0), 0); + const titleScore = cluster.title.toLocaleLowerCase("ru-RU").includes(item.title.toLocaleLowerCase("ru-RU")) ? 10 : 0; + + return { cluster, score: sourceScore + hintScore + titleScore }; + }) + .filter((itemScore) => itemScore.score > 0) + .sort((left, right) => right.score - left.score || right.cluster.score - left.cluster.score) + .map((itemScore) => itemScore.cluster) + .slice(0, 4); +} + +function semanticSourceMatchesPortfolioKeys( + source: { id?: string | null; sourceId?: string | null; sourcePath?: string | null; urlPath?: string | null }, + matchKeys: Set +) { + const sourceKeys = [ + normalizePortfolioMatchKey(source.sourcePath), + normalizePortfolioMatchKey(source.urlPath), + normalizePortfolioMatchKey(source.id), + normalizePortfolioMatchKey(source.sourceId) + ].filter(Boolean); + + return sourceKeys.some((key) => matchKeys.has(key)); +} + +function getStrategyPortfolioDemandLabel(row: Pick) { + if (row.demand > 0) { + return `сырой связанный спрос ${formatCount(row.demand)} · не для прогноза`; + } + + return row.sourceKind === "section" ? "нет подтверждённого спроса в текущей iteration" : "нет спроса в текущей iteration"; +} + function getStrategyScenarioLabel(id: string | null | undefined) { if (id === "landing_first") return "A · рекомендовано"; if (id === "homepage_only") return "B · осторожно"; @@ -2180,27 +2446,129 @@ function getStrategyDemandComposition(rows: StrategyKeywordDecisionRow[]) { .map((group) => ({ demand: groups[group.role].demand, label: group.label, - phrases: groups[group.role].phrases.slice(0, 8), + phrases: groups[group.role].phrases.slice(0, 12), role: group.role })) .filter((group) => group.demand > 0 || group.phrases.length > 0); } +function getStrategyDemandEvidenceTier(row: StrategyKeywordDecisionRow): StrategyDemandEvidenceTier { + if (row.frequency === null) { + return "manual_review"; + } + + const evidenceText = row.evidenceRefs.join(" ").toLocaleLowerCase("ru-RU"); + const hasSerpEvidence = Boolean(row.intent) || /serp\.docs|seo-serp|serp/i.test(evidenceText); + const hasWordstatEvidence = + row.evidenceStatus === "collected" || + row.evidenceStatus === "collected_related_only" || + Boolean(row.wordstatResultId) || + /wordstat|yandex-evidence\.phrases/i.test(evidenceText); + + if (hasSerpEvidence && hasWordstatEvidence) { + return "serp_wordstat"; + } + + if (hasWordstatEvidence) { + return "wordstat_support"; + } + + return "manual_review"; +} + +function getStrategyDemandEvidenceComposition(rows: StrategyKeywordDecisionRow[]): StrategyDemandEvidenceCompositionItem[] { + const groups = rows.reduce>((accumulator, row) => { + const tier = getStrategyDemandEvidenceTier(row); + const group = accumulator.get(tier) ?? { demand: 0, phrases: [] }; + group.demand += row.frequency ?? 0; + + if (row.phrase) { + group.phrases.push(row.phrase); + } + + accumulator.set(tier, group); + return accumulator; + }, new Map()); + + return Array.from(groups.entries()) + .map(([tier, group]) => ({ + demand: group.demand, + detail: STRATEGY_DEMAND_EVIDENCE_TIERS[tier].detail, + label: STRATEGY_DEMAND_EVIDENCE_TIERS[tier].label, + phrases: group.phrases.slice(0, 8), + tier + })) + .filter((group) => group.demand > 0 || group.phrases.length > 0) + .sort((left, right) => STRATEGY_DEMAND_EVIDENCE_TIERS[left.tier].order - STRATEGY_DEMAND_EVIDENCE_TIERS[right.tier].order); +} + +function formatStrategyDemandEvidenceComposition(composition: StrategyDemandEvidenceCompositionItem[]) { + return composition.length > 0 + ? composition.map((part) => `${formatCount(part.demand)} - ${part.label}`).join("; ") + : "уровни доказательности не выделены"; +} + +function getStrategyDemandEvidenceTone(composition: StrategyDemandEvidenceCompositionItem[]): SeoChartTone { + if (composition.length === 0) { + return "warning"; + } + + return composition.some((part) => part.tier === "manual_review") ? "warning" : "good"; +} + +function getStrategySupportThemeLabel(row: StrategyKeywordDecisionRow) { + const text = `${row.phrase} ${row.clusterTitle}`.toLocaleLowerCase("ru-RU"); + + if (/внедрен/.test(text)) return "внедрение"; + if (/управлен|контрол/.test(text)) return "управление процессами"; + if (/решени|платформ|систем/.test(text)) return "решения для процессов"; + if (/оптимизац/.test(text)) return "оптимизация процессов"; + if (/организац|регламент/.test(text)) return "организация процессов"; + if (/интеграц/.test(text)) return "интеграции"; + if (/аналитик|отчет|отчёт|дашборд/.test(text)) return "аналитика и контроль"; + + return row.clusterTitle; +} + +function getStrategySupportThemes(rows: StrategyKeywordDecisionRow[], primaryCluster: string) { + const themeTotals = rows + .filter((row) => row.role === "secondary" || row.role === "support") + .reduce>((accumulator, row) => { + const theme = getStrategySupportThemeLabel(row); + + if (!theme || theme === primaryCluster) { + return accumulator; + } + + accumulator.set(theme, (accumulator.get(theme) ?? 0) + (row.frequency ?? 0)); + return accumulator; + }, new Map()); + + return Array.from(themeTotals.entries()) + .sort((left, right) => right[1] - left[1]) + .map(([theme]) => theme) + .slice(0, 4); +} + function createStrategyDecisionContract(input: { conversionRate: number; deferredDemand: number; demandComposition: StrategyDecisionContract["demandComposition"]; + demandEvidenceComposition: StrategyDecisionContract["demandEvidenceComposition"]; differentiatorClusters: string[]; forecastModelId: StrategyForecastCtrModel; horizonMonths: number; landing: string | null; + landingPageExistsInScan: boolean; nextStage: string; + nextStageAction: StrategyNextStageAction; notPrimaryPhrases: string[]; primaryCluster: string; scenarioId: string; scenarioLabel: string; selectedDemand: number; supportClusters: string[]; + supportThemes: string[]; visibilityMax: number; visibilityMin: number; }): StrategyDecisionContract { @@ -2208,11 +2576,14 @@ function createStrategyDecisionContract(input: { conversionRate: input.conversionRate, deferredDemand: input.deferredDemand, demandComposition: input.demandComposition, + demandEvidenceComposition: input.demandEvidenceComposition, differentiatorClusters: input.differentiatorClusters, forecastModel: buildStrategyForecastModel(input.forecastModelId, input.conversionRate, input.horizonMonths), horizonMonths: input.horizonMonths, landing: input.landing, + landingPageExistsInScan: input.landingPageExistsInScan, nextStage: input.nextStage, + nextStageAction: input.nextStageAction, notPrimaryPhrases: input.notPrimaryPhrases, primaryCluster: input.primaryCluster, scenarioId: input.scenarioId, @@ -2220,6 +2591,7 @@ function createStrategyDecisionContract(input: { selectedScenarioId: input.scenarioId, selectedDemand: input.selectedDemand, supportClusters: input.supportClusters, + supportThemes: input.supportThemes, visibilityMax: input.visibilityMax, visibilityMin: input.visibilityMin }; @@ -2256,12 +2628,13 @@ function buildStrategyExportSnapshot(input: { scanSummary: ProjectScanSummary | null | undefined; selectedContract: StrategyDecisionContract | null; selectedScenarioId: string | null; + semanticAnalysis: SemanticAnalysisRun | null; seoStrategy: SeoStrategyContract | null; serpInterpretation: SerpInterpretationContract | null; yandexEvidence: YandexEvidenceContract | null; }): StrategyExportSnapshot { const keywordRows = getStrategyKeywordRows(input.keywordMap, input.serpInterpretation); - const primaryTargetPath = getStrategyPrimaryTargetPath(keywordRows, input.seoStrategy); + const primaryTargetPath = getStrategyPrimaryTargetPath(keywordRows); const rowsForLanding = primaryTargetPath ? keywordRows.filter((row) => row.targetPath === primaryTargetPath) : keywordRows; const decisionRows = rowsForLanding.filter((row) => { return isStrategySelectedPlanRole(row.role) && row.intent !== "informational" && row.targetFit !== "mismatch"; @@ -2271,15 +2644,20 @@ function buildStrategyExportSnapshot(input: { }); const selectedDemandFromRows = sumStrategyKeywordDemand(decisionRows); const selectedDemand = input.selectedContract?.selectedDemand ?? selectedDemandFromRows; - const totalDemand = - input.yandexEvidence?.summary.totalDemand ?? - input.serpInterpretation?.yandexEvidence.totalDemand ?? - input.seoStrategy?.evidence.demand.totalFrequency ?? - sumStrategyKeywordDemand(keywordRows); + const totalDemand = getStrategyCheckedDemandTotal({ + keywordRows, + serpInterpretation: input.serpInterpretation, + yandexEvidence: input.yandexEvidence + }); const deferredDemand = input.selectedContract?.deferredDemand ?? Math.max(0, totalDemand - selectedDemand); const differentiatorClusters = input.selectedContract?.differentiatorClusters ?? Array.from(new Set(decisionRows.filter((row) => row.role === "differentiator").map((row) => row.clusterTitle).filter(Boolean))).slice(0, 4); + const primaryCluster = + input.selectedContract?.primaryCluster || + getStrategyTopCluster(decisionRows.filter((row) => row.role !== "differentiator")) || + "Кластер не выбран"; + const supportThemes = input.selectedContract?.supportThemes ?? getStrategySupportThemes(decisionRows, primaryCluster); const supportClusters = input.selectedContract?.supportClusters ?? Array.from( @@ -2287,15 +2665,13 @@ function buildStrategyExportSnapshot(input: { decisionRows .filter((row) => row.role === "secondary" || row.role === "support") .map((row) => row.clusterTitle) - .filter(Boolean) + .filter((clusterTitle) => Boolean(clusterTitle) && clusterTitle !== primaryCluster) ) ).slice(0, 4); - const primaryCluster = - input.selectedContract?.primaryCluster || - getStrategyTopCluster(decisionRows.filter((row) => row.role !== "differentiator")) || - input.seoStrategy?.opportunities[0]?.title || - "Кластер не выбран"; const landing = input.selectedContract?.landing ?? primaryTargetPath; + const landingPageExistsInScan = Boolean(getStrategyScanPageId(input.scanSummary, landing)); + const nextStageAction = getStrategyNextStageAction(landing, landingPageExistsInScan); + const nextStage = getStrategyNextStageLabel(landing, landingPageExistsInScan); const visibilityMin = input.selectedContract?.visibilityMin ?? selectedDemand * 0.03; const visibilityMax = input.selectedContract?.visibilityMax ?? selectedDemand * 0.05; const conversionRate = input.selectedContract?.conversionRate ?? DEFAULT_STRATEGY_FORECAST_SETTINGS.conversionRate; @@ -2315,26 +2691,40 @@ function buildStrategyExportSnapshot(input: { .filter(Boolean) .slice(0, 12); const demandComposition = input.selectedContract?.demandComposition ?? getStrategyDemandComposition(decisionRows); + const demandEvidenceComposition = input.selectedContract?.demandEvidenceComposition ?? getStrategyDemandEvidenceComposition(decisionRows); const selectedContract = - input.selectedContract ?? - createStrategyDecisionContract({ - conversionRate, - deferredDemand, - demandComposition, - differentiatorClusters, - forecastModelId, - horizonMonths, - landing, - nextStage: landing ? `План правок для ${landing}` : "План правок", - notPrimaryPhrases, - primaryCluster, - scenarioId: selectedScenarioId, - scenarioLabel: scenario, - selectedDemand, - supportClusters, - visibilityMax, - visibilityMin - }); + input.selectedContract + ? { + ...input.selectedContract, + demandEvidenceComposition, + landingPageExistsInScan, + nextStage, + nextStageAction, + supportClusters, + supportThemes + } + : createStrategyDecisionContract({ + conversionRate, + deferredDemand, + demandComposition, + demandEvidenceComposition, + differentiatorClusters, + forecastModelId, + horizonMonths, + landing, + landingPageExistsInScan, + nextStage, + nextStageAction, + notPrimaryPhrases, + primaryCluster, + scenarioId: selectedScenarioId, + scenarioLabel: scenario, + selectedDemand, + supportClusters, + supportThemes, + visibilityMax, + visibilityMin + }); const productLabel = getReadablePathProductLabel(landing); const siteTitle = getProjectSiteTitle(input.scanSummary); const brandNameCandidate = siteTitle.split(/\s+[—-]\s+|\s+\|\s+/)[0]?.trim(); @@ -2352,6 +2742,7 @@ function buildStrategyExportSnapshot(input: { const evidenceRefs = row.evidenceRefs .map(formatStrategyEvidenceRef) .filter((ref): ref is string => Boolean(ref)); + const evidenceTier = STRATEGY_DEMAND_EVIDENCE_TIERS[getStrategyDemandEvidenceTier(row)]; return [ row.phrase, @@ -2359,9 +2750,16 @@ function buildStrategyExportSnapshot(input: { row.clusterTitle, getStrategyKeywordRoleLabel(row.role), row.targetPath ?? (row.relatedLanding ? `связано с ${row.relatedLanding}` : "URL не выбран"), + evidenceTier.label, evidenceRefs.join("; ") || "связь из карты фраз" ]; }); + const sitePortfolio = buildStrategySitePortfolioRows({ + keywordRows, + landing, + scanSummary: input.scanSummary, + semanticAnalysis: input.semanticAnalysis + }); const sources: StrategyExportSource[] = [ { detail: `${input.keywordMap?.items.length ?? 0} фраз и решений по целевым страницам.`, @@ -2381,7 +2779,7 @@ function buildStrategyExportSnapshot(input: { label: "Wordstat / внешние доказательства" }, { - detail: `${input.seoStrategy?.opportunities.length ?? 0} возможностей, ${input.seoStrategy?.risks.length ?? 0} рисков.`, + detail: `${input.seoStrategy?.opportunities.length ?? 0} возможностей, ${input.seoStrategy?.risks.length ?? 0} рисков. Это evidence layer; 07 План правок читает selectedContract как финальное решение.`, id: "seo-strategy.opportunities", label: "SEO-стратегия" }, @@ -2417,10 +2815,16 @@ function buildStrategyExportSnapshot(input: { value: formatCount(selectedDemand) }, { - detail: supportClusters.length ? supportClusters.join(", ") : "Поддерживающие кластеры не выделены.", - label: "Поддерживающие кластеры", + detail: formatStrategyDemandEvidenceComposition(demandEvidenceComposition), + label: "Доказательность спроса", sourceId: "keyword-map.items", - value: formatGroupCount(supportClusters.length) + value: formatGroupCount(demandEvidenceComposition.length) + }, + { + detail: supportThemes.length ? supportThemes.join(", ") : "Поддерживающие темы не выделены.", + label: "Поддерживающие темы", + sourceId: "keyword-map.items", + value: formatGroupCount(supportThemes.length) } ], sourceId: "keyword-map.items", @@ -2452,6 +2856,25 @@ function buildStrategyExportSnapshot(input: { summary: "Контракт решения нужен, чтобы план правок не спорил заново о посадочной, спросе и исключениях.", title: "Контракт для плана правок" }, + { + sourceId: "project-scope.selected-workspace", + summary: "Этот блок отделяет выбранную первую посадочную от реальных страниц и блоков текущего проекта.", + table: { + columns: ["Страница / блок", "Scope", "Статус", "Сырой спрос", "Семантика", "Кластеры", "Якоря", "Запросы"], + rows: sitePortfolio.map((row) => [ + row.title, + row.subtitle, + row.status, + getStrategyPortfolioDemandLabel(row), + row.readinessLabel, + row.clusters.join("; ") || "кластеры не выделены", + row.semanticAnchors.join("; ") || "якоря не выделены", + row.queryExamples.join("; ") || "запросы не выделены" + ]), + sourceId: "project-scope.selected-workspace" + }, + title: "Семантический портфель сайта" + }, { cards: [ { @@ -2481,7 +2904,7 @@ function buildStrategyExportSnapshot(input: { sourceId: "keyword-map.items", summary: "Таблица показывает не только фразу, но и роль, URL и доказательство, откуда взято решение.", table: { - columns: ["Фраза", "Спрос", "Кластер", "Роль", "URL", "Источник решения"], + columns: ["Фраза", "Спрос", "Кластер", "Роль", "URL", "Уровень", "Источник решения"], rows: phraseTableRows, sourceId: "keyword-map.items" }, @@ -2490,9 +2913,14 @@ function buildStrategyExportSnapshot(input: { ]; const sourceRoutes: StrategyExportRoute[] = [ { - calculation: "Самый сильный targetPath среди пригодных primary/secondary/support строк карты фраз. Article/backlog использует relatedLanding, а не прямой targetPath.", + calculation: "Самый сильный targetPath среди пригодных строк карты фраз. SERP targetFit только отсекает mismatch/informational, но не выбирает URL самостоятельно.", element: "decision.landing", - jsonRoutes: ["keywordMap.items[].targetPath", "serpInterpretation.phraseInterpretations[].targetPath"], + jsonRoutes: [ + "keywordMap.items[].targetPath", + "keywordMap.items[].role", + "keywordMap.items[].frequency", + "serpInterpretation.phraseInterpretations[].targetFit" + ], value: landing }, { @@ -2514,15 +2942,26 @@ function buildStrategyExportSnapshot(input: { value: demandComposition.map((part) => `${part.label}: ${formatCount(part.demand)}`).join("; ") }, { - calculation: "Проверенный спрос минус спрос, выбранный в текущий план.", + calculation: "Разложение выбранного спроса по плотности доказательств: SERP+Wordstat, Wordstat/related и manual/needs model.", + element: "decision.demandEvidenceComposition", + jsonRoutes: [ + "keywordMap.items[].evidenceStatus", + "keywordMap.items[].wordstatResultId", + "serpInterpretation.phraseInterpretations[].intent", + "serpInterpretation.phraseInterpretations[].evidenceRefs" + ], + value: formatStrategyDemandEvidenceComposition(demandEvidenceComposition) + }, + { + calculation: "Проверенный Wordstat-пул минус спрос, выбранный в текущий план.", element: "decision.deferredDemand", - jsonRoutes: ["yandexEvidence.summary.totalDemand", "seoStrategy.evidence.demand.totalFrequency", "decision.selectedDemand"], + jsonRoutes: ["yandexEvidence.summary.totalDemand", "decision.selectedDemand"], value: deferredDemand }, { - calculation: "Доминирующий кластер по суммарной частотности строк выбранной посадочной.", + calculation: "Доминирующий кластер по суммарной частотности выбранных keyword rows посадочной. Opportunities используются только как evidence-layer.", element: "decision.primaryCluster", - jsonRoutes: ["keywordMap.items[].clusterTitle", "keywordMap.items[].frequency", "seoStrategy.opportunities[].title"], + jsonRoutes: ["keywordMap.items[].clusterTitle", "keywordMap.items[].frequency", "keywordMap.items[].role", "keywordMap.items[].targetPath"], value: primaryCluster }, { @@ -2530,6 +2969,24 @@ function buildStrategyExportSnapshot(input: { element: "decision.forecast", jsonRoutes: ["decision.selectedDemand", "strategyForecastSettingsByProjectId", "selectedStrategyScenarioIds"], value: forecast + }, + { + calculation: "07 План правок читает selectedContract и keywordRows; seoStrategy.opportunities остаётся evidence layer и не переопределяет финальное решение.", + element: "stage07.inputContract", + jsonRoutes: ["evidenceContracts.selectedContract", "raw.keywordRows", "raw.notPrimaryPhrases", "decision.landingPageExistsInScan"], + value: selectedContract ? "selectedContract" : "fallback decision" + }, + { + calculation: "Если выбранный URL отсутствует в scanSummary.pages[].urlPath, 07 План правок готовит создание или привязку посадочной, а не rewrite существующей страницы.", + element: "stage07.landingAction", + jsonRoutes: ["decision.landing", "scanSummary.pages[].urlPath", "decision.nextStageAction"], + value: nextStageAction + }, + { + calculation: "Выбранный project scope показывает реальный портфель страниц и блоков; semantic landingPagePlan только обогащает совпавшие scope-элементы.", + element: "sitePortfolio", + jsonRoutes: ["scanSummary.pages[].selected", "scanSummary.siteSections[].selected", "semanticAnalysis.landingPagePlan", "keywordMap.items[].targetPath", "decision.landing"], + value: `${sitePortfolio.length} элементов scope` } ]; @@ -2537,16 +2994,20 @@ function buildStrategyExportSnapshot(input: { decision: { deferredDemand, demandComposition, + demandEvidenceComposition, differentiatorClusters, forecast, forecastModel: selectedContract.forecastModel, landing, + landingPageExistsInScan, nextStage: selectedContract.nextStage, + nextStageAction, primaryCluster, scenario, selectedScenarioId, selectedDemand, - supportClusters + supportClusters, + supportThemes }, evidenceContracts: { keywordMap: input.keywordMap, @@ -2563,6 +3024,7 @@ function buildStrategyExportSnapshot(input: { name: input.project.name, pageCount: input.scanSummary?.counts.pages }, + sitePortfolio, raw: { keywordRows, notPrimaryPhrases, @@ -2611,6 +3073,30 @@ function renderStrategyExportMarkdown(snapshot: StrategyExportSnapshot) { ) : ["- Состав не выделен."] ), + "", + "### Доказательность выбранного спроса", + ...( + snapshot.decision.demandEvidenceComposition.length + ? snapshot.decision.demandEvidenceComposition.map( + (part) => + `- **${part.label}:** ${formatCount(part.demand)} · ${part.phrases.length ? part.phrases.join(", ") : part.detail}` + ) + : ["- Уровни доказательности не выделены."] + ), + "", + "### Семантический портфель сайта", + ...(snapshot.sitePortfolio.length + ? snapshot.sitePortfolio + .slice(0, 10) + .map( + (row) => + `- **${row.title}:** ${row.subtitle} · ${row.status} · ${getStrategyPortfolioDemandLabel(row)} · ${ + row.clusters.join(", ") || "кластеры не выделены" + } · якоря: ${row.semanticAnchors.join(", ") || "не выделены"} · запросы: ${ + row.queryExamples.join(", ") || "не выделены" + }` + ) + : ["- Портфель страниц не выделен."]), "" ]; @@ -2666,9 +3152,32 @@ function renderStrategyExportMarkdown(snapshot: StrategyExportSnapshot) { ? snapshot.decision.demandComposition.map( (part) => `- **${part.label}:** ${formatCount(part.demand)} · ${part.phrases.length ? part.phrases.join(", ") : "фразы не перечислены"}` - ) - : ["- Состав не выделен."] + ) + : ["- Состав не выделен."] ), + "", + "### Доказательность выбранного спроса", + ...( + snapshot.decision.demandEvidenceComposition.length + ? snapshot.decision.demandEvidenceComposition.map( + (part) => `- **${part.label}:** ${formatCount(part.demand)} · ${part.phrases.length ? part.phrases.join(", ") : part.detail}` + ) + : ["- Уровни доказательности не выделены."] + ), + "", + "### Семантический портфель сайта", + ...(snapshot.sitePortfolio.length + ? snapshot.sitePortfolio + .slice(0, 10) + .map( + (row) => + `- **${row.path}:** ${row.status} · ${getStrategyPortfolioDemandLabel(row)} · ${ + row.clusters.join(", ") || "кластеры не выделены" + } · якоря: ${row.semanticAnchors.join(", ") || "не выделены"} · запросы: ${ + row.queryExamples.join(", ") || "не выделены" + }` + ) + : ["- Портфель страниц не выделен."]), "" ]; @@ -3801,7 +4310,7 @@ function getProjectSiteTitle(scan: ProjectScanSummary | null | undefined) { return firstParsedTitle; } - return "Тайтл главной страницы не найден"; + return "Тайтл сайта не найден"; } function getPagePathLabel(page: ProjectScanSummary["pages"][number]) { @@ -4031,7 +4540,7 @@ function getPipelineStageAccess( } return { - reason: qualityReady ? "Проверка качества пройдена; применение всё ещё остаётся будущим контролируемым контуром." : "Применение заблокировано до проверки качества.", + reason: qualityReady ? "Проверка качества пройдена; patch-run всё ещё остаётся будущим контролируемым контуром." : "Patch-run заблокирован до проверки качества.", status: qualityReady ? "future" : "locked", unlocked: qualityReady }; @@ -4193,9 +4702,9 @@ function getLandingOpportunityActionLabel( function getLandingPagePlanActionLabel(action: NonNullable[number]["action"]) { const labels = { - create: "создать", - strengthen: "усилить", - validate: "проверить" + create: "создать посадочную", + strengthen: "усилить страницу", + validate: "проверить спрос" }; return labels[action]; @@ -4379,6 +4888,431 @@ function getRewriteDiffSlotStatusLabel(status: RewriteDiffContract["targets"][nu return labels[status]; } +function getPatchArtifactStateLabel(state: PatchArtifactContract["state"]) { + const labels = { + empty: "пустой patch", + ready_for_dry_run: "готов к dry-run" + }; + + return labels[state]; +} + +function getPatchDryRunStateLabel(state: PatchDryRunContract["state"]) { + const labels = { + blocked: "dry-run blocked", + empty: "dry-run empty", + passed: "dry-run passed" + }; + + return labels[state]; +} + +function getPatchFingerprintStatusLabel(status: NonNullable["status"] | undefined) { + const labels = { + matched: "fingerprint ok", + mismatch: "source changed", + missing_at_build: "rebuild required", + missing_at_dry_run: "source missing" + }; + + return status ? labels[status] : "rebuild required"; +} + +function getPatchProvenanceLabel(provenance: PatchArtifactContract["provenance"] | PatchDryRunContract["provenance"] | undefined) { + if (!provenance) { + return "provenance отсутствует"; + } + + const ontologyLabel = provenance.projectOntology + ? `ontology v${provenance.projectOntology.version} / ${provenance.projectOntology.approvalStatus}` + : "ontology не привязана"; + const rewriteLabel = provenance.rewriteDiff + ? `rewrite ${provenance.rewriteDiff.state}` + : provenance.rewritePlan + ? `plan ${provenance.rewritePlan.state}` + : "rewrite не привязан"; + + return `${ontologyLabel} · ${rewriteLabel}`; +} + +function getPatchGateLabel(ready: boolean | undefined) { + return ready ? "ready" : "blocked"; +} + +function getVisualComposerStrategyScore(patchArtifact: PatchArtifactContract | null) { + const provenance = patchArtifact?.provenance; + let score = 0; + + if (provenance?.projectOntology && provenance.projectOntology.reviewProblemCount === 0) { + score += 30; + } + + if (provenance?.semanticRunId) { + score += 20; + } + + if ((provenance?.rewritePlan?.readiness.approvedDecisionCount ?? 0) > 0) { + score += 25; + } + + if ((provenance?.rewriteDiff?.readiness.slotCount ?? 0) > 0) { + score += 25; + } + + return score; +} + +function getVisualComposerMetrics(input: { + activeSavedOriginalChangeCount: number; + activeUnsavedSectionCount: number; + patchArtifact: PatchArtifactContract | null; + patchDryRun: PatchDryRunContract | null; + rewriteDiff: RewriteDiffContract | null; + variantsContract: VisualComposerVariantsContract | null; +}): VisualComposerMetric[] { + const patchArtifact = input.patchArtifact; + const patchDryRun = input.patchDryRun; + const semanticBlockCount = + input.variantsContract?.readiness.semanticBlockCount ?? input.rewriteDiff?.readiness.semanticBlockCount ?? 0; + const semanticGroupedSlotCount = + input.variantsContract?.readiness.semanticGroupedSlotCount ?? input.rewriteDiff?.readiness.semanticGroupedSlotCount ?? 0; + const slotLevelFallbackCount = + input.variantsContract?.readiness.slotLevelFallbackCount ?? input.rewriteDiff?.readiness.slotLevelFallbackCount ?? 0; + const visualReady = patchArtifact?.provenance?.gates?.visualComposer.ready ?? false; + const applyReady = patchArtifact?.provenance?.gates?.productionPatchRun.ready ?? false; + const checkCount = patchDryRun?.readiness.checkCount ?? 0; + const passedCount = patchDryRun?.readiness.passedCount ?? 0; + const sourceIssueCount = + (patchArtifact?.readiness.sourceUnreadableCount ?? 0) + + (patchDryRun?.readiness.missingSourceCount ?? 0) + + (patchDryRun?.readiness.fingerprintMismatchCount ?? 0) + + (patchDryRun?.readiness.fingerprintMissingCount ?? 0); + const changedCount = patchArtifact?.readiness.changeCount ?? input.activeSavedOriginalChangeCount; + const delta = patchArtifact?.changes.reduce((sum, change) => sum + change.delta, 0) ?? 0; + const strategyScore = getVisualComposerStrategyScore(patchArtifact); + + return [ + { + detail: applyReady ? "visual и apply открыты" : visualReady ? "визуал открыт, apply закрыт" : "нужен patch/dry-run", + label: "Режим", + tone: visualReady ? "good" : "warning", + value: visualReady ? "Визуал" : "Черновик" + }, + { + detail: checkCount > 0 ? "dry-run source checks" : "dry-run ещё не запускался", + label: "Checks", + tone: checkCount > 0 && passedCount === checkCount ? "good" : "warning", + value: checkCount > 0 ? `${passedCount}/${checkCount}` : "0/0" + }, + { + detail: "source fingerprint / read guard", + label: "Source", + tone: sourceIssueCount === 0 ? "good" : "problem", + value: sourceIssueCount === 0 ? "OK" : String(sourceIssueCount) + }, + { + detail: `${input.activeUnsavedSectionCount} локально · дельта ${delta >= 0 ? "+" : ""}${delta}`, + label: "Текст", + tone: changedCount > 0 ? "good" : "neutral", + value: String(changedCount) + }, + { + detail: + semanticBlockCount > 0 + ? `${semanticGroupedSlotCount} grouped slots · ${slotLevelFallbackCount} fallback` + : "semantic blocks ещё не участвуют", + label: "Blocks", + tone: semanticBlockCount > 0 && slotLevelFallbackCount === 0 ? "good" : semanticBlockCount > 0 ? "warning" : "neutral", + value: semanticBlockCount > 0 ? String(semanticBlockCount) : "0" + }, + { + detail: "контракт стратегии, не прогноз ТОПа", + label: "Стратегия", + tone: strategyScore >= 75 ? "good" : strategyScore >= 50 ? "warning" : "problem", + value: `${strategyScore}%` + } + ]; +} + +function getVisualComposerGeneratedConfigurations( + variantsContract: VisualComposerVariantsContract | null | undefined +): VisualComposerConfig[] { + return (variantsContract?.variants ?? []).map((variant) => { + const sectionTextsByPageId = variant.slots.reduce>((pageMap, slot) => { + pageMap[slot.pageId] = { + ...(pageMap[slot.pageId] ?? {}), + [slot.workspaceSectionId]: slot.afterText + }; + return pageMap; + }, {}); + + return { + id: `generated:${variant.id}`, + label: variant.label, + savedAt: variantsContract?.generatedAt ?? null, + sectionTextsByPageId, + source: "generated", + summary: variant.summary + }; + }); +} + +function getVisualComposerConfigurations( + userConfigs: VisualComposerConfig[] | undefined, + variantsContract: VisualComposerVariantsContract | null | undefined +): VisualComposerConfig[] { + return [ + { + id: "draft", + label: "Черновик", + savedAt: null, + source: "system" + }, + { + id: "original", + label: "Оригинал", + savedAt: null, + source: "system" + }, + ...getVisualComposerGeneratedConfigurations(variantsContract), + ...(userConfigs ?? []) + ]; +} + +function getVisualComposerSectionSnapshot( + targets: WorkspaceTarget[], + sectionTextMap: WorkspaceSectionTextMap | undefined +): WorkspaceSectionTextMap { + return targets.reduce((snapshot, target) => { + if (target.permission !== "editable") { + return snapshot; + } + + snapshot[target.sectionId] = sectionTextMap?.[target.sectionId] ?? target.workingText; + return snapshot; + }, {}); +} + +function getVisualComposerSystemSnapshot(targets: WorkspaceTarget[], configId: string): WorkspaceSectionTextMap | null { + if (configId !== "draft" && configId !== "original") { + return null; + } + + return targets.reduce((snapshot, target) => { + if (target.permission !== "editable") { + return snapshot; + } + + snapshot[target.sectionId] = configId === "original" ? target.originalText : target.workingText; + return snapshot; + }, {}); +} + +function getVisualComposerSeedField(target: WorkspaceTarget): VisualComposerSeedInput["field"] { + if (target.sectionId === "seo-title") { + return "title"; + } + + if (target.sectionId === "seo-description") { + return "meta_description"; + } + + if (target.sectionId === "seo-h1" || target.kind === "heading_group") { + return "h1"; + } + + return "body_section"; +} + +function getWorkspacePreviewSignalText(target: WorkspaceTarget) { + return normalizeWorkspaceComparisonText(target.workingText || target.originalText) + .replace(/<[^>]*>/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function isLowSignalWorkspacePreviewText(value: string) { + const text = value.replace(/\s+/g, " ").trim(); + + if (text.length < 14) { + return true; + } + + const hasCyrillic = /[а-яё]/i.test(text); + const words = text.match(/[a-zа-яё0-9][a-zа-яё0-9._-]*/gi) ?? []; + + if (words.length <= 1 && text.length < 28) { + return true; + } + + if (!hasCyrillic && words.length <= 2 && text.length < 24) { + return true; + } + + return !hasCyrillic && /^[a-z0-9._:/#-]+$/i.test(text); +} + +function shouldShowWorkspacePreviewMarker(target: WorkspaceTarget) { + if (target.source === "audit_issue" || target.source === "workspace_section") { + return true; + } + + if (isWorkspaceGlobalSeoTarget(target) || isWorkspaceMediaLikeTarget(target)) { + return false; + } + + if (target.source === "content_field") { + if ((target.semanticGroup?.fieldOrder ?? 0) > 0) { + return false; + } + + return !isLowSignalWorkspacePreviewText(getWorkspacePreviewSignalText(target)); + } + + return target.permission === "editable"; +} + +function getWorkspaceTargetPreviewNeedles(target: WorkspaceTarget) { + if (target.source !== "content_field") { + return []; + } + + return Array.from( + new Set( + [target.workingText, target.originalText] + .flatMap((value) => value.split(/\n+/)) + .map((value) => normalizeWorkspaceComparisonText(value).replace(/\s+/g, " ")) + .filter((value) => value.length >= 3 && !isLowSignalWorkspacePreviewText(value)) + .sort((left, right) => right.length - left.length) + ) + ).slice(0, 4); +} + +function buildVisualComposerGroupText( + groupId: string, + targets: WorkspaceTarget[], + sectionTextMap: WorkspaceSectionTextMap | undefined +) { + return targets + .filter((target) => target.semanticGroup?.id === groupId && target.permission === "editable") + .sort((left, right) => (left.semanticGroup?.fieldOrder ?? left.number) - (right.semanticGroup?.fieldOrder ?? right.number)) + .map((target) => { + const role = target.semanticGroup?.fieldRole ?? target.contentField?.role ?? target.kind; + const value = getWorkspaceTargetDraftText(target, sectionTextMap).trim(); + return value ? `${role}: ${value}` : ""; + }) + .filter(Boolean) + .join("\n"); +} + +function buildVisualComposerPageContextText(targets: WorkspaceTarget[], sectionTextMap: WorkspaceSectionTextMap | undefined) { + const seen = new Set(); + const contextText = targets + .filter((target) => target.permission === "editable" && target.kind !== "media") + .sort((left, right) => left.number - right.number) + .map((target) => { + const value = getWorkspaceTargetDraftText(target, sectionTextMap).replace(/\s+/g, " ").trim(); + + if (!value) { + return ""; + } + + const dedupeKey = `${target.sectionId}:${value}`; + + if (seen.has(dedupeKey)) { + return ""; + } + + seen.add(dedupeKey); + + const groupTitle = target.semanticGroup?.title ?? target.title; + const role = target.semanticGroup?.fieldRole ?? target.contentField?.role ?? target.kind; + + return `[${target.number}] ${groupTitle} / ${role}\n${value}`; + }) + .filter(Boolean) + .join("\n\n"); + + return contextText.length > 80_000 ? `${contextText.slice(0, 80_000).trim()}\n...` : contextText; +} + +function buildVisualComposerSeedInputs( + pageId: string | null, + pageTitle: string | null, + targets: WorkspaceTarget[], + sectionTextMap: WorkspaceSectionTextMap | undefined +): VisualComposerSeedInput[] { + if (!pageId) { + return []; + } + + const pageContextText = buildVisualComposerPageContextText(targets, sectionTextMap); + + return targets + .filter((target) => target.permission === "editable" && target.kind !== "media") + .map((target) => ({ + currentValue: getWorkspaceTargetDraftText(target, sectionTextMap), + field: getVisualComposerSeedField(target), + id: target.id, + instruction: target.rewriteInstruction ?? target.permissionReason, + keywordPhrases: target.keywordBindings?.map((binding) => binding.phrase).filter(Boolean), + pageContextText: pageContextText || null, + pageId, + pageTitle, + semanticGroup: target.semanticGroup + ? { + confidence: target.semanticGroup.confidence, + evidence: target.semanticGroup.evidence, + fieldCount: target.semanticGroup.fieldCount, + fieldOrder: target.semanticGroup.fieldOrder, + fieldRole: target.semanticGroup.fieldRole, + groupText: buildVisualComposerGroupText(target.semanticGroup.id, targets, sectionTextMap), + id: target.semanticGroup.id, + source: target.semanticGroup.source, + title: target.semanticGroup.title + } + : undefined, + sourcePath: target.contentField?.sourcePath ?? null, + title: target.title, + workspaceSectionId: target.sectionId + })) + .filter((seed) => seed.currentValue.trim().length > 0); +} + +function getPatchSourceMatchLabel(match: PatchDryRunContract["checks"][number]["sourceMatch"] | undefined) { + if (!match) { + return "match unknown"; + } + + const coverage = `${Math.round(match.coverage * 100)}%`; + const labels = { + exact_raw: "raw exact", + exact_visible: "visible exact", + none: "not found", + page_token_coverage: "page tokens", + selector_token_coverage: "section tokens" + }; + + return `${labels[match.strategy]} · ${coverage} · ${match.matchedTokenCount}/${match.totalTokenCount}`; +} + +function getPatchBlockerScopeItem( + check: PatchDryRunContract["checks"][number], + scopeItems: WorkspaceScopeItem[] +) { + const checkSourceKey = normalizePortfolioMatchKey(check.sourcePath); + + if (!checkSourceKey) { + return null; + } + + return ( + scopeItems.find((scopeItem) => normalizePortfolioMatchKey(scopeItem.sourcePath) === checkSourceKey) ?? + scopeItems.find((scopeItem) => normalizePortfolioMatchKey(scopeItem.pathLabel).includes(checkSourceKey)) ?? + null + ); +} + function getModelTaskTypeLabel(taskType: SeoModelTaskType) { const labels = { "seo.context_review": "Проверка контекста", @@ -4762,9 +5696,14 @@ type WorkspaceScopeItem = { kind: "page" | "section"; pageId: string; sectionId?: string; + editableFields: ProjectScanSummary["siteSections"][number]["editableFields"]; + semanticBlock: ProjectScanSummary["siteSections"][number]["semanticBlock"]; + headings: string[]; title: string; pathLabel: string; + contentSourcePath: string | null; sourcePath: string; + template: string | null; previewSourcePath: string | null; previewTargetSelector: string | null; previewTargetIndex: number | null; @@ -4774,7 +5713,9 @@ type WorkspaceScopeItem = { function getPageWorkspaceScopeItem(page: ProjectScanSummary["pages"][number], index: number): WorkspaceScopeItem { return { + headings: page.headings.map((heading) => heading.text).filter(Boolean), kind: "page", + editableFields: [], order: getWorkspaceScopeOrder(page, index), pageId: page.pageId, pathLabel: getPagePathLabel(page), @@ -4783,7 +5724,10 @@ function getPageWorkspaceScopeItem(page: ProjectScanSummary["pages"][number], in previewTargetIndex: null, previewTargetSelector: page.previewTargetSelector, scopeId: getPageScopeId(page), + contentSourcePath: null, + semanticBlock: null, sourcePath: page.sourcePath, + template: null, title: getPageTitle(page) }; } @@ -4803,21 +5747,58 @@ function getSectionWorkspaceScopeItem( const indexSuffix = section.previewTargetIndex != null ? ` #${section.previewTargetIndex + 1}` : ""; return { + headings: section.headings.map((heading) => heading.text).filter(Boolean), kind: "section", + editableFields: section.editableFields, order: section.order ?? fallbackOrder, pageId: section.pageId, - pathLabel: `${section.sourcePath} · секция главной${indexSuffix}`, + pathLabel: `${section.sourcePath} · секция сайта${indexSuffix}`, previewKind: section.previewKind, previewSourcePath: section.previewSourcePath, previewTargetIndex: section.previewTargetIndex, previewTargetSelector: section.previewTargetSelector, scopeId: getSectionScopeId(section), sectionId: section.sectionId, + contentSourcePath: section.contentSourcePath, + semanticBlock: section.semanticBlock, sourcePath: section.sourcePath, + template: section.template, title: section.title }; } +function isSafeWorkspaceDomIdFragment(value: string | null | undefined) { + return Boolean(value && /^[a-zA-Z][\w:-]*$/.test(value)); +} + +function getWorkspaceScopedAnchorFallbackSelector(scopeItem: WorkspaceScopeItem | null | undefined) { + if ( + !scopeItem || + scopeItem.kind !== "section" || + (scopeItem.previewTargetIndex ?? 0) <= 0 || + !scopeItem.sectionId || + !scopeItem.previewTargetSelector?.startsWith("#") + ) { + return null; + } + + const anchorId = scopeItem.previewTargetSelector.slice(1); + + if (!isSafeWorkspaceDomIdFragment(scopeItem.sectionId) || !isSafeWorkspaceDomIdFragment(anchorId)) { + return null; + } + + return `#${scopeItem.sectionId}-${anchorId}`; +} + +function getWorkspaceScopePreviewFocusSelector(scopeItem: WorkspaceScopeItem | null | undefined) { + return getWorkspaceScopedAnchorFallbackSelector(scopeItem) ?? scopeItem?.previewTargetSelector ?? null; +} + +function getWorkspaceScopePreviewFocusIndex(scopeItem: WorkspaceScopeItem | null | undefined) { + return getWorkspaceScopedAnchorFallbackSelector(scopeItem) ? 0 : scopeItem?.previewTargetIndex ?? null; +} + function getSelectedWorkspaceScopeItems(scan: ProjectScanSummary): WorkspaceScopeItem[] { const sectionPageIds = new Set( scan.siteSections @@ -4843,6 +5824,164 @@ function getSelectedWorkspaceScopeItems(scan: ProjectScanSummary): WorkspaceScop }); } +function buildStrategySitePortfolioRows(input: { + keywordRows: StrategyKeywordDecisionRow[]; + landing: string | null; + scanSummary: ProjectScanSummary | null | undefined; + semanticAnalysis: SemanticAnalysisRun | null; +}): StrategySitePortfolioRow[] { + if (!input.scanSummary) { + return []; + } + + const selectedScopeItems = getSelectedWorkspaceScopeItems(input.scanSummary); + const scanPageById = new Map(input.scanSummary.pages.map((page) => [page.pageId, page])); + const landingPlanByPath = new Map( + (input.semanticAnalysis?.landingPagePlan ?? []).map((page) => [normalizePortfolioMatchKey(page.path), page]) + ); + const semanticClusters = input.semanticAnalysis?.clusters ?? []; + const semanticClusterById = new Map(semanticClusters.map((cluster) => [cluster.id, cluster])); + const semanticClusterByTitle = new Map(semanticClusters.map((cluster) => [cluster.title, cluster])); + const semanticPageById = new Map((input.semanticAnalysis?.pages ?? []).map((page) => [page.pageId, page])); + const semanticPageBySourcePath = new Map( + (input.semanticAnalysis?.pages ?? []).map((page) => [normalizePortfolioMatchKey(page.sourcePath), page]) + ); + const seedCandidatesByClusterId = new Map(); + + for (const seed of input.semanticAnalysis?.seedCandidates ?? []) { + const seeds = seedCandidatesByClusterId.get(seed.clusterId) ?? []; + seeds.push(seed.phrase); + seedCandidatesByClusterId.set(seed.clusterId, seeds); + } + + const landingKey = normalizePortfolioMatchKey(input.landing); + const fallbackClusters = getStrategyPortfolioFallbackClusters(input.keywordRows); + + return selectedScopeItems + .map((item, index) => { + const scanPage = scanPageById.get(item.pageId) ?? null; + const matchKeys = new Set(); + + addPortfolioMatchKey(matchKeys, scanPage?.urlPath); + addPortfolioMatchKey(matchKeys, scanPage?.sourcePath); + addPortfolioMatchKey(matchKeys, scanPage?.pageId); + addPortfolioMatchKey(matchKeys, item.sourcePath); + addPortfolioMatchKey(matchKeys, item.pageId); + addPortfolioMatchKey(matchKeys, item.sectionId); + + const landingPlan = Array.from(matchKeys) + .map((key) => landingPlanByPath.get(key)) + .find((page): page is NonNullable[number] => Boolean(page)); + const semanticPage = + item.kind === "page" + ? (semanticPageById.get(item.pageId) ?? + semanticPageBySourcePath.get(normalizePortfolioMatchKey(item.sourcePath)) ?? + null) + : null; + const mappedRows = input.keywordRows.filter((row) => { + const targetKey = normalizePortfolioMatchKey(row.targetPath); + const relatedKey = normalizePortfolioMatchKey(row.relatedLanding); + + return (targetKey && matchKeys.has(targetKey)) || (relatedKey && matchKeys.has(relatedKey)); + }); + const mappedDemand = mappedRows.reduce((sum, row) => sum + (row.frequency ?? 0), 0); + const matchedClusterIds = [ + ...(semanticPage?.matchedClusters ?? []), + ...(landingPlan?.clusters.map((cluster) => cluster.clusterId) ?? []), + ...mappedRows + .map((row) => semanticClusterByTitle.get(row.clusterTitle)?.id) + .filter((clusterId): clusterId is string => Boolean(clusterId)) + ]; + const sourceMatchedClusters = semanticClusters.filter((cluster) => + cluster.sourceRefs.some((ref) => semanticSourceMatchesPortfolioKeys(ref, matchKeys)) + ); + const directSemanticClusters = Array.from( + new Map( + [ + ...matchedClusterIds.map((clusterId) => semanticClusterById.get(clusterId)).filter(Boolean), + ...sourceMatchedClusters + ].map((cluster) => [cluster!.id, cluster!]) + ).values() + ); + const contextSemanticClusters = + directSemanticClusters.length > 0 ? directSemanticClusters : getStrategyPortfolioContextClusters(item, semanticClusters, matchKeys); + const semanticClustersForItem = contextSemanticClusters.length > 0 ? contextSemanticClusters : fallbackClusters + .map((clusterTitle) => semanticClusterByTitle.get(clusterTitle)) + .filter((cluster): cluster is NonNullable[number] => Boolean(cluster)); + const directClusters = semanticClustersForItem.map((cluster) => cluster.title); + const clusters = (directClusters.length > 0 ? directClusters : fallbackClusters).slice(0, 4); + const sourceMatchedSnippets = semanticClustersForItem.flatMap((cluster) => + cluster.evidenceSnippets.filter((snippet) => semanticSourceMatchesPortfolioKeys(snippet, matchKeys)) + ); + const sourceMatchedRefs = semanticClustersForItem.flatMap((cluster) => + cluster.sourceRefs.filter((ref) => semanticSourceMatchesPortfolioKeys(ref, matchKeys)) + ); + const semanticAnchors = getUniqueLimitedStrings( + [ + ...item.headings, + ...sourceMatchedSnippets.flatMap((snippet) => snippet.matchedTerms), + ...semanticClustersForItem.flatMap((cluster) => cluster.matchedTerms), + ...(landingPlan?.clusters.map((cluster) => cluster.targetIntent) ?? []) + ], + 8 + ); + const queryExamples = getUniqueLimitedStrings( + [ + ...(landingPlan?.seedPhrases ?? []), + ...mappedRows.map((row) => row.phrase), + ...semanticClustersForItem.flatMap((cluster) => cluster.queryExamples), + ...semanticClustersForItem.flatMap((cluster) => seedCandidatesByClusterId.get(cluster.id) ?? []) + ], + 8 + ); + const sourceSignals = getUniqueLimitedStrings( + sourceMatchedRefs.map((ref) => `${ref.title} · ${ref.hits} совп.`), + 4 + ); + const isCurrentLanding = Boolean(landingKey && matchKeys.has(landingKey)); + const readinessScore = landingPlan?.readinessScore ?? null; + const readinessLabel = + readinessScore !== null + ? `семантическая готовность ${readinessScore}%` + : clusters.length > 0 + ? `семантика: ${clusters.length} кластер(а)` + : "в scope"; + const path = item.kind === "section" ? item.sourcePath : (scanPage?.urlPath ?? item.sourcePath); + + return { + action: landingPlan?.action ?? (mappedDemand > 0 ? "strengthen" : "validate"), + clusters, + demand: mappedDemand, + id: item.scopeId, + path, + queryExamples, + readinessLabel, + readinessScore, + semanticAnchors, + sourceKind: item.kind, + sourceSignals, + status: isCurrentLanding + ? "выбрано сейчас" + : mappedDemand > 0 + ? "есть спрос, не первая ставка" + : item.kind === "section" + ? "выбранный блок сайта" + : "выбрано в scope", + subtitle: item.pathLabel, + title: item.title, + order: index + }; + }) + .sort((left, right) => { + const leftIsLanding = normalizePortfolioMatchKey(left.path) === landingKey ? 1 : 0; + const rightIsLanding = normalizePortfolioMatchKey(right.path) === landingKey ? 1 : 0; + + return rightIsLanding - leftIsLanding || right.demand - left.demand || left.order - right.order; + }) + .map(({ order: _order, ...row }) => row) + .slice(0, 24); +} + function getSelectedWorkspaceIssueCount(scan: ProjectScanSummary | null | undefined, scopeItems: WorkspaceScopeItem[]) { if (!scan?.audit) { return 0; @@ -4861,9 +6000,38 @@ function getWorkspaceUiKeyFromWorkspaceKey(workspaceKey: string) { return workspaceKey.split(":")[0] ?? workspaceKey; } +function getWorkspaceScopeIdFromWorkspaceKey(workspaceKey: string) { + return workspaceKey.split(":").slice(1).join(":"); +} + type WorkspaceSection = PageWorkspace["sections"][number]; type WorkspaceSectionTextMap = Record; +type WorkspaceContentFieldMeta = { + fieldId: string | null; + fieldPath: string; + group: string | null; + kind: string; + order: number; + renderAs: string | null; + role: string; + scopeId: string; + sectionId: string; + sourcePath: string; +}; + +type WorkspaceSemanticGroup = { + confidence: number; + evidence: string[]; + fieldCount: number; + fieldOrder: number; + fieldRole: string; + id: string; + source: "source_object" | "visual_section" | "workspace_section"; + title: string; + visualGroupMode?: "auto" | "single"; +}; + type WorkspaceTarget = { id: string; number: number; @@ -4878,30 +6046,625 @@ type WorkspaceTarget = { originalText: string; workingText: string; issues: AuditIssue[]; + source: "audit_issue" | "content_field" | "rewrite_slot" | "workspace_section"; + permission: "editable" | "locked" | "read_only"; + permissionLabel: string; + permissionReason: string; + rewriteInstruction?: string; + rewriteSlotStatus?: RewriteDiffContract["targets"][number]["slots"][number]["status"]; + keywordBindings?: RewriteDiffContract["targets"][number]["slots"][number]["keywordBindings"]; + contentField?: WorkspaceContentFieldMeta; + semanticGroup?: WorkspaceSemanticGroup; + semanticBlock?: RewriteDiffContract["targets"][number]["slots"][number]["semanticBlock"]; }; type WorkspacePreviewMode = "desktop" | "mobile"; -type WorkspaceDrawerPanel = "pages" | "issues"; +type WorkspaceDrawerPanel = "pages" | "issues" | "rewrite"; +type WorkspaceExcludedTargetMap = Record>; -const WORKSPACE_DRAWER_ORDER: WorkspaceDrawerPanel[] = ["pages", "issues"]; +const WORKSPACE_DRAWER_ORDER: WorkspaceDrawerPanel[] = ["pages", "rewrite", "issues"]; +const WORKSPACE_MANUAL_PREVIEW_SCOPE_LOCK_MS = 6000; const WORKSPACE_PREVIEW_MODES: Array<{ id: WorkspacePreviewMode; label: string }> = [ { id: "desktop", label: "Desktop" }, { id: "mobile", label: "Mobile" } ]; +const SEMANTIC_BLOCK_EDITOR_ENABLED = true; +const SEMANTIC_BLOCK_OVERRIDES_ENABLED = true; + type PreviewMarker = { targetId: string; number: number; label: string; top: number; left: number; + width?: number; + height?: number; }; +type WorkspaceSemanticBlockOverride = { + anchors?: WorkspaceSemanticBlockAnchor[]; + capturedEntities?: WorkspaceSemanticBlockCapturedEntity[]; + geometry?: WorkspaceSemanticBlockGeometry; + id: string; + title: string; + targetIds: string[]; +}; + +type WorkspaceSemanticBlockDraftBox = WorkspaceSemanticBlockOverride & { + height?: number; + left?: number; + top?: number; + width?: number; +}; + +type WorkspaceSemanticBlockAnchor = { + entityType: string; + fingerprint?: { + mediaSrc?: string; + tagName?: string; + text?: string; + targetId?: string; + }; + id: string; + pageId?: string; + scopeId?: string; + selector?: string; + selectorIndex?: number; + source: "captured_dom" | "manual_scope" | "workspace_target"; + targetId?: string; + text?: string; +}; + +type WorkspaceSemanticBlockCapturedEntity = { + id: string; + kind: string; + selector: string; + selectorIndex: number; + text?: string; +}; + +type WorkspaceSemanticBlockGeometry = { + anchorBounds?: { + height: number; + left: number; + top: number; + width: number; + }; + documentHeight?: number; + documentWidth?: number; + height: number; + left: number; + offsets?: { + bottom: number; + left: number; + right: number; + top: number; + }; + top: number; + width: number; +}; + +const SEMANTIC_BLOCK_OVERRIDES_STORAGE_KEY = "seo-mode.semanticBlockOverrides.v3"; +const WORKSPACE_UI_CONTEXT_STORAGE_KEY = "seo-mode.workspaceUiContext.v1"; + +function sanitizeSemanticBlockCapturedEntities(value: unknown): WorkspaceSemanticBlockCapturedEntity[] { + if (!Array.isArray(value)) { + return []; + } + + return value.flatMap((entity): WorkspaceSemanticBlockCapturedEntity[] => { + if (!entity || typeof entity !== "object") { + return []; + } + + const record = entity as Record; + const selector = typeof record.selector === "string" && record.selector.trim() ? record.selector.trim() : ""; + + if (!selector) { + return []; + } + + return [ + { + id: typeof record.id === "string" && record.id.trim() ? record.id.trim() : selector, + kind: typeof record.kind === "string" && record.kind.trim() ? record.kind.trim() : "element", + selector, + selectorIndex: typeof record.selectorIndex === "number" && Number.isFinite(record.selectorIndex) ? Math.max(0, record.selectorIndex) : 0, + text: typeof record.text === "string" && record.text.trim() ? record.text.trim().slice(0, 500) : undefined + } + ]; + }).slice(0, 80); +} + +function sanitizeSemanticBlockAnchors(value: unknown): WorkspaceSemanticBlockAnchor[] { + if (!Array.isArray(value)) { + return []; + } + + const anchorsByKey = new Map(); + + value.forEach((anchor) => { + if (!anchor || typeof anchor !== "object") { + return; + } + + const record = anchor as Record; + const source = + record.source === "workspace_target" || record.source === "manual_scope" || record.source === "captured_dom" + ? record.source + : "captured_dom"; + const selector = typeof record.selector === "string" && record.selector.trim() ? record.selector.trim() : undefined; + const targetId = typeof record.targetId === "string" && record.targetId.trim() ? record.targetId.trim() : undefined; + const scopeId = typeof record.scopeId === "string" && record.scopeId.trim() ? record.scopeId.trim() : undefined; + const pageId = typeof record.pageId === "string" && record.pageId.trim() ? record.pageId.trim() : undefined; + const id = typeof record.id === "string" && record.id.trim() + ? record.id.trim() + : [source, targetId, selector, scopeId, pageId].filter(Boolean).join(":"); + + if (!id || (!selector && !targetId && !scopeId && !pageId)) { + return; + } + + const fingerprintRecord = + record.fingerprint && typeof record.fingerprint === "object" + ? (record.fingerprint as Record) + : {}; + const cleanAnchor: WorkspaceSemanticBlockAnchor = { + entityType: typeof record.entityType === "string" && record.entityType.trim() ? record.entityType.trim() : "element", + fingerprint: { + mediaSrc: + typeof fingerprintRecord.mediaSrc === "string" && fingerprintRecord.mediaSrc.trim() + ? fingerprintRecord.mediaSrc.trim().slice(0, 500) + : undefined, + tagName: + typeof fingerprintRecord.tagName === "string" && fingerprintRecord.tagName.trim() + ? fingerprintRecord.tagName.trim().slice(0, 80) + : undefined, + text: + typeof fingerprintRecord.text === "string" && fingerprintRecord.text.trim() + ? fingerprintRecord.text.trim().slice(0, 500) + : undefined, + targetId: + typeof fingerprintRecord.targetId === "string" && fingerprintRecord.targetId.trim() + ? fingerprintRecord.targetId.trim().slice(0, 160) + : undefined + }, + id, + pageId, + scopeId, + selector, + selectorIndex: typeof record.selectorIndex === "number" && Number.isFinite(record.selectorIndex) ? Math.max(0, record.selectorIndex) : 0, + source, + targetId, + text: typeof record.text === "string" && record.text.trim() ? record.text.trim().slice(0, 500) : undefined + }; + const key = [ + cleanAnchor.source, + cleanAnchor.targetId ?? "", + cleanAnchor.selector ?? "", + cleanAnchor.selectorIndex ?? 0, + cleanAnchor.scopeId ?? "", + cleanAnchor.pageId ?? "", + cleanAnchor.entityType + ].join("::"); + anchorsByKey.set(key, cleanAnchor); + }); + + return Array.from(anchorsByKey.values()).slice(0, 120); +} + +function getAnchorsFromCapturedEntities(entities: WorkspaceSemanticBlockCapturedEntity[]): WorkspaceSemanticBlockAnchor[] { + return entities.map((entity) => ({ + entityType: entity.kind, + fingerprint: { + text: entity.text + }, + id: `captured:${entity.selector}:${entity.selectorIndex}`, + selector: entity.selector, + selectorIndex: entity.selectorIndex, + source: "captured_dom", + text: entity.text + })); +} + +function sanitizeSemanticBlockGeometry(value: unknown): WorkspaceSemanticBlockGeometry | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + + const record = value as Record; + const left = typeof record.left === "number" && Number.isFinite(record.left) ? record.left : null; + const top = typeof record.top === "number" && Number.isFinite(record.top) ? record.top : null; + const width = typeof record.width === "number" && Number.isFinite(record.width) ? record.width : null; + const height = typeof record.height === "number" && Number.isFinite(record.height) ? record.height : null; + + if (left === null || top === null || width === null || height === null) { + return undefined; + } + + const anchorRecord = record.anchorBounds && typeof record.anchorBounds === "object" ? record.anchorBounds as Record : null; + const offsetsRecord = record.offsets && typeof record.offsets === "object" ? record.offsets as Record : null; + const anchorBounds = + typeof anchorRecord?.left === "number" && + typeof anchorRecord?.top === "number" && + typeof anchorRecord?.width === "number" && + typeof anchorRecord?.height === "number" + ? { + height: anchorRecord.height, + left: anchorRecord.left, + top: anchorRecord.top, + width: anchorRecord.width + } + : undefined; + const offsets = + typeof offsetsRecord?.left === "number" && + typeof offsetsRecord?.top === "number" && + typeof offsetsRecord?.right === "number" && + typeof offsetsRecord?.bottom === "number" + ? { + bottom: offsetsRecord.bottom, + left: offsetsRecord.left, + right: offsetsRecord.right, + top: offsetsRecord.top + } + : undefined; + + return { + anchorBounds, + documentHeight: typeof record.documentHeight === "number" && Number.isFinite(record.documentHeight) ? record.documentHeight : undefined, + documentWidth: typeof record.documentWidth === "number" && Number.isFinite(record.documentWidth) ? record.documentWidth : undefined, + height: Math.max(2, height), + left: Math.max(0, left), + offsets, + top: Math.max(0, top), + width: Math.max(2, width) + }; +} + +function getCollapsedCarouselOverrideTitle(value: string) { + return value.replace(/\s+\d+$/g, "").trim() || value; +} + +function getMergedSemanticBlockGeometry( + current: WorkspaceSemanticBlockGeometry | undefined, + next: WorkspaceSemanticBlockGeometry | undefined +): WorkspaceSemanticBlockGeometry | undefined { + if (!current) return next; + if (!next) return current; + + const left = Math.min(current.left, next.left); + const top = Math.min(current.top, next.top); + const right = Math.max(current.left + current.width, next.left + next.width); + const bottom = Math.max(current.top + current.height, next.top + next.height); + + return { + documentHeight: Math.max(current.documentHeight ?? 0, next.documentHeight ?? 0) || (current.documentHeight ?? next.documentHeight), + documentWidth: Math.max(current.documentWidth ?? 0, next.documentWidth ?? 0) || (current.documentWidth ?? next.documentWidth), + height: Math.max(2, bottom - top), + left, + top, + width: Math.max(2, right - left) + }; +} + +function mergeSemanticBlockAnchors( + current: WorkspaceSemanticBlockAnchor[] | undefined, + next: WorkspaceSemanticBlockAnchor[] | undefined +) { + return sanitizeSemanticBlockAnchors([...(current ?? []), ...(next ?? [])]); +} + +function collapseCarouselVisualSemanticBlockOverrides(overrides: WorkspaceSemanticBlockOverride[]) { + const merged = new Map(); + const result: WorkspaceSemanticBlockOverride[] = []; + + overrides.forEach((override) => { + const visualMatch = /^(.*):visual-\d+$/i.exec(override.id); + const titleMatch = /^(.*?)(?:\s+|[-_#])\d+$/i.exec(override.title); + const baseTitle = (titleMatch?.[1] ?? override.title).trim(); + const hasGeometry = Boolean(override.geometry); + const baseId = + visualMatch?.[1] ?? + (titleMatch && hasGeometry && CAROUSEL_LIKE_SEMANTIC_PATTERN.test(baseTitle.toLocaleLowerCase("ru-RU")) + ? `manual-carousel:${normalizeSemanticBlockIdFragment(baseTitle).toLocaleLowerCase("ru-RU")}` + : ""); + const searchableValue = `${baseId} ${baseTitle} ${override.title}`.toLocaleLowerCase("ru-RU"); + + if (!baseId || !CAROUSEL_LIKE_SEMANTIC_PATTERN.test(searchableValue)) { + result.push(override); + return; + } + + const band = Math.round((override.geometry?.top ?? 0) / Math.max(220, override.geometry?.height ?? 220)); + const mergeKey = baseId.startsWith("manual-carousel:") ? `${baseId}:band-${band}` : baseId; + const current = merged.get(mergeKey); + + if (!current) { + const collapsed = { + ...override, + id: baseId, + title: getCollapsedCarouselOverrideTitle(override.title) + }; + merged.set(mergeKey, collapsed); + result.push(collapsed); + return; + } + + current.targetIds = Array.from(new Set([...current.targetIds, ...override.targetIds])); + current.anchors = mergeSemanticBlockAnchors(current.anchors, override.anchors); + current.capturedEntities = sanitizeSemanticBlockCapturedEntities([...(current.capturedEntities ?? []), ...(override.capturedEntities ?? [])]); + current.geometry = getMergedSemanticBlockGeometry(current.geometry, override.geometry); + }); + + return result; +} + +function sanitizeStoredWorkspaceSemanticBlockOverrides(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + + const entries = Object.entries(value as Record).flatMap(([workspaceKey, overrides]) => { + if (!workspaceKey || !Array.isArray(overrides)) { + return []; + } + + const cleanOverrides = overrides.flatMap((override): WorkspaceSemanticBlockOverride[] => { + if (!override || typeof override !== "object") { + return []; + } + + const record = override as Record; + const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : ""; + const title = typeof record.title === "string" && record.title.trim() ? record.title.trim() : "Semantic block"; + const targetIds = Array.isArray(record.targetIds) + ? Array.from(new Set(record.targetIds.filter((targetId): targetId is string => typeof targetId === "string" && targetId.trim().length > 0))) + : []; + const capturedEntities = sanitizeSemanticBlockCapturedEntities(record.capturedEntities); + const anchors = sanitizeSemanticBlockAnchors([ + ...(Array.isArray(record.anchors) ? record.anchors : []), + ...getAnchorsFromCapturedEntities(capturedEntities) + ]); + const geometry = sanitizeSemanticBlockGeometry(record.geometry); + + return id && (targetIds.length > 0 || capturedEntities.length > 0 || anchors.length > 0) + ? [{ anchors, capturedEntities, geometry, id, targetIds, title }] + : []; + }); + + return [[workspaceKey, collapseCarouselVisualSemanticBlockOverrides(cleanOverrides)] as const]; + }); + + return Object.fromEntries(entries); +} + +function readStoredWorkspaceSemanticBlockOverrides(): Record { + if (typeof window === "undefined") { + return {}; + } + + try { + return sanitizeStoredWorkspaceSemanticBlockOverrides( + JSON.parse(window.localStorage.getItem(SEMANTIC_BLOCK_OVERRIDES_STORAGE_KEY) ?? "{}") + ); + } catch { + return {}; + } +} + +function sanitizeSemanticBlockDraftBoxes(value: unknown): WorkspaceSemanticBlockDraftBox[] { + if (!Array.isArray(value)) { + return []; + } + + return value.flatMap((box): WorkspaceSemanticBlockDraftBox[] => { + if (!box || typeof box !== "object") { + return []; + } + + const record = box as Record; + const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : ""; + const title = typeof record.title === "string" && record.title.trim() ? record.title.trim() : "Semantic block"; + const targetIds = Array.isArray(record.targetIds) + ? Array.from(new Set(record.targetIds.filter((targetId): targetId is string => typeof targetId === "string" && targetId.trim().length > 0))) + : []; + const capturedEntities = sanitizeSemanticBlockCapturedEntities(record.capturedEntities); + const anchors = sanitizeSemanticBlockAnchors([ + ...(Array.isArray(record.anchors) ? record.anchors : []), + ...getAnchorsFromCapturedEntities(capturedEntities) + ]); + const geometry = sanitizeSemanticBlockGeometry(record.geometry); + + if (!id) { + return []; + } + + return [ + { + anchors, + capturedEntities, + geometry, + height: typeof record.height === "number" ? record.height : undefined, + id, + left: typeof record.left === "number" ? record.left : undefined, + targetIds, + title, + top: typeof record.top === "number" ? record.top : undefined, + width: typeof record.width === "number" ? record.width : undefined + } + ]; + }); +} + +function commitSemanticBlockDraftBoxes(boxes: WorkspaceSemanticBlockDraftBox[] | undefined): WorkspaceSemanticBlockOverride[] { + const overrides = (boxes ?? []).flatMap((box): WorkspaceSemanticBlockOverride[] => { + const targetIds = Array.from(new Set(box.targetIds.filter((targetId) => targetId.trim().length > 0))); + const capturedEntities = sanitizeSemanticBlockCapturedEntities(box.capturedEntities); + const anchors = sanitizeSemanticBlockAnchors([ + ...(box.anchors ?? []), + ...targetIds.map((targetId) => ({ + entityType: "workspace_target", + id: `target:${targetId}`, + source: "workspace_target", + targetId + })), + ...getAnchorsFromCapturedEntities(capturedEntities) + ]); + const geometry = sanitizeSemanticBlockGeometry(box.geometry); + + return targetIds.length > 0 || capturedEntities.length > 0 || anchors.length > 0 + ? [ + { + anchors, + capturedEntities, + geometry, + id: box.id, + targetIds, + title: box.title + } + ] + : []; + }); + + return collapseCarouselVisualSemanticBlockOverrides(overrides); +} + +function getSemanticBlockOverridesFromModels( + semanticBlocks: SemanticBlockModel[] | undefined, + workspaceKey: string +): WorkspaceSemanticBlockOverride[] { + const overrides = (semanticBlocks ?? []).flatMap((block): WorkspaceSemanticBlockOverride[] => { + if (block.workspaceKey !== workspaceKey) { + return []; + } + + const capturedEntities = sanitizeSemanticBlockCapturedEntities(block.capturedEntities); + const anchors = sanitizeSemanticBlockAnchors([ + ...block.anchors, + ...getAnchorsFromCapturedEntities(capturedEntities) + ]); + const geometry = sanitizeSemanticBlockGeometry(block.geometryCache); + const blockKey = block.blockKey || block.id; + const targetIds = Array.from(new Set(block.targetIds.filter(Boolean))); + + return blockKey && (targetIds.length > 0 || capturedEntities.length > 0 || anchors.length > 0) + ? [ + { + anchors, + capturedEntities, + geometry, + id: blockKey, + targetIds, + title: block.title || "Semantic block" + } + ] + : []; + }); + + return collapseCarouselVisualSemanticBlockOverrides(overrides); +} + +function buildSemanticBlockSourceBindings(override: WorkspaceSemanticBlockOverride, targets: WorkspaceTarget[]) { + const targetsById = new Map(targets.map((target) => [target.id, target])); + const anchorTargetIds = sanitizeSemanticBlockAnchors(override.anchors) + .map((anchor) => anchor.targetId) + .filter((targetId): targetId is string => Boolean(targetId)); + const targetIds = Array.from(new Set([...override.targetIds, ...anchorTargetIds])); + + return targetIds.flatMap((targetId) => { + const target = targetsById.get(targetId); + + if (!target) { + return []; + } + + return [ + { + contentField: target.contentField ?? null, + fallbackSelector: target.fallbackSelector, + kind: target.kind, + markerSelectors: target.markerSelectors, + permission: target.permission, + sectionId: target.sectionId, + selector: target.selector, + semanticGroup: target.semanticGroup + ? { + fieldCount: target.semanticGroup.fieldCount, + fieldOrder: target.semanticGroup.fieldOrder, + fieldRole: target.semanticGroup.fieldRole, + id: target.semanticGroup.id, + source: target.semanticGroup.source, + title: target.semanticGroup.title + } + : null, + source: target.source, + subtitle: target.subtitle, + targetId, + title: target.title + } + ]; + }); +} + +function buildSemanticBlockIdentity(override: WorkspaceSemanticBlockOverride, sourceBindings: unknown[]) { + const bindingRecords = sourceBindings.filter( + (binding): binding is Record => Boolean(binding) && typeof binding === "object" && !Array.isArray(binding) + ); + const contentFieldPaths = bindingRecords + .map((binding) => { + const contentField = binding.contentField; + return contentField && typeof contentField === "object" && !Array.isArray(contentField) + ? (contentField as { fieldPath?: unknown }).fieldPath + : null; + }) + .filter((value): value is string => typeof value === "string" && value.length > 0); + const selectors = bindingRecords + .flatMap((binding) => [binding.selector, ...(Array.isArray(binding.markerSelectors) ? binding.markerSelectors : [])]) + .filter((value): value is string => typeof value === "string" && value.length > 0); + const anchorKeys = sanitizeSemanticBlockAnchors(override.anchors).map((anchor) => + [anchor.source, anchor.targetId ?? "", anchor.selector ?? "", anchor.selectorIndex ?? 0, anchor.scopeId ?? "", anchor.pageId ?? ""].join("::") + ); + + return { + anchorKeys, + blockKey: override.id, + contentFieldPaths: Array.from(new Set(contentFieldPaths)), + selectors: Array.from(new Set(selectors)), + targetIds: Array.from(new Set(override.targetIds)), + title: override.title + }; +} + +function buildSemanticBlockMediaContext(sourceBindings: unknown[]) { + const mediaTargetIds = sourceBindings + .filter((binding): binding is Record => Boolean(binding) && typeof binding === "object" && !Array.isArray(binding)) + .filter((binding) => binding.kind === "media") + .map((binding) => binding.targetId) + .filter((value): value is string => typeof value === "string" && value.length > 0); + + return { + mediaTargetIds + }; +} + +function hasOwnRecordKey(record: Record, key: string) { + return Object.prototype.hasOwnProperty.call(record, key); +} + +type WorkspaceEditableField = WorkspaceScopeItem["editableFields"][number]; + function getInitialSectionTextMap(workspace: PageWorkspace): WorkspaceSectionTextMap { return Object.fromEntries(workspace.sections.map((section) => [section.id, section.workingText])); } +function getContentFieldDraftTextMap(contentDrafts: ContentFieldDraft[] | undefined): WorkspaceSectionTextMap { + return Object.fromEntries( + (contentDrafts ?? []).map((draft) => [`content-field:${draft.sectionId}:${draft.fieldPath}`, draft.workingText]) + ); +} + function buildWorkingTextFromSections(workspace: PageWorkspace, sectionTexts: WorkspaceSectionTextMap | undefined) { return workspace.sections .map((section) => { @@ -4912,6 +6675,113 @@ function buildWorkingTextFromSections(workspace: PageWorkspace, sectionTexts: Wo .trim(); } +function normalizeWorkspaceComparisonText(value: string) { + return value.replace(/\r\n/g, "\n").trim(); +} + +function getWorkspaceTargetDraftText(target: WorkspaceTarget, sectionTexts: WorkspaceSectionTextMap | undefined) { + return sectionTexts?.[target.sectionId] ?? target.workingText; +} + +function getWorkspaceTargetDraftStats(target: WorkspaceTarget, sectionTexts: WorkspaceSectionTextMap | undefined) { + const draftText = getWorkspaceTargetDraftText(target, sectionTexts); + const normalizedDraft = normalizeWorkspaceComparisonText(draftText); + const normalizedOriginal = normalizeWorkspaceComparisonText(target.originalText); + const normalizedSaved = normalizeWorkspaceComparisonText(target.workingText); + + return { + draftText, + hasUnsavedChanges: normalizedDraft !== normalizedSaved, + differsFromOriginal: normalizedDraft !== normalizedOriginal, + draftLength: draftText.length, + originalDelta: draftText.length - target.originalText.length, + savedDelta: draftText.length - target.workingText.length + }; +} + +function getWorkspacePreviewSectionTextMap( + _workspace: PageWorkspace, + sectionTexts: WorkspaceSectionTextMap | undefined, + targets: WorkspaceTarget[] +) { + if (!sectionTexts || targets.length === 0) { + return {}; + } + + return targets.reduce((snapshot, target) => { + const canPreviewTarget = + target.permission === "editable" && + target.kind !== "media" && + (Boolean(target.selector) || target.source === "content_field"); + const nextText = sectionTexts[target.sectionId]; + + if (!canPreviewTarget || typeof nextText !== "string") { + return snapshot; + } + + if (normalizeWorkspaceComparisonText(nextText) === normalizeWorkspaceComparisonText(target.workingText)) { + return snapshot; + } + + snapshot[target.sectionId] = nextText; + return snapshot; + }, {}); +} + +function getWorkspaceUnsavedSectionCount(workspace: PageWorkspace, sectionTexts: WorkspaceSectionTextMap | undefined) { + if (!sectionTexts) { + return 0; + } + + return workspace.sections.filter((section) => { + const draftText = sectionTexts[section.id] ?? section.workingText; + return normalizeWorkspaceComparisonText(draftText) !== normalizeWorkspaceComparisonText(section.workingText); + }).length; +} + +function isPersistableWorkspaceTarget(target: WorkspaceTarget) { + if (target.permission !== "editable") { + return false; + } + + if (target.source === "content_field") { + return Boolean(target.contentField); + } + + return target.kind !== "media"; +} + +function getWorkspaceUnsavedPersistableTargetCount( + targets: WorkspaceTarget[], + sectionTexts: WorkspaceSectionTextMap | undefined +) { + const seen = new Set(); + + return targets.reduce((count, target) => { + if (!isPersistableWorkspaceTarget(target) || !getWorkspaceTargetDraftStats(target, sectionTexts).hasUnsavedChanges) { + return count; + } + + const key = + target.source === "content_field" + ? `content:${target.contentField?.sectionId ?? target.sectionId}:${target.contentField?.fieldPath ?? target.id}` + : `section:${target.sectionId}`; + + if (seen.has(key)) { + return count; + } + + seen.add(key); + return count + 1; + }, 0); +} + +function getWorkspaceSavedOriginalChangeCount(workspace: PageWorkspace) { + return workspace.sections.filter( + (section) => normalizeWorkspaceComparisonText(section.workingText) !== normalizeWorkspaceComparisonText(section.originalText) + ).length; +} + function getSectionKindLabel(kind: WorkspaceSection["kind"]) { const labels: Record = { content_block: "Блок страницы", @@ -4923,6 +6793,37 @@ function getSectionKindLabel(kind: WorkspaceSection["kind"]) { return labels[kind]; } +function isWorkspaceGlobalSeoTarget(target: WorkspaceTarget) { + return target.kind === "meta" || target.sectionId === "seo-title" || target.sectionId === "seo-description" || target.sectionId === "seo-h1"; +} + +function isWorkspaceMediaLikeTarget(target: WorkspaceTarget) { + const value = `${target.title} ${target.subtitle} ${target.contentField?.fieldPath ?? ""} ${target.contentField?.group ?? ""} ${ + target.contentField?.kind ?? "" + }`.toLocaleLowerCase("ru-RU"); + + return ( + target.kind === "media" || + target.contentField?.renderAs === "attr" || + /video|media|poster|alt|icon|image|src|file|filename|window|файл|видео|икон|изображ|окно/.test(value) + ); +} + +function isSemanticBlockEditorTarget(target: WorkspaceTarget) { + if (isWorkspaceGlobalSeoTarget(target)) { + return false; + } + + if (isWorkspaceMediaLikeTarget(target)) { + return target.kind === "media" && Boolean(target.selector) && target.semanticGroup?.id === "semantic-block:media-assets"; + } + + return ( + target.permission === "editable" && + normalizeWorkspaceComparisonText(target.workingText || target.originalText).length >= 12 + ); +} + function normalizeIssueLookupText(value: string | null | undefined) { return (value ?? "").replace(/\s+/g, " ").trim().toLocaleLowerCase(); } @@ -4956,6 +6857,576 @@ function uniqueWorkspaceSections(sections: WorkspaceSection[]) { }); } +function getRewriteDiffTargetForPage(rewriteDiff: RewriteDiffContract | null | undefined, pageId: string | null | undefined) { + if (!rewriteDiff || !pageId) { + return null; + } + + return rewriteDiff.targets.find((target) => target.pageId === pageId) ?? null; +} + +function getRewriteSlotPermission(input: { + hasWorkspaceSection: boolean; + slot: RewriteDiffContract["targets"][number]["slots"][number]; +}) { + if (!input.hasWorkspaceSection) { + return { + permission: "locked" as const, + permissionLabel: "locked", + permissionReason: "Слот есть в rewrite diff, но секция не найдена в Page Workspace." + }; + } + + if (input.slot.blockers.length > 0 || input.slot.status !== "ready_for_model_review") { + return { + permission: "locked" as const, + permissionLabel: "locked", + permissionReason: input.slot.blockers.join(" ") || "Слот ждёт источник перед модельной правкой." + }; + } + + return { + permission: "editable" as const, + permissionLabel: "editable", + permissionReason: "Есть page/section binding, текущий текст и SEO-инструкция для будущего model review." + }; +} + +function isSafeVisualComposerFallbackSection(section: WorkspaceSection) { + const normalizedText = normalizeWorkspaceComparisonText(section.workingText || section.originalText); + + if (!section.selector || normalizedText.length === 0 || /{{|}}/.test(normalizedText)) { + return false; + } + + if (section.kind === "media") { + return false; + } + + if (section.kind === "meta") { + return normalizedText.length <= 240; + } + + if (section.kind === "heading_group") { + return normalizedText.length <= 700; + } + + return normalizedText.length <= 900; +} + +function getWorkspaceSectionSemanticGroup(section: WorkspaceSection, fallbackOrder: number): WorkspaceSemanticGroup { + return section.semanticGroup + ? { + confidence: section.semanticGroup.confidence, + evidence: section.semanticGroup.evidence, + fieldCount: section.semanticGroup.fieldCount, + fieldOrder: section.semanticGroup.fieldOrder, + fieldRole: section.semanticGroup.fieldRole, + id: section.semanticGroup.id, + source: section.semanticGroup.source, + title: section.semanticGroup.title + } + : { + confidence: 0.58, + evidence: ["workspace_section"], + fieldCount: 1, + fieldOrder: fallbackOrder, + fieldRole: section.kind, + id: `semantic-block:${section.id}`, + source: "workspace_section", + title: section.title + }; +} + +function getWorkspaceSemanticGroupSourceFromBlock( + source: NonNullable["source"] +): WorkspaceSemanticGroup["source"] { + if (source === "source_object" || source === "visual_section" || source === "workspace_section") { + return source; + } + + return "workspace_section"; +} + +function getWorkspaceSemanticGroupFromRewriteSlot( + slot: RewriteDiffContract["targets"][number]["slots"][number], + fieldOrder: number, + fieldCount: number +): WorkspaceSemanticGroup | undefined { + if (!slot.semanticBlock) { + return undefined; + } + + return { + confidence: 0.96, + evidence: [ + "persisted_semantic_block", + `semantic_block_source:${slot.semanticBlock.source}`, + ...slot.semanticBlock.targetIds.slice(0, 6).map((targetId) => `target:${targetId}`) + ], + fieldCount: Math.max(1, fieldCount), + fieldOrder: Math.min(fieldOrder, Math.max(0, fieldCount - 1)), + fieldRole: slot.field, + id: `semantic-block:${slot.semanticBlock.id}`, + source: getWorkspaceSemanticGroupSourceFromBlock(slot.semanticBlock.source), + title: slot.semanticBlock.title + }; +} + +function applyWorkspaceSemanticBlockOverrides( + targets: WorkspaceTarget[], + overrides: WorkspaceSemanticBlockOverride[] | undefined +) { + // Semantic block overrides are editor/model grouping only; they must not create or mutate site structure. + if (!overrides || overrides.length === 0) { + return targets; + } + + const overrideByTargetId = new Map(); + + overrides.forEach((override) => { + const anchoredTargetIds = sanitizeSemanticBlockAnchors(override.anchors) + .map((anchor) => anchor.targetId) + .filter((targetId): targetId is string => Boolean(targetId)); + const targetIds = Array.from(new Set([...override.targetIds, ...anchoredTargetIds])); + + targetIds.forEach((targetId, order) => { + overrideByTargetId.set(targetId, { override, order }); + }); + }); + + return targets.map((target) => { + const match = overrideByTargetId.get(target.id); + + if (!match) { + return target; + } + + const fieldCount = Math.max(1, match.override.targetIds.length); + + return { + ...target, + semanticGroup: { + confidence: 0.96, + evidence: ["manual_semantic_block", "visual_resize", "user_applied"], + fieldCount, + fieldOrder: Math.min(match.order, fieldCount - 1), + fieldRole: target.semanticGroup?.fieldRole ?? target.contentField?.role ?? target.kind, + id: `semantic-block:manual:${match.override.id}`, + source: target.semanticGroup?.source ?? "workspace_section", + title: match.override.title || target.semanticGroup?.title || target.title + } + } satisfies WorkspaceTarget; + }); +} + +function getWorkspaceTargetsForRewriteRuntime( + targets: WorkspaceTarget[], + overrides: WorkspaceSemanticBlockOverride[] | undefined +) { + return SEMANTIC_BLOCK_OVERRIDES_ENABLED ? applyWorkspaceSemanticBlockOverrides(targets, overrides) : targets; +} + +function filterExcludedWorkspaceTargets( + workspaceKey: string, + targets: WorkspaceTarget[], + excludedTargetIds: WorkspaceExcludedTargetMap +) { + const excludedForWorkspace = excludedTargetIds[workspaceKey]; + + if (!excludedForWorkspace) { + return targets; + } + + return targets.filter((target) => !excludedForWorkspace[target.id]); +} + +function getExcludedWorkspaceTargetCount(workspaceKey: string | null, excludedTargetIds: WorkspaceExcludedTargetMap) { + if (!workspaceKey) { + return 0; + } + + return Object.values(excludedTargetIds[workspaceKey] ?? {}).filter(Boolean).length; +} + +function buildWorkspaceSectionTargets(workspace: PageWorkspace, editable = false): WorkspaceTarget[] { + const sections = editable ? workspace.sections.filter(isSafeVisualComposerFallbackSection) : workspace.sections; + + return sections.map((section, index) => ({ + fallbackSelector: section.selector, + id: `section-${section.id}`, + issues: [], + kind: section.kind, + markerFallbackSelector: section.selector, + markerSelectors: section.selector ? [section.selector] : [], + number: index + 1, + originalText: section.originalText, + permission: editable ? "editable" : "read_only", + permissionLabel: editable ? "editable" : "context", + permissionReason: editable + ? "Fallback visual composer target: секцию можно править в working draft без записи в source tree." + : "SEO-план ещё не привязал эту секцию к правке; используем как контекст страницы.", + sectionId: section.id, + selector: section.selector, + semanticGroup: getWorkspaceSectionSemanticGroup(section, index), + source: "workspace_section", + subtitle: section.sourceHint, + title: section.title, + workingText: section.workingText + })); +} + +function normalizeSemanticBlockIdFragment(value: string) { + return value.replace(/[^a-zA-Z0-9а-яА-ЯёЁ._-]+/g, "-").replace(/^-+|-+$/g, ""); +} + +const CAROUSEL_LIKE_SEMANTIC_PATTERN = + /carousel|slider|slides?|swiper|slick|splide|keen|flickity|owl|gallery|galleries|rail|marquee|track|strip|media|videos?|images?|photos?|pictures?|poster|thumbnail|карусел|слайдер|слайды?|галере|медиа|видео|изображ|картин|фото|лента|витрин/; + +function isCarouselLikeContentFieldCollection(input: { + collectionName: string; + collectionPath: string; + field: WorkspaceEditableField; + scopeItem: WorkspaceScopeItem; +}) { + const value = [ + input.collectionName, + input.collectionPath, + input.field.group, + input.field.id, + input.field.kind, + input.field.label, + input.field.path, + input.field.renderAs, + input.field.role, + input.scopeItem.contentSourcePath, + input.scopeItem.template, + input.scopeItem.sourcePath, + input.scopeItem.title + ] + .filter(Boolean) + .join(" ") + .toLocaleLowerCase("ru-RU"); + + return CAROUSEL_LIKE_SEMANTIC_PATTERN.test(value); +} + +function getCarouselLikeSemanticBlockTitle(field: WorkspaceEditableField, collectionName: string) { + const baseLabel = field.group || getReadablePathProductLabel(collectionName) || "Карусель"; + + if (CAROUSEL_LIKE_SEMANTIC_PATTERN.test(baseLabel.toLocaleLowerCase("ru-RU"))) { + return baseLabel; + } + + return `${baseLabel} · карусель`; +} + +function isCarouselLikeWorkspaceTarget(target: WorkspaceTarget) { + if (target.semanticGroup?.visualGroupMode === "single") { + return true; + } + + const value = [ + target.contentField?.fieldPath, + target.contentField?.group, + target.contentField?.kind, + target.contentField?.renderAs, + target.contentField?.role, + target.contentField?.sourcePath, + target.id, + target.kind, + target.semanticGroup?.id, + target.semanticGroup?.source, + target.semanticGroup?.title, + target.subtitle, + target.title + ] + .filter(Boolean) + .join(" ") + .toLocaleLowerCase("ru-RU"); + + return CAROUSEL_LIKE_SEMANTIC_PATTERN.test(value); +} + +function getContentFieldSearchValue(scopeItem: WorkspaceScopeItem, field: WorkspaceEditableField) { + return [ + field.group, + field.id, + field.kind, + field.label, + field.path, + field.renderAs, + field.role, + scopeItem.contentSourcePath, + scopeItem.sourcePath, + scopeItem.template, + scopeItem.title + ] + .filter(Boolean) + .join(" ") + .toLocaleLowerCase("ru-RU"); +} + +function isTopLevelHeroContentField(scopeItem: WorkspaceScopeItem, field: WorkspaceEditableField, indexedSegmentIndex: number) { + if (indexedSegmentIndex > 1) { + return false; + } + + const value = getContentFieldSearchValue(scopeItem, field); + + return ( + field.role === "heading" || + field.role === "intro" || + /hero|cover|main|lead|intro|heading|headline|title|subtitle|h1|h2|заголов|подзаг|лид|облож|главн/.test(value) + ); +} + +function isFormLikeContentField(scopeItem: WorkspaceScopeItem, field: WorkspaceEditableField) { + return /form|input|field|email|mail|phone|tel|name|message|submit|button|cta|waitlist|lead|форма|имя|почт|телефон|сообщ|заяв|кноп/.test( + getContentFieldSearchValue(scopeItem, field) + ); +} + +function isLegalLikeContentField(scopeItem: WorkspaceScopeItem, field: WorkspaceEditableField) { + return /legal|terms|privacy|policy|copyright|license|consent|agreement|юрид|правов|политик|соглас|копирайт|лиценз/.test( + getContentFieldSearchValue(scopeItem, field) + ); +} + +function getReadableFieldPathLabel(value: string | null | undefined) { + const lastSegment = value?.split(".").filter(Boolean).slice(-1)[0] ?? value; + + return getReadablePathProductLabel(lastSegment?.replace(/[A-Z]/g, (match) => `-${match.toLocaleLowerCase("ru-RU")}`)); +} + +function getContentFieldSemanticBlockDraft(scopeItem: WorkspaceScopeItem, field: WorkspaceEditableField) { + const pathValue = field.path; + const pathSegments = pathValue.split("."); + const indexedSegmentIndex = pathSegments.findIndex((segment, index) => index > 1 && /^\d+$/.test(segment)); + + if (isTopLevelHeroContentField(scopeItem, field, indexedSegmentIndex)) { + return { + id: `semantic-block:${scopeItem.scopeId}:hero`, + title: "Заголовочный блок" + }; + } + + if (indexedSegmentIndex > 1) { + const collectionName = pathSegments[indexedSegmentIndex - 1] ?? "item"; + const collectionPath = pathSegments.slice(0, indexedSegmentIndex).join("."); + + if ( + isCarouselLikeContentFieldCollection({ + collectionName, + collectionPath, + field, + scopeItem + }) + ) { + const safeCollectionPath = normalizeSemanticBlockIdFragment(collectionPath); + + return { + id: `semantic-block:${scopeItem.scopeId}:${safeCollectionPath || normalizeSemanticBlockIdFragment(collectionName) || "carousel"}`, + title: getCarouselLikeSemanticBlockTitle(field, collectionName) + }; + } + } + + if (indexedSegmentIndex > 1) { + const collectionName = pathSegments[indexedSegmentIndex - 1] ?? "item"; + const collectionIndex = Number(pathSegments[indexedSegmentIndex] ?? 0); + const clusterPath = pathSegments.slice(0, indexedSegmentIndex + 1).join("."); + const collectionLabel = field.group || getReadableFieldPathLabel(collectionName) || "Элемент"; + + return { + id: `semantic-block:${scopeItem.scopeId}:${normalizeSemanticBlockIdFragment(clusterPath)}`, + title: `${collectionLabel} ${collectionIndex + 1}` + }; + } + + if (isFormLikeContentField(scopeItem, field)) { + return { + id: `semantic-block:${scopeItem.scopeId}:form`, + title: "Форма / CTA" + }; + } + + if (isLegalLikeContentField(scopeItem, field)) { + return { + id: `semantic-block:${scopeItem.scopeId}:legal`, + title: "Юридический блок" + }; + } + + const fallbackLabel = field.group ?? getReadableFieldPathLabel(pathValue) ?? scopeItem.title; + const groupId = normalizeSemanticBlockIdFragment(fallbackLabel || field.role || "content").toLocaleLowerCase("ru-RU"); + + return { + id: `semantic-block:${scopeItem.scopeId}:${groupId || field.id}`, + title: fallbackLabel + }; +} + +function getContentFieldSemanticBlockStats(scopeItem: WorkspaceScopeItem) { + const groups = new Map(); + + scopeItem.editableFields.forEach((field) => { + const draft = getContentFieldSemanticBlockDraft(scopeItem, field); + groups.set(draft.id, [...(groups.get(draft.id) ?? []), field]); + }); + + return groups; +} + +function buildContentFieldWorkspaceTargets( + scopeItem: WorkspaceScopeItem | null | undefined, + contentFieldTextMap: WorkspaceSectionTextMap | undefined +): WorkspaceTarget[] { + if (!scopeItem || scopeItem.kind !== "section" || scopeItem.editableFields.length === 0) { + return []; + } + + const semanticBlockStats = getContentFieldSemanticBlockStats(scopeItem); + + return scopeItem.editableFields.map((field, index) => { + const group = field.group ? `${field.group} · ` : ""; + const fieldId = `content-field:${scopeItem.sectionId ?? scopeItem.scopeId}:${field.path}`; + const workingText = contentFieldTextMap?.[fieldId] ?? field.value; + const semanticBlockDraft = getContentFieldSemanticBlockDraft(scopeItem, field); + const fieldsInBlock = semanticBlockStats.get(semanticBlockDraft.id) ?? [field]; + const fieldOrder = Math.max( + 0, + fieldsInBlock.findIndex((item) => item.path === field.path) + ); + + return { + contentField: { + fieldId: field.id, + fieldPath: field.path, + group: field.group, + kind: field.kind, + order: field.order, + renderAs: field.renderAs, + role: field.role, + scopeId: scopeItem.scopeId, + sectionId: scopeItem.sectionId ?? scopeItem.scopeId, + sourcePath: scopeItem.contentSourcePath ?? scopeItem.sourcePath + }, + fallbackSelector: null, + id: fieldId, + issues: [], + kind: field.renderAs === "html" || field.kind === "textarea" ? "content_block" : "heading_group", + markerFallbackSelector: null, + markerSelectors: [], + number: index + 1, + originalText: field.value, + permission: "editable", + permissionLabel: "content", + permissionReason: + `Поле из ${scopeItem.contentSourcePath ?? scopeItem.sourcePath}. Сейчас редактируется как локальная visual-конфигурация; source tree не меняется.`, + sectionId: fieldId, + selector: null, + semanticGroup: { + confidence: 0.82, + evidence: [ + "content_field_path_cluster", + "source_field_order", + ...(scopeItem.semanticBlock?.reasons ?? []) + ], + fieldCount: fieldsInBlock.length, + fieldOrder, + fieldRole: field.role, + id: semanticBlockDraft.id, + source: scopeItem.semanticBlock?.source ?? "workspace_section", + title: semanticBlockDraft.title, + visualGroupMode: isCarouselLikeContentFieldCollection({ + collectionName: field.path.split(".").at(-2) ?? field.group ?? "content", + collectionPath: field.path.split(".").slice(0, -1).join("."), + field, + scopeItem + }) + ? "single" + : "auto" + }, + source: "content_field", + subtitle: `${group}${field.path}`, + title: field.label, + workingText + } satisfies WorkspaceTarget; + }); +} + +function buildRewriteWorkspaceTargets(input: { + issues: AuditIssue[]; + pageId: string | null | undefined; + rewriteDiff: RewriteDiffContract | null | undefined; + workspace: PageWorkspace; +}) { + const diffTarget = getRewriteDiffTargetForPage(input.rewriteDiff, input.pageId ?? input.workspace.pageId); + + if (!diffTarget) { + return []; + } + + const semanticBlockFieldCounts = diffTarget.slots.reduce>((counts, slot) => { + if (slot.semanticBlock) { + counts.set(slot.semanticBlock.id, (counts.get(slot.semanticBlock.id) ?? 0) + 1); + } + + return counts; + }, new Map()); + const semanticBlockFieldOrders = new Map(); + + return diffTarget.slots.map((slot, index) => { + const section = input.workspace.sections.find((item) => item.id === slot.workspaceSectionId) ?? null; + const sectionId = section?.id ?? slot.workspaceSectionId ?? slot.id; + const semanticBlockId = slot.semanticBlock?.id ?? null; + const semanticBlockFieldOrder = semanticBlockId ? semanticBlockFieldOrders.get(semanticBlockId) ?? 0 : 0; + + if (semanticBlockId) { + semanticBlockFieldOrders.set(semanticBlockId, semanticBlockFieldOrder + 1); + } + + const issuesForSection = input.issues.filter((issue) => + getIssueTargetSections(issue, input.workspace.sections).some((targetSection) => targetSection.id === section?.id) + ); + const permission = getRewriteSlotPermission({ + hasWorkspaceSection: Boolean(section), + slot + }); + + return { + fallbackSelector: section?.selector ?? null, + id: `rewrite-${slot.id}`, + issues: issuesForSection, + keywordBindings: slot.keywordBindings, + kind: section?.kind ?? (slot.field === "body_section" ? "content_block" : "meta"), + markerFallbackSelector: section?.selector ?? null, + markerSelectors: section?.selector ? [section.selector] : [], + number: index + 1, + originalText: section?.originalText ?? slot.currentValue ?? "", + permission: permission.permission, + permissionLabel: permission.permissionLabel, + permissionReason: permission.permissionReason, + rewriteInstruction: slot.instruction, + rewriteSlotStatus: slot.status, + sectionId, + selector: section?.selector ?? null, + semanticBlock: slot.semanticBlock, + semanticGroup: + getWorkspaceSemanticGroupFromRewriteSlot( + slot, + semanticBlockFieldOrder, + semanticBlockId ? semanticBlockFieldCounts.get(semanticBlockId) ?? 1 : 1 + ) ?? (section ? getWorkspaceSectionSemanticGroup(section, index) : undefined), + source: "rewrite_slot", + subtitle: `${getRewriteFieldLabel(slot.field)} · ${diffTarget.urlPath ?? diffTarget.sourcePath ?? diffTarget.pageId}`, + title: section?.title ?? slot.workspaceSectionId ?? getRewriteFieldLabel(slot.field), + workingText: section?.workingText ?? slot.currentValue ?? "" + } satisfies WorkspaceTarget; + }); +} + function getFilenameFromHint(value: string | null | undefined) { const cleanValue = (value ?? "").split(/[?#]/)[0] ?? ""; return cleanValue.split("/").filter(Boolean).at(-1) ?? ""; @@ -5016,10 +7487,32 @@ function getIssueTargetSections(issue: AuditIssue, sections: WorkspaceSection[] return []; } -function buildWorkspaceTargets(workspace: PageWorkspace, issues: AuditIssue[]): WorkspaceTarget[] { +function buildWorkspaceTargets( + workspace: PageWorkspace, + issues: AuditIssue[], + options: { + includeSectionFallback?: boolean; + includeRewriteTargets?: boolean; + pageId?: string | null; + rewriteDiff?: RewriteDiffContract | null; + scopeItem?: WorkspaceScopeItem | null; + contentFieldTextMap?: WorkspaceSectionTextMap; + } = {} +): WorkspaceTarget[] { const fallbackSection = getWorkspaceFallbackSection(workspace.sections); - - return issues.map((issue, index) => { + const contentFieldTargets = options.includeRewriteTargets + ? buildContentFieldWorkspaceTargets(options.scopeItem, options.contentFieldTextMap) + : []; + const rewriteTargets = options.includeRewriteTargets + ? buildRewriteWorkspaceTargets({ + issues, + pageId: options.pageId ?? workspace.pageId, + rewriteDiff: options.rewriteDiff, + workspace + }) + : []; + const rewriteSectionIds = new Set(rewriteTargets.map((target) => target.sectionId)); + const auditTargets = issues.map((issue, index) => { const targetSections = getIssueTargetSections(issue, workspace.sections); const sectionId = targetSections[0]?.id ?? fallbackSection?.id ?? workspace.sections[0]?.id ?? issue.id; const section = workspace.sections.find((item) => item.id === sectionId) ?? fallbackSection ?? workspace.sections[0]; @@ -5041,9 +7534,26 @@ function buildWorkspaceTargets(workspace: PageWorkspace, issues: AuditIssue[]): subtitle: section?.sourceHint ?? getAuditScopeLabel(issue), originalText: section?.originalText ?? "", workingText: section?.workingText ?? "", - issues: [issue] - }; + issues: [issue], + keywordBindings: [], + permission: "editable", + permissionLabel: "editable", + permissionReason: "Audit issue привязан к секции Page Workspace; ручной draft можно править без Apply.", + source: "audit_issue" + } satisfies WorkspaceTarget; }); + const visibleAuditTargets = options.includeRewriteTargets + ? auditTargets.filter((target) => !rewriteSectionIds.has(target.sectionId)) + : auditTargets; + const sectionTargets = + options.includeSectionFallback && rewriteTargets.length === 0 && contentFieldTargets.length === 0 + ? buildWorkspaceSectionTargets(workspace, Boolean(options.includeRewriteTargets)) + : []; + + return [...rewriteTargets, ...contentFieldTargets, ...visibleAuditTargets, ...sectionTargets].map((target, index) => ({ + ...target, + number: index + 1 + })); } function getWorkspaceFieldDomId(workspaceKey: string, targetId: string) { @@ -5291,6 +7801,9 @@ export function App() { const renameCancelRef = useRef(false); const committingRenameProjectIdRef = useRef(null); const autoScannedProjectIdsRef = useRef(new Set()); + const pendingActiveWorkspaceScopeIdsRef = useRef>({}); + const manualWorkspacePreviewScopeLocksRef = useRef>({}); + const prefetchingWorkspaceKeysRef = useRef(new Set()); const [workspaceMode, setWorkspaceMode] = useState("dev"); const [activeStageIndex, setActiveStageIndex] = useState(0); const [health, setHealth] = useState(null); @@ -5325,6 +7838,11 @@ export function App() { const [rewritePlans, setRewritePlans] = useState>({}); const [materializationContracts, setMaterializationContracts] = useState>({}); const [rewriteDiffContracts, setRewriteDiffContracts] = useState>({}); + const [patchArtifacts, setPatchArtifacts] = useState>({}); + const [patchDryRuns, setPatchDryRuns] = useState>({}); + const [activeVisualComposerConfigIds, setActiveVisualComposerConfigIds] = useState>({}); + const [visualComposerConfigs, setVisualComposerConfigs] = useState>({}); + const [visualComposerVariantContracts, setVisualComposerVariantContracts] = useState>({}); const [modelProviderStatuses, setModelProviderStatuses] = useState>({}); const [activeSemanticClusterIds, setActiveSemanticClusterIds] = useState>({}); const [projectsError, setProjectsError] = useState(null); @@ -5338,6 +7856,7 @@ export function App() { const [pendingProjectName, setPendingProjectName] = useState(""); const [isProjectNameModalOpen, setIsProjectNameModalOpen] = useState(false); const [isProjectMenuOpen, setIsProjectMenuOpen] = useState(false); + const [isStageRailOpen, setIsStageRailOpen] = useState(true); const [isWorkPanelOpen, setIsWorkPanelOpen] = useState(false); const [isWorkPanelFullscreen, setIsWorkPanelFullscreen] = useState(false); const [editingProjectId, setEditingProjectId] = useState(null); @@ -5356,6 +7875,9 @@ export function App() { const [reviewingStrategyQualityProjectId, setReviewingStrategyQualityProjectId] = useState(null); const [persistingKeywordMapProjectId, setPersistingKeywordMapProjectId] = useState(null); const [materializingTargetKey, setMaterializingTargetKey] = useState(null); + const [buildingPatchProjectId, setBuildingPatchProjectId] = useState(null); + const [runningPatchDryRunKey, setRunningPatchDryRunKey] = useState(null); + const [generatingVisualComposerProjectId, setGeneratingVisualComposerProjectId] = useState(null); const [runningModelTaskKey, setRunningModelTaskKey] = useState(null); const [exportingSemanticKey, setExportingSemanticKey] = useState(null); const [selectingPagesProjectId, setSelectingPagesProjectId] = useState(null); @@ -5367,10 +7889,19 @@ export function App() { const [activeWorkspaceTargetIds, setActiveWorkspaceTargetIds] = useState>({}); const [workspacePreviewMarkers, setWorkspacePreviewMarkers] = useState>({}); const [workspaceDrawers, setWorkspaceDrawers] = useState>({}); + const [excludedWorkspaceTargetIds, setExcludedWorkspaceTargetIds] = useState({}); const [workspacePreviewScopeIds, setWorkspacePreviewScopeIds] = useState>({}); + const [semanticBlockEditModes, setSemanticBlockEditModes] = useState>({}); + const [semanticBlockEditProjectModes, setSemanticBlockEditProjectModes] = useState>({}); + const [workspaceSemanticBlockOverrides, setWorkspaceSemanticBlockOverrides] = useState>( + () => readStoredWorkspaceSemanticBlockOverrides() + ); + const [workspaceSemanticBlockDrafts, setWorkspaceSemanticBlockDrafts] = useState>({}); + const [semanticBlockGenerationDirty, setSemanticBlockGenerationDirty] = useState>({}); const [pageWorkspaces, setPageWorkspaces] = useState>({}); const [workspaceDraftTexts, setWorkspaceDraftTexts] = useState>({}); const [workspaceSectionTexts, setWorkspaceSectionTexts] = useState>({}); + const [workspaceContentFieldTexts, setWorkspaceContentFieldTexts] = useState>({}); const [workspacePreviewModes, setWorkspacePreviewModes] = useState>({}); const [busyWorkspaceKey, setBusyWorkspaceKey] = useState(null); const [projectActionError, setProjectActionError] = useState(null); @@ -5611,6 +8142,21 @@ export function App() { setRewriteDiffContracts(Object.fromEntries(rewriteDiffEntries)); } + async function loadPatchArtifacts(projectList: ProjectSummary[]) { + const patchEntries = await Promise.all( + projectList.map(async (project) => { + try { + const result = await fetchLatestPatchArtifact(project.id); + return [project.id, result.patchArtifact] as const; + } catch { + return [project.id, null] as const; + } + }) + ); + + setPatchArtifacts(Object.fromEntries(patchEntries)); + } + async function loadModelProviderStatuses(projectList: ProjectSummary[]) { const statusEntries = await Promise.all( projectList.map(async (project) => { @@ -5759,11 +8305,12 @@ export function App() { loadSeoStrategies(result.projects), loadStrategySyntheses(result.projects), loadStrategyQualityReviews(result.projects), - loadRewritePlans(result.projects), - loadMaterializationContracts(result.projects), - loadRewriteDiffContracts(result.projects), - loadModelProviderStatuses(result.projects) - ]); + loadRewritePlans(result.projects), + loadMaterializationContracts(result.projects), + loadRewriteDiffContracts(result.projects), + loadPatchArtifacts(result.projects), + loadModelProviderStatuses(result.projects) + ]); } catch (error: unknown) { setProjectsError(getErrorMessage(error, "Не удалось загрузить проекты.")); } finally { @@ -5804,6 +8351,22 @@ export function App() { void loadProjects(); }, []); + useEffect(() => { + try { + window.localStorage.setItem(SEMANTIC_BLOCK_OVERRIDES_STORAGE_KEY, JSON.stringify(workspaceSemanticBlockOverrides)); + } catch { + // Local persistence is best-effort; production application still goes through patch artifact/dry-run. + } + }, [workspaceSemanticBlockOverrides]); + + useEffect(() => { + try { + window.localStorage.removeItem(WORKSPACE_UI_CONTEXT_STORAGE_KEY); + } catch { + // Ignore storage cleanup failures; UI state should still start from the default launcher. + } + }, []); + useEffect(() => { if (activeStageIndex !== 2) { return; @@ -5821,12 +8384,12 @@ export function App() { }; }, [activeProjectId, activeStageIndex]); - useEffect(() => { - const timers: number[] = []; - const projectId = activeProjectId ?? ""; + useEffect(() => { + const timers: number[] = []; + const projectId = activeProjectId ?? ""; const scanSummary = projectId ? scanSummaries[projectId] : null; - if (activeStageIndex !== 2 || !projectId || !scanSummary) { + if ((activeStageIndex !== 2 && activeStageIndex !== 6) || !projectId || !scanSummary) { return () => undefined; } @@ -5841,7 +8404,19 @@ export function App() { } const issues = getPageIssues(scanSummary, activeScopeItem?.pageId ?? null); - const targets = buildWorkspaceTargets(workspace, issues); + const rawTargets = buildWorkspaceTargets(workspace, issues, { + includeRewriteTargets: activeStageIndex === 6, + includeSectionFallback: activeStageIndex === 6, + contentFieldTextMap: workspaceContentFieldTexts[workspaceKey], + pageId: activeScopeItem?.pageId ?? workspace.pageId, + rewriteDiff: rewriteDiffContracts[projectId] ?? null, + scopeItem: activeScopeItem + }); + const targets = filterExcludedWorkspaceTargets( + workspaceKey, + getWorkspaceTargetsForRewriteRuntime(rawTargets, workspaceSemanticBlockOverrides[workspaceKey]), + excludedWorkspaceTargetIds + ); timers.push(window.setTimeout(() => updateWorkspacePreviewMarkers(workspaceKey, targets, scopeItems, activeScopeItem), 80)); timers.push(window.setTimeout(() => updateWorkspacePreviewMarkers(workspaceKey, targets, scopeItems, activeScopeItem), 360)); @@ -5850,9 +8425,92 @@ export function App() { return () => { timers.forEach((timer) => window.clearTimeout(timer)); }; - }, [activeProjectId, activeStageIndex, activeWorkspaceScopeIds, pageWorkspaces, scanSummaries, workspacePreviewModes]); + }, [ + activeProjectId, + activeStageIndex, + activeWorkspaceScopeIds, + excludedWorkspaceTargetIds, + pageWorkspaces, + rewriteDiffContracts, + scanSummaries, + semanticBlockEditProjectModes, + workspaceContentFieldTexts, + workspacePreviewModes, + workspaceSemanticBlockDrafts, + workspaceSemanticBlockOverrides + ]); - function scrollWorkspaceFieldIntoView(workspaceKey: string, targetId: string, attempt = 0) { + useEffect(() => { + const timers: number[] = []; + const projectId = activeProjectId ?? ""; + const scanSummary = projectId ? scanSummaries[projectId] : null; + + if ((activeStageIndex !== 2 && activeStageIndex !== 6) || !projectId || !scanSummary) { + return () => undefined; + } + + const scopeItems = getSelectedWorkspaceScopeItems(scanSummary); + const activeScopeId = activeWorkspaceScopeIds[projectId] ?? scopeItems[0]?.scopeId ?? ""; + const activeScopeItem = scopeItems.find((item) => item.scopeId === activeScopeId) ?? scopeItems[0] ?? null; + const workspaceKey = activeScopeItem ? getWorkspaceKey(projectId, activeScopeItem.scopeId) : ""; + const workspace = workspaceKey ? pageWorkspaces[workspaceKey] : null; + + if (!workspaceKey || !workspace) { + return () => undefined; + } + + if (activeStageIndex !== 6) { + timers.push(window.setTimeout(() => postWorkspacePreviewSectionTexts(workspaceKey, {}), 0)); + timers.push(window.setTimeout(() => postWorkspacePreviewSectionTexts(workspaceKey, {}), 180)); + + return () => { + timers.forEach((timer) => window.clearTimeout(timer)); + }; + } + + const sectionTexts = workspaceSectionTexts[workspaceKey]; + + if (!sectionTexts) { + return () => undefined; + } + + const issues = getPageIssues(scanSummary, activeScopeItem?.pageId ?? null); + const rawTargets = buildWorkspaceTargets(workspace, issues, { + includeRewriteTargets: activeStageIndex === 6, + includeSectionFallback: activeStageIndex === 6, + contentFieldTextMap: workspaceContentFieldTexts[workspaceKey], + pageId: activeScopeItem?.pageId ?? workspace.pageId, + rewriteDiff: rewriteDiffContracts[projectId] ?? null, + scopeItem: activeScopeItem + }); + const targets = filterExcludedWorkspaceTargets( + workspaceKey, + getWorkspaceTargetsForRewriteRuntime(rawTargets, workspaceSemanticBlockOverrides[workspaceKey]), + excludedWorkspaceTargetIds + ); + const previewSectionTexts = getWorkspacePreviewSectionTextMap(workspace, sectionTexts, targets); + + timers.push(window.setTimeout(() => postWorkspacePreviewSectionTexts(workspaceKey, previewSectionTexts), 0)); + timers.push(window.setTimeout(() => postWorkspacePreviewSectionTexts(workspaceKey, previewSectionTexts), 180)); + + return () => { + timers.forEach((timer) => window.clearTimeout(timer)); + }; + }, [ + activeProjectId, + activeStageIndex, + activeWorkspaceScopeIds, + excludedWorkspaceTargetIds, + pageWorkspaces, + rewriteDiffContracts, + scanSummaries, + workspaceContentFieldTexts, + workspaceSectionTexts, + workspacePreviewModes, + workspaceSemanticBlockOverrides + ]); + + function scrollWorkspaceFieldIntoView(workspaceKey: string, targetId: string, attempt = 0) { window.requestAnimationFrame(() => { const element = document.getElementById(getWorkspaceFieldDomId(workspaceKey, targetId)); @@ -5876,6 +8534,79 @@ export function App() { } if (data.type !== "seo-mode:preview-markers") { + if (data?.type === "seo-mode:semantic-block-draft") { + const workspaceKey = typeof data.workspaceKey === "string" ? data.workspaceKey : ""; + + if (!workspaceKey) { + return; + } + + const boxes = sanitizeSemanticBlockDraftBoxes(data.boxes); + const hasManualSemanticBlockSet = Boolean(data.hasManualSemanticBlockSet); + + setWorkspaceSemanticBlockDrafts((currentDrafts) => { + if (boxes.length === 0 && !hasManualSemanticBlockSet) { + const nextDrafts = { ...currentDrafts }; + delete nextDrafts[workspaceKey]; + return nextDrafts; + } + + return { + ...currentDrafts, + [workspaceKey]: boxes + }; + }); + return; + } + + if (data?.type === "seo-mode:semantic-block-commit") { + const workspaceKey = typeof data.workspaceKey === "string" ? data.workspaceKey : ""; + + if (!workspaceKey || !Array.isArray(data.boxes)) { + return; + } + const projectId = getWorkspaceUiKeyFromWorkspaceKey(workspaceKey); + + const currentWorkspaceDraft = sanitizeSemanticBlockDraftBoxes(data.boxes); + const projectWorkspacePrefix = `${projectId}:`; + const projectDraftEntries = Object.entries(workspaceSemanticBlockDrafts) + .filter(([draftWorkspaceKey]) => draftWorkspaceKey.startsWith(projectWorkspacePrefix) && draftWorkspaceKey !== workspaceKey) + .map(([draftWorkspaceKey, boxes]) => [draftWorkspaceKey, commitSemanticBlockDraftBoxes(boxes)] as const); + const currentWorkspaceOverrides = commitSemanticBlockDraftBoxes(currentWorkspaceDraft); + + setWorkspaceSemanticBlockOverrides((existingOverrides) => ({ + ...existingOverrides, + ...Object.fromEntries(projectDraftEntries), + [workspaceKey]: currentWorkspaceOverrides + })); + setSemanticBlockGenerationDirty((currentDirty) => ({ + ...currentDirty, + ...Object.fromEntries(projectDraftEntries.map(([draftWorkspaceKey]) => [draftWorkspaceKey, true])), + [workspaceKey]: true + })); + setWorkspaceSemanticBlockDrafts((currentDrafts) => + Object.fromEntries(Object.entries(currentDrafts).filter(([draftWorkspaceKey]) => !draftWorkspaceKey.startsWith(projectWorkspacePrefix))) + ); + void persistWorkspaceSemanticBlocks(workspaceKey, currentWorkspaceOverrides); + projectDraftEntries.forEach(([draftWorkspaceKey, draftOverrides]) => { + void persistWorkspaceSemanticBlocks(draftWorkspaceKey, draftOverrides); + }); + setSemanticBlockEditModes((currentModes) => ({ + ...currentModes, + [workspaceKey]: false + })); + setSemanticBlockEditProjectModes((currentModes) => ({ + ...currentModes, + [projectId]: false + })); + setProjectActionError(null); + setProjectActionMessage( + `Семантические блоки обновлены: ${currentWorkspaceOverrides.length + projectDraftEntries.reduce((sum, [, boxes]) => sum + boxes.length, 0)}. Нужна новая генерация.` + ); + setProjectActionProjectId(projectId); + return; + } + if (data?.type === "seo-mode:active-scope-page") { const sourceWorkspaceKey = typeof data.workspaceKey === "string" ? data.workspaceKey : ""; const pageId = typeof data.pageId === "string" ? data.pageId : ""; @@ -5891,11 +8622,37 @@ export function App() { return; } + const expectedPreviewScopeId = workspacePreviewScopeIds[projectId] ?? activeWorkspaceScopeIds[projectId] ?? ""; + const expectedPreviewWorkspaceKey = expectedPreviewScopeId ? getWorkspaceKey(projectId, expectedPreviewScopeId) : ""; + const scanSummary = scanSummaries[projectId]; + const knownPreviewSourceScopeIds = scanSummary + ? new Set(getSelectedWorkspaceScopeItems(scanSummary).map((item) => item.scopeId)) + : new Set(); + const sourceScopeId = getWorkspaceScopeIdFromWorkspaceKey(sourceWorkspaceKey); + + if ( + expectedPreviewWorkspaceKey && + sourceWorkspaceKey !== expectedPreviewWorkspaceKey && + !knownPreviewSourceScopeIds.has(sourceScopeId) + ) { + return; + } + + const manualPreviewLock = manualWorkspacePreviewScopeLocksRef.current[projectId]; + + if (manualPreviewLock && manualPreviewLock.until > Date.now() && manualPreviewLock.scopeId !== scopeId) { + return; + } + + if (manualPreviewLock && manualPreviewLock.until <= Date.now()) { + delete manualWorkspacePreviewScopeLocksRef.current[projectId]; + } + const nextWorkspaceKey = getWorkspaceKey(projectId, scopeId); + pendingActiveWorkspaceScopeIdsRef.current[projectId] = scopeId; if (!pageWorkspaces[nextWorkspaceKey]) { const project = projects.find((item) => item.id === projectId); - const scanSummary = scanSummaries[projectId]; const scopeItem = scanSummary ? getSelectedWorkspaceScopeItems(scanSummary).find((item) => item.scopeId === scopeId) : null; if (project && scopeItem && busyWorkspaceKey !== nextWorkspaceKey) { @@ -5942,7 +8699,7 @@ export function App() { ...currentSectionIds, [workspaceKey]: sectionId || targetId })); - openWorkspaceDrawer(getWorkspaceUiKeyFromWorkspaceKey(workspaceKey), "issues"); + openWorkspaceDrawer(getWorkspaceUiKeyFromWorkspaceKey(workspaceKey), activeStageIndex === 6 ? "rewrite" : "issues"); scrollWorkspaceFieldIntoView(workspaceKey, targetId); return; @@ -5977,7 +8734,9 @@ export function App() { number: markerRecord.number, label: markerRecord.label, top: markerRecord.top, - left: markerRecord.left + left: markerRecord.left, + width: typeof markerRecord.width === "number" ? markerRecord.width : undefined, + height: typeof markerRecord.height === "number" ? markerRecord.height : undefined } ]; }); @@ -5993,9 +8752,63 @@ export function App() { return () => { window.removeEventListener("message", handlePreviewMessage); }; - }, [busyWorkspaceKey, pageWorkspaces, projects, scanSummaries]); + }, [ + activeStageIndex, + activeWorkspaceScopeIds, + busyWorkspaceKey, + pageWorkspaces, + projects, + rewriteDiffContracts, + scanSummaries, + semanticBlockEditProjectModes, + workspaceContentFieldTexts, + workspacePreviewScopeIds, + workspaceSemanticBlockDrafts + ]); useEffect(() => { + if (activeStageIndex !== 6 || !activeProjectId || !semanticBlockEditProjectModes[activeProjectId]) { + return () => undefined; + } + + function handleSemanticBlockDeleteKey(event: KeyboardEvent) { + if (event.key !== "Delete" && event.key !== "Backspace") { + return; + } + + const target = event.target instanceof Element ? event.target : null; + + if (target?.closest("input, textarea, select, [contenteditable='true'], [contenteditable='']")) { + return; + } + + const projectId = activeProjectId ?? ""; + const scanSummary = projectId ? scanSummaries[projectId] : null; + const scopeItems = scanSummary ? getSelectedWorkspaceScopeItems(scanSummary) : []; + const activeScopeId = activeWorkspaceScopeIds[projectId] ?? scopeItems[0]?.scopeId ?? ""; + const activeScopeItem = scopeItems.find((item) => item.scopeId === activeScopeId) ?? scopeItems[0] ?? null; + const workspaceKey = activeScopeItem ? getWorkspaceKey(projectId, activeScopeItem.scopeId) : ""; + + if (!workspaceKey) { + return; + } + + event.preventDefault(); + deleteSelectedSemanticBlock(workspaceKey); + } + + document.addEventListener("keydown", handleSemanticBlockDeleteKey); + + return () => { + document.removeEventListener("keydown", handleSemanticBlockDeleteKey); + }; + }, [activeProjectId, activeStageIndex, activeWorkspaceScopeIds, scanSummaries, semanticBlockEditProjectModes]); + + useEffect(() => { + if (projectsLoading) { + return; + } + if (projects.length === 0) { setActiveProjectId(null); return; @@ -6004,7 +8817,7 @@ export function App() { if (activeProjectId && !projects.some((project) => project.id === activeProjectId)) { setActiveProjectId(null); } - }, [activeProjectId, projects]); + }, [activeProjectId, projects, projectsLoading]); useEffect(() => { folderInputRef.current?.setAttribute("webkitdirectory", ""); @@ -6198,6 +9011,14 @@ export function App() { ...currentContracts, [result.project.id]: null })); + setPatchArtifacts((currentArtifacts) => ({ + ...currentArtifacts, + [result.project.id]: null + })); + setPatchDryRuns((currentDryRuns) => ({ + ...currentDryRuns, + [result.project.id]: null + })); setCreateMessage( `Проект «${result.project.name}» создан, папка импортирована в хранилище.\nПервичный скан запущен автоматически.` ); @@ -6362,6 +9183,14 @@ export function App() { ...currentContracts, [result.project.id]: null })); + setPatchArtifacts((currentArtifacts) => ({ + ...currentArtifacts, + [result.project.id]: null + })); + setPatchDryRuns((currentDryRuns) => ({ + ...currentDryRuns, + [result.project.id]: null + })); setProjectActionProjectId(result.project.id); setProjectActionMessage(`Создана копия «${result.project.name}».`); } catch (error: unknown) { @@ -6416,6 +9245,14 @@ export function App() { ...currentContracts, [project.id]: null })); + setPatchArtifacts((currentArtifacts) => ({ + ...currentArtifacts, + [project.id]: null + })); + setPatchDryRuns((currentDryRuns) => ({ + ...currentDryRuns, + [project.id]: null + })); invalidateStrategyQualityReview(project.id); if (!options.hideSuccessMessage) { setProjectActionMessage(getProjectScanActionMessage(result.scan)); @@ -6566,6 +9403,7 @@ export function App() { scanSummary: scanSummaries[project.id] ?? null, selectedContract: strategyDecisionContractsByProjectId[project.id] ?? null, selectedScenarioId: selectedStrategyScenarioIds[project.id] ?? null, + semanticAnalysis: semanticAnalyses[project.id] ?? null, seoStrategy: seoStrategies[project.id] ?? null, serpInterpretation: serpInterpretations[project.id] ?? null, yandexEvidence: yandexEvidences[project.id] ?? null @@ -6930,7 +9768,7 @@ export function App() { setProjectActionProjectId(project.id); try { - const result = await runYandexEvidence(project.id, 5); + const result = await runYandexEvidence(project.id, 18); const strategyResult = await fetchLatestSeoStrategy(project.id); setYandexEvidences((currentEvidences) => ({ @@ -7194,6 +10032,92 @@ export function App() { } } + function hydratePersistedWorkspaceSemanticBlocks(workspaceKey: string, semanticBlocks: SemanticBlockModel[] | undefined) { + const matchingBlocks = (semanticBlocks ?? []).filter((block) => block.workspaceKey === workspaceKey); + + if (matchingBlocks.length === 0) { + return; + } + + const overrides = getSemanticBlockOverridesFromModels(matchingBlocks, workspaceKey); + + setWorkspaceSemanticBlockOverrides((currentOverrides) => ({ + ...currentOverrides, + [workspaceKey]: overrides + })); + setSemanticBlockGenerationDirty((currentDirty) => ({ + ...currentDirty, + [workspaceKey]: false + })); + } + + async function persistWorkspaceSemanticBlocks(workspaceKey: string, overrides: WorkspaceSemanticBlockOverride[]) { + const projectId = getWorkspaceUiKeyFromWorkspaceKey(workspaceKey); + const scopeId = getWorkspaceScopeIdFromWorkspaceKey(workspaceKey); + const workspace = pageWorkspaces[workspaceKey]; + + if (!projectId || !scopeId || !workspace) { + return; + } + + const scanSummary = scanSummaries[projectId] ?? null; + const scopeItem = + scanSummary + ? getSelectedWorkspaceScopeItems(scanSummary).find((item) => item.scopeId === scopeId) ?? null + : null; + const issues = getPageIssues(scanSummary, scopeItem?.pageId ?? workspace.pageId); + const rawTargets = buildWorkspaceTargets(workspace, issues, { + includeRewriteTargets: true, + includeSectionFallback: true, + contentFieldTextMap: workspaceContentFieldTexts[workspaceKey], + pageId: scopeItem?.pageId ?? workspace.pageId, + rewriteDiff: rewriteDiffContracts[projectId] ?? null, + scopeItem + }); + const targets = getWorkspaceTargetsForRewriteRuntime(rawTargets, overrides); + const blocks = overrides.map((override) => { + const sourceBindings = buildSemanticBlockSourceBindings(override, targets); + + return { + anchors: override.anchors ?? [], + blockKey: override.id, + capturedEntities: override.capturedEntities ?? [], + geometryCache: override.geometry ?? {}, + identity: buildSemanticBlockIdentity(override, sourceBindings), + mediaContext: buildSemanticBlockMediaContext(sourceBindings), + rewriteState: { + status: "draft" + }, + role: "content", + source: "manual" as const, + sourceBindings, + targetIds: override.targetIds, + title: override.title + }; + }); + + try { + const result = await savePageSemanticBlocks(projectId, workspace.pageId, { + blocks, + scanVersionId: workspace.page.scanVersionId, + scopeId, + workspaceKey + }); + const persistedOverrides = getSemanticBlockOverridesFromModels(result.semanticBlocks, workspaceKey); + + setWorkspaceSemanticBlockOverrides((currentOverrides) => ({ + ...currentOverrides, + [workspaceKey]: persistedOverrides + })); + setProjectActionError(null); + setProjectActionMessage(`Семантические блоки сохранены в backend: ${persistedOverrides.length}. Нужна новая генерация.`); + setProjectActionProjectId(projectId); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось сохранить semantic blocks в backend.")); + setProjectActionProjectId(projectId); + } + } + async function handleOpenPageWorkspace( project: ProjectSummary, target: ProjectScanSummary["pages"][number] | WorkspaceScopeItem, @@ -7205,6 +10129,7 @@ export function App() { const workspaceTitle = isScopeItem ? target.title : getPageTitle(target); const workspaceKey = getWorkspaceKey(project.id, scopeId); + pendingActiveWorkspaceScopeIdsRef.current[project.id] = scopeId; setBusyWorkspaceKey(workspaceKey); if (!options.silent) { @@ -7214,6 +10139,10 @@ export function App() { } if (options.navigatePreview !== false) { + manualWorkspacePreviewScopeLocksRef.current[project.id] = { + scopeId, + until: Date.now() + WORKSPACE_MANUAL_PREVIEW_SCOPE_LOCK_MS + }; setWorkspacePreviewScopeIds((currentScopeIds) => ({ ...currentScopeIds, [project.id]: scopeId @@ -7227,10 +10156,12 @@ export function App() { })); } - try { - const result = await openPageWorkspace(project.id, pageId); + try { + const result = await openPageWorkspace(project.id, pageId); + const contentFieldTextMap = getContentFieldDraftTextMap(result.contentDrafts); + hydratePersistedWorkspaceSemanticBlocks(workspaceKey, result.semanticBlocks); - setPageWorkspaces((currentWorkspaces) => ({ + setPageWorkspaces((currentWorkspaces) => ({ ...currentWorkspaces, [workspaceKey]: result.workspace })); @@ -7242,6 +10173,10 @@ export function App() { ...currentTexts, [workspaceKey]: getInitialSectionTextMap(result.workspace) })); + setWorkspaceContentFieldTexts((currentTexts) => ({ + ...currentTexts, + [workspaceKey]: contentFieldTextMap + })); setActiveWorkspaceSectionIds((currentSectionIds) => ({ ...currentSectionIds, [workspaceKey]: currentSectionIds[workspaceKey] ?? result.workspace.sections[0]?.id ?? "" @@ -7251,10 +10186,18 @@ export function App() { [workspaceKey]: currentTargetIds[workspaceKey] ?? result.workspace.sections[0]?.id ?? "" })); if (options.deferActiveUntilLoaded) { - setActiveWorkspaceScopeIds((currentScopeIds) => ({ - ...currentScopeIds, - [project.id]: scopeId - })); + setActiveWorkspaceScopeIds((currentScopeIds) => { + const pendingScopeId = pendingActiveWorkspaceScopeIdsRef.current[project.id]; + + if (pendingScopeId && pendingScopeId !== scopeId) { + return currentScopeIds; + } + + return { + ...currentScopeIds, + [project.id]: scopeId + }; + }); } if (!options.silent) { @@ -7267,6 +10210,55 @@ export function App() { } } + async function prefetchPageWorkspace(project: ProjectSummary, scopeItem: WorkspaceScopeItem) { + const workspaceKey = getWorkspaceKey(project.id, scopeItem.scopeId); + + if (pageWorkspaces[workspaceKey] || busyWorkspaceKey === workspaceKey || prefetchingWorkspaceKeysRef.current.has(workspaceKey)) { + return; + } + + prefetchingWorkspaceKeysRef.current.add(workspaceKey); + + try { + const result = await openPageWorkspace(project.id, scopeItem.pageId); + const contentFieldTextMap = getContentFieldDraftTextMap(result.contentDrafts); + hydratePersistedWorkspaceSemanticBlocks(workspaceKey, result.semanticBlocks); + + setPageWorkspaces((currentWorkspaces) => { + if (currentWorkspaces[workspaceKey]) { + return currentWorkspaces; + } + + return { + ...currentWorkspaces, + [workspaceKey]: result.workspace + }; + }); + setWorkspaceDraftTexts((currentTexts) => ({ + ...currentTexts, + [workspaceKey]: currentTexts[workspaceKey] ?? result.workspace.item.workingText + })); + setWorkspaceSectionTexts((currentTexts) => ({ + ...currentTexts, + [workspaceKey]: currentTexts[workspaceKey] ?? getInitialSectionTextMap(result.workspace) + })); + setWorkspaceContentFieldTexts((currentTexts) => ({ + ...currentTexts, + [workspaceKey]: currentTexts[workspaceKey] ?? contentFieldTextMap + })); + setActiveWorkspaceSectionIds((currentSectionIds) => ({ + ...currentSectionIds, + [workspaceKey]: currentSectionIds[workspaceKey] ?? result.workspace.sections[0]?.id ?? "" + })); + setActiveWorkspaceTargetIds((currentTargetIds) => ({ + ...currentTargetIds, + [workspaceKey]: currentTargetIds[workspaceKey] ?? result.workspace.sections[0]?.id ?? "" + })); + } catch { + prefetchingWorkspaceKeysRef.current.delete(workspaceKey); + } + } + async function openPageEditorFromAudit(project: ProjectSummary, page: ProjectScanSummary["pages"][number]) { setActiveProjectId(project.id); setActiveStageIndex(2); @@ -7302,12 +10294,17 @@ export function App() { })); setWorkspaceSectionTexts((currentTexts) => ({ ...currentTexts, - [workspaceKey]: { - ...getInitialSectionTextMap(result.workspace), - ...(workspaceSectionTexts[workspaceKey] ?? {}) - } + [workspaceKey]: getInitialSectionTextMap(result.workspace) })); - setProjectActionMessage("Working draft сохранён. Исходный проект не изменён."); + setPatchArtifacts((currentArtifacts) => ({ + ...currentArtifacts, + [projectId]: null + })); + setPatchDryRuns((currentDryRuns) => ({ + ...currentDryRuns, + [projectId]: null + })); + setProjectActionMessage("Рабочий черновик сохранён. Исходный проект не изменён."); } catch (error: unknown) { setProjectActionError(getErrorMessage(error, "Не удалось сохранить рабочий draft.")); } finally { @@ -7315,8 +10312,202 @@ export function App() { } } + async function handleSaveContentFieldDraft(projectId: string, pageId: string, workspaceKey: string, target: WorkspaceTarget) { + if (!target.contentField) { + setProjectActionError("У content field нет metadata для сохранения patch draft."); + return; + } + + const workingText = getWorkspaceTargetDraftText(target, workspaceSectionTexts[workspaceKey]); + + setBusyWorkspaceKey(workspaceKey); + setBuildingPatchProjectId(projectId); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(projectId); + + try { + const result = await saveContentFieldDraft(projectId, pageId, { + fieldId: target.contentField.fieldId, + fieldPath: target.contentField.fieldPath, + group: target.contentField.group, + kind: target.contentField.kind, + originalText: target.originalText, + renderAs: target.contentField.renderAs, + scopeId: target.contentField.scopeId, + sectionId: target.contentField.sectionId, + sourcePath: target.contentField.sourcePath, + title: target.title, + workingText + }); + const savedText = result.contentDraft.workingText; + + setWorkspaceContentFieldTexts((currentTexts) => ({ + ...currentTexts, + [workspaceKey]: { + ...(currentTexts[workspaceKey] ?? {}), + [target.sectionId]: savedText + } + })); + setWorkspaceSectionTexts((currentTexts) => ({ + ...currentTexts, + [workspaceKey]: { + ...(currentTexts[workspaceKey] ?? {}), + [target.sectionId]: savedText + } + })); + + const patchResult = await buildPatchArtifact(projectId); + setPatchArtifacts((currentArtifacts) => ({ + ...currentArtifacts, + [projectId]: patchResult.patchArtifact + })); + setPatchDryRuns((currentDryRuns) => ({ + ...currentDryRuns, + [projectId]: null + })); + setProjectActionMessage( + `Content draft сохранён. Patch artifact: ${patchResult.patchArtifact.readiness.changeCount} правок, ${patchResult.patchArtifact.readiness.affectedFileCount} файлов.` + ); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось сохранить content field draft.")); + } finally { + setBuildingPatchProjectId(null); + setBusyWorkspaceKey(null); + } + } + + async function handleAcceptVisualComposerDraft( + projectId: string, + pageId: string, + workspaceKey: string | null, + workspace: PageWorkspace | null, + targets: WorkspaceTarget[], + sectionTextMap: WorkspaceSectionTextMap | undefined + ) { + if (!workspaceKey || !workspace) { + setProjectActionError("Нет активной рабочей зоны для сохранения варианта."); + return; + } + + const persistableTargets = targets.filter( + (target) => isPersistableWorkspaceTarget(target) && getWorkspaceTargetDraftStats(target, sectionTextMap).hasUnsavedChanges + ); + + if (persistableTargets.length === 0) { + setProjectActionError("В выбранном варианте нет несохранённых slot-изменений."); + return; + } + + const contentTargetsByKey = new Map(); + + persistableTargets.forEach((target) => { + if (target.source !== "content_field" || !target.contentField) { + return; + } + + contentTargetsByKey.set(`${target.contentField.sectionId}:${target.contentField.fieldPath}`, target); + }); + + const contentTargets = Array.from(contentTargetsByKey.values()); + const hasSectionChanges = workspace.sections.some((section) => { + const draftText = sectionTextMap?.[section.id] ?? section.workingText; + return normalizeWorkspaceComparisonText(draftText) !== normalizeWorkspaceComparisonText(section.workingText); + }); + + setBusyWorkspaceKey(workspaceKey); + setBuildingPatchProjectId(projectId); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(projectId); + + try { + let savedWorkspace: PageWorkspace | null = null; + + if (hasSectionChanges) { + const workingText = buildWorkingTextFromSections(workspace, sectionTextMap); + const result = await savePageWorkspace(projectId, pageId, workingText); + savedWorkspace = result.workspace; + + setPageWorkspaces((currentWorkspaces) => ({ + ...currentWorkspaces, + [workspaceKey]: result.workspace + })); + setWorkspaceDraftTexts((currentTexts) => ({ + ...currentTexts, + [workspaceKey]: result.workspace.item.workingText + })); + } + + const contentDraftResults = await Promise.all( + contentTargets.map((target) => { + if (!target.contentField) { + throw new Error("Content field target без metadata."); + } + + return saveContentFieldDraft(projectId, pageId, { + fieldId: target.contentField.fieldId, + fieldPath: target.contentField.fieldPath, + group: target.contentField.group, + kind: target.contentField.kind, + originalText: target.originalText, + renderAs: target.contentField.renderAs, + scopeId: target.contentField.scopeId, + sectionId: target.contentField.sectionId, + sourcePath: target.contentField.sourcePath, + title: target.title, + workingText: getWorkspaceTargetDraftText(target, sectionTextMap) + }); + }) + ); + const contentTextMap = Object.fromEntries( + contentDraftResults.map((result, index) => [ + contentTargets[index]?.sectionId ?? result.contentDraft.sectionId, + result.contentDraft.workingText + ]) + ); + const savedSectionTextMap = savedWorkspace ? getInitialSectionTextMap(savedWorkspace) : null; + + if (Object.keys(contentTextMap).length > 0) { + setWorkspaceContentFieldTexts((currentTexts) => ({ + ...currentTexts, + [workspaceKey]: { + ...(currentTexts[workspaceKey] ?? {}), + ...contentTextMap + } + })); + } + + setWorkspaceSectionTexts((currentTexts) => ({ + ...currentTexts, + [workspaceKey]: { + ...(savedSectionTextMap ?? currentTexts[workspaceKey] ?? sectionTextMap ?? {}), + ...contentTextMap + } + })); + + const patchResult = await buildPatchArtifact(projectId); + setPatchArtifacts((currentArtifacts) => ({ + ...currentArtifacts, + [projectId]: patchResult.patchArtifact + })); + setPatchDryRuns((currentDryRuns) => ({ + ...currentDryRuns, + [projectId]: null + })); + setProjectActionMessage( + `Вариант принят в draft: ${getWorkspaceUnsavedPersistableTargetCount(targets, sectionTextMap)} слотов. Patch artifact: ${patchResult.patchArtifact.readiness.changeCount} правок.` + ); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось принять вариант в draft.")); + } finally { + setBuildingPatchProjectId(null); + setBusyWorkspaceKey(null); + } + } + async function handleResetPageWorkspace(projectId: string, pageId: string, workspaceKey = getWorkspaceKey(projectId, `page:${pageId}`)) { - const confirmed = window.confirm("Сбросить working draft к original snapshot? Исходный проект не изменится."); + const confirmed = window.confirm("Сбросить рабочий черновик к оригинальному снимку? Исходный проект не изменится."); if (!confirmed) { return; @@ -7350,7 +10541,15 @@ export function App() { ...currentTargetIds, [workspaceKey]: result.workspace.sections[0]?.id ?? "" })); - setProjectActionMessage("Working draft сброшен к original snapshot."); + setPatchArtifacts((currentArtifacts) => ({ + ...currentArtifacts, + [projectId]: null + })); + setPatchDryRuns((currentDryRuns) => ({ + ...currentDryRuns, + [projectId]: null + })); + setProjectActionMessage("Рабочий черновик сброшен к оригинальному снимку."); } catch (error: unknown) { setProjectActionError(getErrorMessage(error, "Не удалось сбросить рабочий draft.")); } finally { @@ -7358,6 +10557,223 @@ export function App() { } } + async function handleBuildPatchArtifact(projectId: string) { + setBuildingPatchProjectId(projectId); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(projectId); + + try { + const result = await buildPatchArtifact(projectId); + + setPatchArtifacts((currentArtifacts) => ({ + ...currentArtifacts, + [projectId]: result.patchArtifact + })); + setPatchDryRuns((currentDryRuns) => ({ + ...currentDryRuns, + [projectId]: null + })); + setProjectActionMessage( + result.patchArtifact.readiness.changeCount > 0 + ? `Patch artifact собран: ${result.patchArtifact.readiness.changeCount} правок, ${result.patchArtifact.readiness.affectedFileCount} файлов.` + : "Patch artifact собран пустым: сохранённых отличий от оригинала пока нет." + ); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось собрать patch artifact.")); + } finally { + setBuildingPatchProjectId(null); + } + } + + async function handleRunPatchDryRun(projectId: string, changesetId: string) { + const dryRunKey = `${projectId}:${changesetId}`; + setRunningPatchDryRunKey(dryRunKey); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(projectId); + + try { + const result = await runPatchDryRun(projectId, changesetId); + + setPatchDryRuns((currentDryRuns) => ({ + ...currentDryRuns, + [projectId]: result.dryRun + })); + setProjectActionMessage( + result.dryRun.state === "passed" + ? `Dry-run passed: ${result.dryRun.readiness.passedCount}/${result.dryRun.readiness.checkCount} checks.` + : `Dry-run blocked: ${result.dryRun.readiness.blockerCount} blockers.` + ); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось выполнить dry-run.")); + } finally { + setRunningPatchDryRunKey(null); + } + } + + function handleSelectVisualComposerConfig( + projectId: string, + configId: string, + workspaceKey: string | null, + pageId: string | null, + targets: WorkspaceTarget[], + sectionTextMap: WorkspaceSectionTextMap | undefined, + configs: VisualComposerConfig[] + ) { + setActiveVisualComposerConfigIds((currentConfigIds) => ({ + ...currentConfigIds, + [projectId]: configId + })); + + if (!workspaceKey) { + return; + } + + const selectedConfig = configs.find((config) => config.id === configId); + const systemSnapshot = getVisualComposerSystemSnapshot(targets, configId); + const savedSnapshot = selectedConfig?.sectionTextsByWorkspace?.[workspaceKey] ?? null; + const pageSnapshot = pageId ? (selectedConfig?.sectionTextsByPageId?.[pageId] ?? null) : null; + const nextSnapshot = systemSnapshot ?? savedSnapshot ?? pageSnapshot; + const semanticBlockOverrides = selectedConfig?.semanticBlockOverridesByWorkspace?.[workspaceKey] ?? null; + + if (semanticBlockOverrides) { + setWorkspaceSemanticBlockOverrides((currentOverrides) => ({ + ...currentOverrides, + [workspaceKey]: semanticBlockOverrides + })); + setSemanticBlockGenerationDirty((currentDirty) => ({ + ...currentDirty, + [workspaceKey]: false + })); + } + + if (!nextSnapshot || Object.keys(nextSnapshot).length === 0) { + return; + } + + setWorkspaceSectionTexts((currentTexts) => ({ + ...currentTexts, + [workspaceKey]: { + ...(currentTexts[workspaceKey] ?? sectionTextMap ?? {}), + ...nextSnapshot + } + })); + } + + function handleSaveVisualComposerConfig( + projectId: string, + workspaceKey: string | null, + pageId: string | null, + targets: WorkspaceTarget[], + sectionTextMap: WorkspaceSectionTextMap | undefined + ) { + const name = window.prompt("Название конфигурации", "Вариант"); + const cleanName = name?.trim(); + + if (!cleanName) { + return; + } + + const config: VisualComposerConfig = { + id: `user:${Date.now()}`, + label: cleanName.slice(0, 28), + savedAt: new Date().toISOString(), + semanticBlockOverridesByWorkspace: + workspaceKey && hasOwnRecordKey(workspaceSemanticBlockOverrides, workspaceKey) + ? { + [workspaceKey]: workspaceSemanticBlockOverrides[workspaceKey] ?? [] + } + : undefined, + sectionTextsByPageId: pageId + ? { + [pageId]: getVisualComposerSectionSnapshot(targets, sectionTextMap) + } + : undefined, + sectionTextsByWorkspace: workspaceKey + ? { + [workspaceKey]: getVisualComposerSectionSnapshot(targets, sectionTextMap) + } + : undefined, + source: "user" + }; + + setVisualComposerConfigs((currentConfigs) => ({ + ...currentConfigs, + [projectId]: [...(currentConfigs[projectId] ?? []), config].slice(-8) + })); + setActiveVisualComposerConfigIds((currentConfigIds) => ({ + ...currentConfigIds, + [projectId]: config.id + })); + setProjectActionError(null); + setProjectActionMessage(`Конфигурация сохранена: ${config.label}.`); + setProjectActionProjectId(projectId); + } + + async function handleRegenerateVisualComposerVariant( + projectId: string, + workspaceKey: string | null, + pageId: string | null, + pageTitle: string | null, + targets: WorkspaceTarget[], + sectionTextMap: WorkspaceSectionTextMap | undefined + ) { + setGeneratingVisualComposerProjectId(projectId); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(projectId); + + try { + const seeds = buildVisualComposerSeedInputs(pageId, pageTitle, targets, sectionTextMap); + const result = await generateVisualComposerVariants(projectId, seeds); + const variants = result.visualComposerVariants; + const firstGeneratedConfig = getVisualComposerGeneratedConfigurations(variants)[0] ?? null; + + setVisualComposerVariantContracts((currentContracts) => ({ + ...currentContracts, + [projectId]: variants + })); + + if (firstGeneratedConfig) { + setActiveVisualComposerConfigIds((currentConfigIds) => ({ + ...currentConfigIds, + [projectId]: firstGeneratedConfig.id + })); + + if (workspaceKey && pageId) { + const pageSnapshot = firstGeneratedConfig.sectionTextsByPageId?.[pageId] ?? null; + + if (pageSnapshot && Object.keys(pageSnapshot).length > 0) { + setWorkspaceSectionTexts((currentTexts) => ({ + ...currentTexts, + [workspaceKey]: { + ...(currentTexts[workspaceKey] ?? sectionTextMap ?? {}), + ...pageSnapshot + } + })); + } + } + } + + setProjectActionMessage( + variants.state === "blocked" + ? `Варианты заблокированы: ${variants.readiness.blockerCount} blockers.` + : `Собрано ${variants.readiness.variantCount} вариантов · blocks ${variants.readiness.semanticBlockCount} · slots ${variants.readiness.readySlotCount}/${variants.readiness.slotCount} · fallback ${variants.readiness.slotLevelFallbackCount}.` + ); + if (workspaceKey) { + setSemanticBlockGenerationDirty((currentDirty) => ({ + ...currentDirty, + [workspaceKey]: false + })); + } + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось собрать варианты visual composer.")); + } finally { + setGeneratingVisualComposerProjectId(null); + } + } + function activateWorkspaceTarget(workspaceKey: string, target: WorkspaceTarget) { setActiveWorkspaceTargetIds((currentTargetIds) => ({ ...currentTargetIds, @@ -7367,11 +10783,53 @@ export function App() { ...currentSectionIds, [workspaceKey]: target.sectionId })); - openWorkspaceDrawer(getWorkspaceUiKeyFromWorkspaceKey(workspaceKey), "issues"); + openWorkspaceDrawer( + getWorkspaceUiKeyFromWorkspaceKey(workspaceKey), + target.source === "rewrite_slot" || target.source === "workspace_section" || target.source === "content_field" + ? "rewrite" + : "issues" + ); scrollWorkspaceFieldIntoView(workspaceKey, target.id); } + function excludeWorkspaceTarget(workspaceKey: string, target: WorkspaceTarget) { + setExcludedWorkspaceTargetIds((currentExcludedIds) => ({ + ...currentExcludedIds, + [workspaceKey]: { + ...(currentExcludedIds[workspaceKey] ?? {}), + [target.id]: true + } + })); + setActiveWorkspaceTargetIds((currentTargetIds) => { + if (currentTargetIds[workspaceKey] !== target.id) { + return currentTargetIds; + } + + const nextTargetIds = { ...currentTargetIds }; + delete nextTargetIds[workspaceKey]; + return nextTargetIds; + }); + setProjectActionError(null); + setProjectActionMessage(`Исключено из правок и генерации: ${target.title}.`); + setProjectActionProjectId(getWorkspaceUiKeyFromWorkspaceKey(workspaceKey)); + } + + function restoreExcludedWorkspaceTargets(workspaceKey: string) { + setExcludedWorkspaceTargetIds((currentExcludedIds) => { + if (!currentExcludedIds[workspaceKey]) { + return currentExcludedIds; + } + + const nextExcludedIds = { ...currentExcludedIds }; + delete nextExcludedIds[workspaceKey]; + return nextExcludedIds; + }); + setProjectActionError(null); + setProjectActionMessage("Исключённые блоки возвращены в правки и генерацию."); + setProjectActionProjectId(getWorkspaceUiKeyFromWorkspaceKey(workspaceKey)); + } + function openWorkspaceDrawer(workspaceKey: string, drawer: WorkspaceDrawerPanel) { setWorkspaceDrawers((currentDrawers) => { const currentWorkspaceDrawers = currentDrawers[workspaceKey] ?? []; @@ -7394,6 +10852,12 @@ export function App() { ? currentWorkspaceDrawers.filter((currentDrawer) => currentDrawer !== drawer) : [...currentWorkspaceDrawers, drawer]; + if (nextWorkspaceDrawers.length === 0) { + const nextDrawers = { ...currentDrawers }; + delete nextDrawers[workspaceKey]; + return nextDrawers; + } + return { ...currentDrawers, [workspaceKey]: nextWorkspaceDrawers @@ -7409,16 +10873,51 @@ export function App() { return currentDrawers; } + const nextWorkspaceDrawers = currentWorkspaceDrawers.filter((currentDrawer) => currentDrawer !== drawer); + + if (nextWorkspaceDrawers.length === 0) { + const nextDrawers = { ...currentDrawers }; + delete nextDrawers[workspaceKey]; + return nextDrawers; + } + return { ...currentDrawers, - [workspaceKey]: currentWorkspaceDrawers.filter((currentDrawer) => currentDrawer !== drawer) + [workspaceKey]: nextWorkspaceDrawers }; }); } - function updateWorkspacePreviewMarkers( - workspaceKey: string, - targets: WorkspaceTarget[], + function serializeSemanticBlockEditBoxes(boxes: Array = []) { + return boxes.map((box) => ({ + anchors: box.anchors, + capturedEntities: box.capturedEntities, + geometry: box.geometry, + height: + typeof (box as WorkspaceSemanticBlockDraftBox).height === "number" + ? (box as WorkspaceSemanticBlockDraftBox).height + : box.geometry?.height, + id: box.id, + left: + typeof (box as WorkspaceSemanticBlockDraftBox).left === "number" + ? (box as WorkspaceSemanticBlockDraftBox).left + : box.geometry?.left, + targetIds: box.targetIds, + title: box.title, + top: + typeof (box as WorkspaceSemanticBlockDraftBox).top === "number" + ? (box as WorkspaceSemanticBlockDraftBox).top + : box.geometry?.top, + width: + typeof (box as WorkspaceSemanticBlockDraftBox).width === "number" + ? (box as WorkspaceSemanticBlockDraftBox).width + : box.geometry?.width + })); + } + + function updateWorkspacePreviewMarkers( + workspaceKey: string, + targets: WorkspaceTarget[], scopeItems: WorkspaceScopeItem[] = [], activeScopeItem: WorkspaceScopeItem | null = null ) { @@ -7433,32 +10932,52 @@ export function App() { [workspaceKey]: [] })); - frameWindow.postMessage( - { + const projectId = getWorkspaceUiKeyFromWorkspaceKey(workspaceKey); + const semanticBlockEditModeEnabled = + SEMANTIC_BLOCK_EDITOR_ENABLED && activeStageIndex === 6 && Boolean(semanticBlockEditProjectModes[projectId]); + const semanticBlockBoxes = semanticBlockEditModeEnabled + ? serializeSemanticBlockEditBoxes(getSemanticBlockEditBoxes(workspaceKey)) + : []; + + frameWindow.postMessage( + { type: "seo-mode:measure-targets", workspaceKey, activePageId: activeScopeItem?.pageId ?? "", activeScopeId: activeScopeItem?.scopeId ?? "", + hasManualSemanticBlockSet: semanticBlockEditModeEnabled ? hasSemanticBlockManualSet(workspaceKey) : false, + semanticBlockBoxes, + semanticBlockEditMode: semanticBlockEditModeEnabled, scopePages: scopeItems - .filter((item) => item.previewTargetSelector) .map((item) => ({ label: item.title, + kind: item.kind, + order: item.order, pageId: item.pageId, scopeId: item.scopeId, - selector: item.previewTargetSelector, - selectorIndex: item.previewTargetIndex + selector: getWorkspaceScopePreviewFocusSelector(item), + selectorIndex: getWorkspaceScopePreviewFocusIndex(item) })), targets: targets - .filter((target) => (target.markerSelectors.length > 0 || target.markerFallbackSelector) && target.issues.length > 0) + .filter( + (target) => + (target.markerSelectors.length > 0 || target.markerFallbackSelector || target.source === "content_field") && + (target.issues.length > 0 || target.source === "rewrite_slot" || target.permission === "editable") + ) .map((target) => { - const shouldAnchorToRenderedSection = activeScopeItem?.kind === "section" && Boolean(activeScopeItem.previewTargetSelector); + const shouldAnchorToRenderedSection = + !semanticBlockEditModeEnabled && + activeScopeItem?.kind === "section" && + Boolean(activeScopeItem.previewTargetSelector); const sectionFallbackSelector = shouldAnchorToRenderedSection - ? activeScopeItem?.previewTargetSelector ?? null + ? getWorkspaceScopePreviewFocusSelector(activeScopeItem) : target.markerFallbackSelector; return { fallbackSelector: sectionFallbackSelector, - fallbackSelectorIndex: shouldAnchorToRenderedSection ? activeScopeItem?.previewTargetIndex ?? 0 : null, + fallbackSelectorIndex: shouldAnchorToRenderedSection + ? getWorkspaceScopePreviewFocusIndex(activeScopeItem) ?? 0 + : null, id: target.id, label: target.title, number: target.number, @@ -7467,17 +10986,184 @@ export function App() { !shouldAnchorToRenderedSection && (target.sectionId === "seo-title" || target.sectionId === "seo-description" || target.sectionId === "seo-h1"), sectionId: target.sectionId, + markerVisible: shouldShowWorkspacePreviewMarker(target), + semanticBlockEditable: isSemanticBlockEditorTarget(target), + semanticGroup: target.semanticGroup + ? { + fieldCount: target.semanticGroup.fieldCount, + fieldOrder: target.semanticGroup.fieldOrder, + id: target.semanticGroup.id, + source: target.semanticGroup.source, + title: target.semanticGroup.title, + visualGroupMode: isCarouselLikeWorkspaceTarget(target) ? "single" : target.semanticGroup.visualGroupMode ?? "auto" + } + : null, selector: target.selector, selectorIndex: null, - selectors: shouldAnchorToRenderedSection ? [] : target.markerSelectors + selectors: shouldAnchorToRenderedSection ? [] : target.markerSelectors, + source: target.source, + textNeedles: getWorkspaceTargetPreviewNeedles(target) }; }) + }, + "*" + ); + } + + function postWorkspacePreviewSectionTexts(workspaceKey: string, sectionTexts: WorkspaceSectionTextMap | undefined) { + const frameWindow = previewFrameRefs.current[workspaceKey]?.contentWindow; + + if (!frameWindow) { + return; + } + + frameWindow.postMessage( + { + type: "seo-mode:apply-section-texts", + workspaceKey, + sectionTexts: sectionTexts ?? {} + }, + "*" + ); + } + + function getSemanticBlockEditBoxes(workspaceKey: string) { + if (hasOwnRecordKey(workspaceSemanticBlockDrafts, workspaceKey)) { + return workspaceSemanticBlockDrafts[workspaceKey] ?? []; + } + + if (hasOwnRecordKey(workspaceSemanticBlockOverrides, workspaceKey)) { + return workspaceSemanticBlockOverrides[workspaceKey] ?? []; + } + + return []; + } + + function hasSemanticBlockManualSet(workspaceKey: string) { + return ( + hasOwnRecordKey(workspaceSemanticBlockDrafts, workspaceKey) || + hasOwnRecordKey(workspaceSemanticBlockOverrides, workspaceKey) + ); + } + + function postSemanticBlockEditMode( + workspaceKey: string, + enabled: boolean, + boxes: Array = [] + ) { + const frameWindow = previewFrameRefs.current[workspaceKey]?.contentWindow; + + if (!frameWindow) { + return; + } + + frameWindow.postMessage( + { + type: "seo-mode:set-semantic-block-edit-mode", + workspaceKey, + enabled, + hasManualSemanticBlockSet: enabled ? hasSemanticBlockManualSet(workspaceKey) : false, + semanticBlockBoxes: serializeSemanticBlockEditBoxes(boxes) + }, + "*" + ); + } + + function beginSemanticBlockEdit(workspaceKey: string) { + if (!SEMANTIC_BLOCK_EDITOR_ENABLED) { + const projectId = getWorkspaceUiKeyFromWorkspaceKey(workspaceKey); + + setProjectActionError(null); + setProjectActionMessage("Semantic blocks временно отключены: План правок работает по rewrite slots и workspace sections."); + setProjectActionProjectId(projectId); + return; + } + + const projectId = getWorkspaceUiKeyFromWorkspaceKey(workspaceKey); + + setSemanticBlockEditProjectModes((currentModes) => ({ + ...currentModes, + [projectId]: true + })); + setSemanticBlockEditModes((currentModes) => ({ + ...currentModes, + [workspaceKey]: true + })); + postSemanticBlockEditMode(workspaceKey, true, getSemanticBlockEditBoxes(workspaceKey)); + } + + function cancelSemanticBlockEdit(workspaceKey: string) { + const projectId = getWorkspaceUiKeyFromWorkspaceKey(workspaceKey); + const projectWorkspacePrefix = `${projectId}:`; + + setSemanticBlockEditProjectModes((currentModes) => ({ + ...currentModes, + [projectId]: false + })); + setSemanticBlockEditModes((currentModes) => ({ + ...Object.fromEntries( + Object.entries(currentModes).map(([currentWorkspaceKey, enabled]) => [ + currentWorkspaceKey, + currentWorkspaceKey.startsWith(projectWorkspacePrefix) ? false : enabled + ]) + ), + [workspaceKey]: false + })); + setWorkspaceSemanticBlockDrafts((currentDrafts) => + Object.fromEntries(Object.entries(currentDrafts).filter(([draftWorkspaceKey]) => !draftWorkspaceKey.startsWith(projectWorkspacePrefix))) + ); + postSemanticBlockEditMode(workspaceKey, false); + } + + function applySemanticBlockEdit(workspaceKey: string) { + const frameWindow = previewFrameRefs.current[workspaceKey]?.contentWindow; + + if (!frameWindow) { + return; + } + + frameWindow.postMessage( + { + type: "seo-mode:request-semantic-block-commit", + workspaceKey }, "*" ); } - function changeWorkspacePreviewMode( + function addSemanticBlock(workspaceKey: string) { + const frameWindow = previewFrameRefs.current[workspaceKey]?.contentWindow; + + if (!frameWindow) { + return; + } + + frameWindow.postMessage( + { + type: "seo-mode:add-semantic-block", + workspaceKey + }, + "*" + ); + } + + function deleteSelectedSemanticBlock(workspaceKey: string) { + const frameWindow = previewFrameRefs.current[workspaceKey]?.contentWindow; + + if (!frameWindow) { + return; + } + + frameWindow.postMessage( + { + type: "seo-mode:delete-selected-semantic-block", + workspaceKey + }, + "*" + ); + } + + function changeWorkspacePreviewMode( workspaceKey: string, mode: WorkspacePreviewMode, targets: WorkspaceTarget[], @@ -7619,6 +11305,7 @@ export function App() { if (activeProjectId === project.id) { setActiveProjectId(null); setActiveStageIndex(0); + setIsStageRailOpen(true); } if (editingProjectId === project.id) { @@ -7674,9 +11361,41 @@ export function App() { : null; const activeProjectWorkspace = activeProjectWorkspaceKey ? pageWorkspaces[activeProjectWorkspaceKey] : null; const activeProjectWorkspaceIssues = getPageIssues(activeProjectScanSummary, activeProjectWorkspaceScopeItem?.pageId ?? null); - const activeProjectWorkspaceTargets = activeProjectWorkspace - ? buildWorkspaceTargets(activeProjectWorkspace, activeProjectWorkspaceIssues) + const activeProjectRewriteDiff = activeProject ? (rewriteDiffContracts[activeProject.id] ?? null) : null; + const activeProjectPatchArtifact = activeProject ? (patchArtifacts[activeProject.id] ?? null) : null; + const activeProjectVisualComposerVariantContract = activeProject + ? (visualComposerVariantContracts[activeProject.id] ?? null) + : null; + const activeProjectWorkspaceSectionTextMap = activeProjectWorkspaceKey + ? workspaceSectionTexts[activeProjectWorkspaceKey] + : undefined; + const activeProjectRawWorkspaceTargets = activeProjectWorkspace + ? buildWorkspaceTargets(activeProjectWorkspace, activeProjectWorkspaceIssues, { + includeRewriteTargets: activeStageIndex === 6, + includeSectionFallback: activeStageIndex === 6, + contentFieldTextMap: activeProjectWorkspaceKey ? workspaceContentFieldTexts[activeProjectWorkspaceKey] : undefined, + pageId: activeProjectWorkspaceScopeItem?.pageId ?? activeProjectWorkspace.pageId, + rewriteDiff: activeProjectRewriteDiff, + scopeItem: activeProjectWorkspaceScopeItem + }) : []; + const activeProjectRuntimeWorkspaceTargets = activeProjectWorkspaceKey + ? getWorkspaceTargetsForRewriteRuntime( + activeProjectRawWorkspaceTargets, + workspaceSemanticBlockOverrides[activeProjectWorkspaceKey] + ) + : activeProjectRawWorkspaceTargets; + const activeProjectWorkspaceTargets = activeProjectWorkspaceKey + ? filterExcludedWorkspaceTargets( + activeProjectWorkspaceKey, + activeProjectRuntimeWorkspaceTargets, + excludedWorkspaceTargetIds + ) + : activeProjectRuntimeWorkspaceTargets; + const activeProjectWorkspaceUnsavedPersistableTargetCount = getWorkspaceUnsavedPersistableTargetCount( + activeProjectWorkspaceTargets, + activeProjectWorkspaceSectionTextMap + ); const activeProjectWorkspacePreviewMode = activeProjectWorkspaceKey ? (workspacePreviewModes[activeProjectWorkspaceKey] ?? "desktop") : "desktop"; @@ -7843,6 +11562,49 @@ export function App() { } }, [activeWorkspaceScopeIds, busyWorkspaceKey, pageWorkspaces, projects, scanSummaries, workspacePreviewScopeIds]); + useEffect(() => { + if (activeStageIndex !== 6 || !activeProject || !activeProjectScanSummary) { + return () => undefined; + } + + let cancelled = false; + const selectedScopeItems = getSelectedWorkspaceScopeItems(activeProjectScanSummary); + const activeScopeId = activeWorkspaceScopeIds[activeProject.id] ?? selectedScopeItems[0]?.scopeId ?? ""; + const orderedScopeItems = [...selectedScopeItems].sort((first, second) => { + if (first.scopeId === activeScopeId) return -1; + if (second.scopeId === activeScopeId) return 1; + return first.order - second.order; + }); + + const prefetch = async () => { + const batchSize = 4; + + for (let index = 0; index < orderedScopeItems.length; index += batchSize) { + if (cancelled) { + return; + } + + const batch = orderedScopeItems.slice(index, index + batchSize); + + await Promise.all( + batch.map((scopeItem) => { + const workspaceKey = getWorkspaceKey(activeProject.id, scopeItem.scopeId); + + return !pageWorkspaces[workspaceKey] && busyWorkspaceKey !== workspaceKey + ? prefetchPageWorkspace(activeProject, scopeItem) + : Promise.resolve(); + }) + ); + } + }; + + void prefetch(); + + return () => { + cancelled = true; + }; + }, [activeProject, activeProjectScanSummary, activeStageIndex, activeWorkspaceScopeIds, busyWorkspaceKey, pageWorkspaces]); + const createName = pendingProjectName.trim(); const createNameConflict = Boolean(createName) && hasProjectNameConflict(createName); const canCreateProject = Boolean(pickedFolder && createName) && !isSubmitting && !createNameConflict; @@ -7919,9 +11681,95 @@ export function App() { > {mode.label} - ))} + ))} + ) : activeProject && activeStageIndex === 6 && activeProjectWorkspaceScopeItem ? ( +
+ + + + +
) : null; function openProjectScanStage(project: ProjectSummary) { @@ -7932,6 +11780,7 @@ export function App() { setActiveProjectId(project.id); setActiveStageIndex(1); setIsProjectMenuOpen(false); + setIsStageRailOpen(true); setIsWorkPanelOpen(true); setIsWorkPanelFullscreen(false); @@ -7946,6 +11795,7 @@ export function App() { setActiveProjectId(project.id); setActiveStageIndex(nextStageIndex); setIsProjectMenuOpen(false); + setIsStageRailOpen(true); setIsWorkPanelOpen(false); setIsWorkPanelFullscreen(false); } @@ -7954,13 +11804,20 @@ export function App() { setActiveProjectId(null); setActiveStageIndex(0); setIsProjectMenuOpen(false); + setIsStageRailOpen(true); setIsWorkPanelOpen(false); setIsWorkPanelFullscreen(false); } + function closeStageRail() { + setIsStageRailOpen(false); + setIsProjectMenuOpen(false); + } + function closeWorkPanel() { setIsWorkPanelOpen(false); setIsWorkPanelFullscreen(false); + setIsStrategyExportMenuOpen(false); } function handlePipelineStageClick(stageIndex: number) { @@ -7974,6 +11831,7 @@ export function App() { } setActiveStageIndex(stageIndex); + setIsStageRailOpen(true); setIsWorkPanelOpen(true); } @@ -8037,10 +11895,12 @@ export function App() { return (
{strategyExportMenuPortal}
@@ -8479,19 +12339,92 @@ export function App() { SEO Mode
+ {activeProject && !isWorkPanelOpen ? ( +
+
+ + + {createError ? ( +

+ + {createError} +

+ ) : null} +
+
+ ) : null} - {activeProject ? ( + {activeProject && isStageRailOpen ? ( ) : null} - {!activeProject || isWorkPanelOpen ? (
@@ -8839,10 +12771,21 @@ export function App() { const serpInterpretation = serpInterpretations[project.id] ?? null; const strategySynthesis = strategySyntheses[project.id] ?? null; const strategyQualityReview = strategyQualityReviews[project.id] ?? null; - const rewritePlan = rewritePlans[project.id] ?? null; - const materialization = materializationContracts[project.id] ?? null; - const rewriteDiff = rewriteDiffContracts[project.id] ?? null; - const acceptedStrategyDecisionContract = strategyDecisionContractsByProjectId[project.id] ?? null; + const rewritePlan = rewritePlans[project.id] ?? null; + const materialization = materializationContracts[project.id] ?? null; + const rewriteDiff = rewriteDiffContracts[project.id] ?? null; + const patchArtifact = patchArtifacts[project.id] ?? null; + const patchDryRun = patchDryRuns[project.id] ?? null; + const visualComposerVariantContract = visualComposerVariantContracts[project.id] ?? null; + const visualComposerConfigList = getVisualComposerConfigurations( + visualComposerConfigs[project.id], + visualComposerVariantContract + ); + const activeVisualComposerConfigId = activeVisualComposerConfigIds[project.id] ?? "draft"; + const activeVisualComposerConfig = + visualComposerConfigList.find((config) => config.id === activeVisualComposerConfigId) ?? + visualComposerConfigList[0]; + const acceptedStrategyDecisionContract = strategyDecisionContractsByProjectId[project.id] ?? null; const stageDecisionFrame = stageDecisionFrames[activeStageIndex] ?? null; const marketDecision = marketEnrichment ? getMarketDecision(marketEnrichment) : null; const keywordPlanDecision = keywordMap ? getKeywordPlanDecision(keywordMap) : null; @@ -8875,9 +12818,13 @@ export function App() { const isEnrichingMarket = enrichingMarketProjectId === project.id; const isInterpretingSerp = interpretingSerpProjectId === project.id; const isSynthesizingStrategy = synthesizingStrategyProjectId === project.id; - const isReviewingStrategyQuality = reviewingStrategyQualityProjectId === project.id; - const isPersistingKeywordMap = persistingKeywordMapProjectId === project.id; - const modelProviderStatus = modelProviderStatuses[project.id] ?? null; + const isReviewingStrategyQuality = reviewingStrategyQualityProjectId === project.id; + const isPersistingKeywordMap = persistingKeywordMapProjectId === project.id; + const isBuildingPatch = buildingPatchProjectId === project.id; + const isRunningPatchDryRun = patchArtifact + ? runningPatchDryRunKey === `${project.id}:${patchArtifact.changesetId}` + : false; + const modelProviderStatus = modelProviderStatuses[project.id] ?? null; const defaultModelProvider = modelProviderStatus?.catalog.providers.find( (provider) => provider.id === modelProviderStatus.catalog.defaultProviderId @@ -9037,13 +12984,13 @@ export function App() { }; const strategyKeywordRows = getStrategyKeywordRows(keywordMap, serpInterpretation); const strategyCommercialKeywordRows = strategyKeywordRows.filter( - (row) => row.role === "primary" || row.role === "secondary" || row.role === "support" + (row) => row.role === "primary" || row.role === "secondary" || row.role === "support" || row.role === "differentiator" ); const strategyArticleOrDeferredRows = strategyKeywordRows.filter( - (row) => isStrategyDeferredRole(row.role) || row.targetFit === "mismatch" || !row.targetPath + (row) => isStrategyDeferredRole(row.role) || row.targetFit === "mismatch" || row.intent === "informational" || !row.targetPath ); const strategyDemandByTargetPath = strategyCommercialKeywordRows.reduce>((accumulator, row) => { - if (!row.targetPath) { + if (!row.targetPath || row.targetFit === "mismatch" || row.intent === "informational") { return accumulator; } @@ -9051,10 +12998,7 @@ export function App() { return accumulator; }, new Map()); const strategyPrimaryTargetPath = - Array.from(strategyDemandByTargetPath.entries()).sort((left, right) => right[1] - left[1])[0]?.[0] ?? - seoStrategy?.opportunities.find((opportunity) => opportunity.targetPath)?.targetPath ?? - strategyKeywordRows.find((row) => row.targetPath)?.targetPath ?? - null; + Array.from(strategyDemandByTargetPath.entries()).sort((left, right) => right[1] - left[1])[0]?.[0] ?? null; const strategyRowsForPrimaryTarget = strategyPrimaryTargetPath ? strategyKeywordRows.filter((row) => row.targetPath === strategyPrimaryTargetPath) : []; @@ -9064,26 +13008,22 @@ export function App() { row.targetFit !== "mismatch" && row.intent !== "informational" ); - const strategyPrimaryClusterByDemand = strategyRowsForPrimaryTarget - .filter((row) => row.role !== "differentiator" && !isStrategyDeferredRole(row.role)) + const strategyPrimaryClusterByDemand = strategySelectedKeywordRows + .filter((row) => row.role !== "differentiator") .reduce>((accumulator, row) => { accumulator.set(row.clusterTitle, (accumulator.get(row.clusterTitle) ?? 0) + (row.frequency ?? 0)); return accumulator; }, new Map()); const strategyPrimaryCluster = Array.from(strategyPrimaryClusterByDemand.entries()).sort((left, right) => right[1] - left[1])[0]?.[0] ?? - seoStrategy?.opportunities.find((opportunity) => opportunity.targetPath === strategyPrimaryTargetPath)?.title ?? - seoStrategy?.opportunities[0]?.title ?? - strategyKeywordRows[0]?.clusterTitle ?? + strategySelectedKeywordRows[0]?.clusterTitle ?? "кластер не выбран"; const strategyKeywordDemandTotal = strategyKeywordRows.reduce((sum, row) => sum + (row.frequency ?? 0), 0); - const strategyTotalAnalyzedDemand = - [ - serpInterpretation?.yandexEvidence.totalDemand, - yandexEvidence?.summary.totalDemand, - seoStrategy?.evidence.yandex.totalDemand, - seoStrategy?.evidence.demand.totalFrequency - ].find((value) => typeof value === "number" && value > 0) ?? strategyKeywordDemandTotal; + const strategyTotalAnalyzedDemand = getStrategyCheckedDemandTotal({ + keywordRows: strategyKeywordRows, + serpInterpretation, + yandexEvidence + }); const strategyPrimaryOpportunity = seoStrategy?.opportunities.find((opportunity) => opportunity.targetPath === strategyPrimaryTargetPath) ?? seoStrategy?.opportunities[0] ?? @@ -9102,25 +13042,27 @@ export function App() { const strategyExcludedDemandFromRows = strategyExcludedRows.reduce((sum, row) => sum + (row.frequency ?? 0), 0); const strategyDeferredDemand = Math.max(strategyTotalAnalyzedDemand - strategySelectedDemand, 0); const strategyTargetPageCount = new Set(strategyCommercialKeywordRows.map((row) => row.targetPath).filter(Boolean)).size; - const strategyPersistedPageByPath = new Map(); + const strategyKeywordMapPageByPath = new Map(); (keywordMap?.persisted.items ?? []).forEach((item) => { - if (item.targetPath && item.pageId && !strategyPersistedPageByPath.has(item.targetPath)) { - strategyPersistedPageByPath.set(item.targetPath, item.pageId); + if (item.targetPath && item.pageId && !strategyKeywordMapPageByPath.has(item.targetPath)) { + strategyKeywordMapPageByPath.set(item.targetPath, item.pageId); } }); - (scanSummary?.pages ?? []).forEach((page) => { - if (page.urlPath && !strategyPersistedPageByPath.has(page.urlPath)) { - strategyPersistedPageByPath.set(page.urlPath, page.pageId); - } - }); - const strategyPrimaryPageId = strategyPrimaryTargetPath - ? (strategyPersistedPageByPath.get(strategyPrimaryTargetPath) ?? null) + const strategyPrimaryScanPageId = getStrategyScanPageId(scanSummary, strategyPrimaryTargetPath); + const strategyPrimaryKeywordMapPageId = strategyPrimaryTargetPath + ? (strategyKeywordMapPageByPath.get(strategyPrimaryTargetPath) ?? null) : null; + const strategyPrimaryPageExistsInScan = Boolean(strategyPrimaryScanPageId); + const strategyPrimaryPageId = strategyPrimaryScanPageId; + const strategyNextStageAction = getStrategyNextStageAction(strategyPrimaryTargetPath, strategyPrimaryPageExistsInScan); + const strategyNextStageLabel = getStrategyNextStageLabel(strategyPrimaryTargetPath, strategyPrimaryPageExistsInScan); const strategyPrimaryPageStatus = strategyPrimaryPageId - ? "страница связана" + ? "страница есть в scan" : strategyPrimaryTargetPath - ? "нужно связать или создать посадочную" + ? strategyPrimaryKeywordMapPageId + ? "целевой URL выбран; страницы нет в scan" + : "целевой URL выбран; нужно создать или привязать посадочную" : "посадочная не выбрана"; const strategyPageReadyDemand = strategyPrimaryPageId ? strategySelectedDemand : 0; const strategyNeedsLandingDemand = strategyPrimaryPageId ? 0 : strategySelectedDemand; @@ -9137,6 +13079,7 @@ export function App() { .sort((left, right) => right[1] - left[1]) .map(([cluster]) => cluster) .slice(0, 3); + const strategySupportThemes = getStrategySupportThemes(strategySelectedKeywordRows, strategyPrimaryCluster); const strategyDifferentiatorCluster = strategyRowsForPrimaryTarget.find((row) => row.role === "differentiator")?.clusterTitle ?? strategySecondaryClusters[0] ?? @@ -9182,6 +13125,18 @@ export function App() { ); const isSelectedCluster = clusterTitle === strategyPrimaryCluster; const isPrimaryTarget = targetPath === strategyPrimaryTargetPath; + const selectedDemandForCluster = + isSelectedCluster || isPrimaryTarget + ? rows + .filter( + (row) => + isStrategySelectedPlanRole(row.role) && + row.targetFit !== "mismatch" && + row.intent !== "informational" && + row.targetPath === strategyPrimaryTargetPath + ) + .reduce((sum, row) => sum + (row.frequency ?? 0), 0) + : 0; const action: StrategyTargetAction = !targetPath ? "defer" : hasIntentRisk && (isSelectedCluster || isPrimaryTarget) @@ -9200,7 +13155,10 @@ export function App() { ? Math.min(demandFromRows || opportunity?.demand.totalFrequency || 0, strategyTotalAnalyzedDemand) : demandFromRows || opportunity?.demand.totalFrequency || 0, evidenceRefs: Array.from(new Set(rows.flatMap((row) => row.evidenceRefs).concat(opportunity?.evidenceRefs ?? []))), - intent: getStrategyIntentDecisionLabel(topRow?.intent), + intent: + action === "partial" + ? "смешанный: коммерческое ядро + инфо-слой" + : getStrategyIntentDecisionLabel(topRow?.intent), reason: formatStrategyDisplayText( action === "partial" ? "Кластер выбран как ставка, но точные широкие или информационные фразы не становятся основной ставкой без отдельного подтверждения выдачи." @@ -9215,6 +13173,7 @@ export function App() { : targetPath ? "следить за смешением соседних кластеров" : "нет подтверждённой посадочной", + selectedDemand: selectedDemandForCluster, targetPath }; }); @@ -9232,6 +13191,7 @@ export function App() { intent: "нужно проверить", reason: formatStrategyDisplayText(opportunity.whyItMatters || opportunity.recommendation), risk: formatStrategyDisplayText(opportunity.timing.reason), + selectedDemand: 0, targetPath: opportunity.targetPath })) ) @@ -9287,7 +13247,7 @@ export function App() { return accumulator; }, new Map()); const strategyLandingRows = Array.from(strategyLandingGroups.entries()).map(([targetPath, rows]) => { - const pageId = strategyPersistedPageByPath.get(targetPath) ?? null; + const pageId = getStrategyScanPageId(scanSummary, targetPath); const hasCommercialRows = rows.some( (row) => row.role === "primary" || @@ -9345,17 +13305,17 @@ export function App() { ? "Главная держит общий продуктовый смысл и не должна становиться одной узкой SEO-страницей." : "Страница может поддержать выбранный кластер, но не заменяет основную посадочную.", requiredBlocks, - status: pageId ? "страница связана" : "нужно связать или создать посадочную", + status: pageId ? "страница есть в scan" : "целевой URL выбран; нужно создать или привязать посадочную", targetClusters, targetPath }; }); const hasHomePage = (scanSummary?.pages ?? []).some( - (page) => page.urlPath === "/" || page.previewKind === "home_section" + (page) => page.urlPath === "/" || page.previewKind === "source_section" ); if (hasHomePage && strategyPrimaryTargetPath && strategyPrimaryTargetPath !== "/" && !strategyLandingRows.some((row) => row.targetPath === "/")) { - const homePageId = strategyPersistedPageByPath.get("/") ?? null; + const homePageId = getStrategyScanPageId(scanSummary, "/"); strategyLandingRows.unshift({ action: "keep", demand: 0, @@ -9363,7 +13323,7 @@ export function App() { pageRole: "home", reason: "Главную не превращаем в посадочную одного кластера, пока выбран отдельный URL посадочной.", requiredBlocks: ["общая роль продукта", "переход на выбранную посадочную", "без точного переспама"], - status: homePageId ? "страница связана" : "нужна явная связь с главной", + status: homePageId ? "страница есть в scan" : "нужна явная связь с главной", targetClusters: [strategyPrimaryCluster], targetPath: "/" }); @@ -9426,18 +13386,29 @@ export function App() { strategyTitleDifferentiator ? `Отдельный акцент - ${formatLowerFirst(strategyTitleDifferentiator)}. ` : "" }Сценарии, роли, процессы и контроль результата.` : "Нужен главный кластер и продуктовая роль посадочной."; + const formatEditorialSupportEvidence = (ref: string | undefined) => { + const evidenceLabel = formatStrategyEvidenceRef(ref); + + if (!evidenceLabel) { + return null; + } + + return `поддержано выбранным кластером и ${evidenceLabel + .replace(/^подтверждено Wordstat: фраза/i, "Wordstat-фразой") + .replace(/^решение карты фраз:/i, "решением карты фраз")}`; + }; const strategyPlanPreviewRows = [ { field: "SEO-заголовок", reason: - formatStrategyEvidenceRef(strategyPrimaryKeyword?.evidenceRefs[0]) ?? + formatEditorialSupportEvidence(strategyPrimaryKeyword?.evidenceRefs[0]) ?? "title держит продукт и главный кластер без перегруза отличающим слоем", value: strategySeoTitleCandidate }, { field: "Черновик описания страницы", reason: - formatStrategyEvidenceRef(strategySupportKeywords[0]?.evidenceRefs[0]) ?? + formatEditorialSupportEvidence(strategySupportKeywords[0]?.evidenceRefs[0]) ?? "дифференциатор и поддерживающие фразы уходят в описание и секции, а не перегружают title", value: strategyMetaDescriptionCandidate }, @@ -9451,11 +13422,11 @@ export function App() { }, { field: "Секции страницы", - reason: "Секции строятся из поддерживающих кластеров, не из сырого списка фраз.", + reason: "Секции строятся из поддерживающих тем внутри выбранного кластера, не из сырого списка фраз.", value: - strategySecondaryClusters.length > 0 - ? strategySecondaryClusters.slice(0, 3).join(" · ") - : "Нужны поддерживающие кластеры" + strategySupportThemes.length > 0 + ? strategySupportThemes.slice(0, 3).join(" · ") + : "Нужны поддерживающие темы" }, { field: "FAQ и разметка", @@ -9500,7 +13471,7 @@ export function App() { ); addStrategyBlockedMove( "Не применять без связанной посадочной, сравнения изменений и проверки", - "Stage 06 только выбирает стратегию. Исходники сайта не меняются до отдельного этапа применения." + "06 Стратегия только выбирает маршрут. Исходники сайта не меняются до отдельного этапа применения." ); (keywordMap?.blockers ?? []).slice(0, 2).forEach((blocker) => { addStrategyBlockedMove("Закрыть блокер карты фраз", blocker); @@ -9578,7 +13549,12 @@ export function App() { const strategyDecisionScenarios: StrategyDecisionScenario[] = [ buildStrategyScenario({ businessFit: strategyPrimaryTargetPath ? "high" : "medium", - cta: strategyPrimaryPageId ? "зафиксировать и открыть план" : "зафиксировать и связать посадочную", + cta: + strategyNextStageAction === "rewrite_existing_landing" + ? "зафиксировать и открыть план" + : strategyNextStageAction === "create_or_prepare_landing" + ? "зафиксировать и подготовить посадочную" + : "зафиксировать и выбрать посадочную", demand: strategySelectedDemand, effort: strategyPrimaryPageId ? "medium" : "medium", id: "landing_first", @@ -9696,13 +13672,16 @@ export function App() { ? formatForecastRange(selectedStrategyScenario.trafficMin, selectedStrategyScenario.trafficMax, " визитов/мес") : "n/a" }, - { - detail: strategyPrimaryTargetPath - ? `Этап 07 получит план правок для ${strategyPrimaryTargetPath}` - : "без URL план правок не открываем", + { + detail: strategyNextStageLabel, label: "Следующий этап", tone: strategyPrimaryTargetPath ? "good" : "warning", - value: strategyPrimaryPageId ? "план правок" : "связать посадочную" + value: + strategyNextStageAction === "rewrite_existing_landing" + ? "rewrite existing" + : strategyNextStageAction === "create_or_prepare_landing" + ? "create / prepare" + : "select landing" } ]; const strategyDemandFunnelItems: SeoChartDatum[] = [ @@ -9768,6 +13747,8 @@ export function App() { strategySelectedDemandComposition.length > 0 ? strategySelectedDemandComposition.map((part) => `${formatCount(part.demand)} - ${part.label}`).join("; ") : (strategyPrimaryTargetPath ?? "посадочная не выбрана"); + const strategySelectedDemandEvidenceComposition = getStrategyDemandEvidenceComposition(strategySelectedKeywordRows); + const strategySelectedDemandEvidenceDetail = formatStrategyDemandEvidenceComposition(strategySelectedDemandEvidenceComposition); const strategyDemandLedgerRows: StrategyDemandLedgerRow[] = [ { detail: "очищенный пул спроса, который используется как база решения", @@ -9781,6 +13762,12 @@ export function App() { tone: "good", value: formatCount(strategySelectedDemand) }, + { + detail: strategySelectedDemandEvidenceDetail, + label: "Доказательность плана", + tone: getStrategyDemandEvidenceTone(strategySelectedDemandEvidenceComposition), + value: formatGroupCount(strategySelectedDemandEvidenceComposition.length) + }, { detail: "очередь, статьи, широкий интент или ручная проверка", label: "Отложено", @@ -9802,8 +13789,8 @@ export function App() { : "0" }, { - detail: "не используется для прогноза: может содержать пересечения, широкие фразы и вторичные роли", - label: "Сырая сумма по посадочной", + detail: "контрольная сумма всех связанных строк посадочной; не используется для прогноза, если включает спорные роли", + label: "Связанный спрос посадочной", tone: strategyRawPortfolioDemand > strategySelectedDemand ? "warning" : "neutral", value: formatCount(strategyRawPortfolioDemand) } @@ -9827,6 +13814,12 @@ export function App() { const strategySerpPhraseRows = serpInterpretation?.phraseInterpretations ?? []; const strategySerpInterpretedCount = serpInterpretation?.readiness.interpretedPhraseCount ?? strategySerpPhraseRows.length; + const strategySerpCoverageTargetCount = Math.max( + strategySerpInterpretedCount, + serpInterpretation?.yandexEvidence.checkedPhraseCount ?? 0, + yandexEvidence?.summary.checkedPhraseCount ?? 0, + strategyKeywordRows.length + ); const strategySerpCommercialCount = strategySerpPhraseRows.filter((row) => row.intent === "commercial").length; const strategySerpMixedCount = strategySerpPhraseRows.filter((row) => row.intent === "mixed").length; const strategySerpInfoCount = strategySerpPhraseRows.filter((row) => row.intent === "informational").length; @@ -9844,6 +13837,18 @@ export function App() { tone: strategySelectedFitCount > 0 ? "good" : "warning", value: strategySelectedFitCount > 0 ? "смешанный fit" : "нужна проверка" }, + { + detail: + strategySerpInterpretedCount > 0 + ? "достаточно для выбора первой посадочной; недостаточно для автоматического расширения статей/backlog" + : "выдача ещё не стала фактором решения", + label: "SERP-покрытие", + tone: strategySerpInterpretedCount > 0 ? "warning" : "neutral", + value: + strategySerpCoverageTargetCount > 0 + ? `${strategySerpInterpretedCount} / ${strategySerpCoverageTargetCount}` + : "нет данных" + }, { detail: strategySerpQueueCount > 0 @@ -9960,6 +13965,21 @@ export function App() { values[scenario.id] = mapper(scenario); return values; }, {}); + const getStrategyLandingNeedLabel = (targetPath: string | null) => { + if (!targetPath) { + return "выбрать посадочную"; + } + + if (strategyNextStageAction === "create_or_prepare_landing") { + return `создать / подготовить ${targetPath}`; + } + + if (strategyNextStageAction === "rewrite_existing_landing") { + return `план правок для ${targetPath}`; + } + + return "выбрать посадочную"; + }; const getStrategyScenarioNeed = (scenario: StrategyDecisionScenario) => { if (scenario.id === "landing_plus_article") { return "посадочная + инфо URL"; @@ -9973,7 +13993,7 @@ export function App() { return "ручное решение по широкому спросу"; } - return strategyPrimaryTargetPath ? `план ${strategyPrimaryTargetPath}` : "выбрать посадочную"; + return getStrategyLandingNeedLabel(strategyPrimaryTargetPath); }; const strategyScenarioComparisonRows = [ { @@ -10089,11 +14109,9 @@ export function App() { ? "отдельный справочный URL" : selectedStrategyScenario.id === "homepage_only" ? "работать только с текущими страницами" - : selectedStrategyScenario.id === "broad_attack" + : selectedStrategyScenario.id === "broad_attack" ? "ручное подтверждение широкого интента" - : strategyPrimaryTargetPath - ? `план для ${strategyPrimaryTargetPath}` - : "выбрать посадочную" + : getStrategyLandingNeedLabel(strategyPrimaryTargetPath) } ] : []; @@ -10139,17 +14157,21 @@ export function App() { conversionRate: strategyForecastSettings.conversionRate, deferredDemand: strategyDeferredDemand, demandComposition: strategyDecisionDemandComposition, + demandEvidenceComposition: strategySelectedDemandEvidenceComposition, differentiatorClusters: strategyDifferentiatorClusters, forecastModelId: strategyForecastSettings.ctrModel, - horizonMonths: strategyForecastSettings.horizonMonths, - landing: strategyPrimaryTargetPath, - nextStage: strategyPrimaryTargetPath ? `план правок для ${strategyPrimaryTargetPath}` : "выбрать посадочную", - notPrimaryPhrases: strategyNotPrimaryPhrases, + horizonMonths: strategyForecastSettings.horizonMonths, + landing: strategyPrimaryTargetPath, + landingPageExistsInScan: strategyPrimaryPageExistsInScan, + nextStage: strategyNextStageLabel, + nextStageAction: strategyNextStageAction, + notPrimaryPhrases: strategyNotPrimaryPhrases, primaryCluster: strategyPrimaryCluster, scenarioId: selectedStrategyScenario.id, scenarioLabel: selectedStrategyScenario.label, selectedDemand: strategySelectedDemand, supportClusters: strategySupportClustersForContract, + supportThemes: strategySupportThemes, visibilityMax: strategyCtrRange.max, visibilityMin: strategyCtrRange.min }) @@ -10168,8 +14190,8 @@ export function App() { }, { detail: "идут в секции, FAQ, внутренние ссылки и поддерживающий текст", - label: "Поддерживающие кластеры", - value: strategySecondaryClusters.length > 0 ? strategySecondaryClusters.join(" · ") : "нужно добрать" + label: "Поддерживающие темы", + value: strategySupportThemes.length > 0 ? strategySupportThemes.join(" · ") : "нужно добрать" }, { detail: "усиливает страницу, но не меняет основную ставку", @@ -10246,6 +14268,12 @@ export function App() { rows: strategyLandingDecisionRows.filter((row) => row.targetPath === "/" || row.action === "keep") } ].filter((lane) => lane.rows.length > 0); + const strategySemanticPortfolioRows = buildStrategySitePortfolioRows({ + keywordRows: strategyKeywordRows, + landing: strategyPrimaryTargetPath, + scanSummary, + semanticAnalysis + }); const strategyDecisionMetrics = [ { detail: strategyPrimaryCluster, @@ -10300,19 +14328,80 @@ export function App() { selectedWorkspaceScopeItems.find((item) => item.scopeId === activeWorkspaceScopeId) ?? selectedWorkspaceScopeItems[0] ?? null; - const activeWorkspaceKey = activeWorkspaceScopeItem - ? getWorkspaceKey(project.id, activeWorkspaceScopeItem.scopeId) - : null; - const activeWorkspace = activeWorkspaceKey ? pageWorkspaces[activeWorkspaceKey] : null; - const activeWorkspaceText = - activeWorkspaceKey && activeWorkspace - ? (workspaceDraftTexts[activeWorkspaceKey] ?? activeWorkspace.item.workingText) - : ""; - const isWorkspaceBusy = activeWorkspaceKey ? busyWorkspaceKey === activeWorkspaceKey : false; + const activeWorkspaceKey = activeWorkspaceScopeItem + ? getWorkspaceKey(project.id, activeWorkspaceScopeItem.scopeId) + : null; + const activeWorkspace = activeWorkspaceKey ? pageWorkspaces[activeWorkspaceKey] : null; + const activeWorkspaceSectionTextMap = activeWorkspaceKey ? workspaceSectionTexts[activeWorkspaceKey] : undefined; + const activeWorkspaceText = + activeWorkspaceKey && activeWorkspace + ? buildWorkingTextFromSections(activeWorkspace, activeWorkspaceSectionTextMap) + : ""; + const isWorkspaceBusy = activeWorkspaceKey ? busyWorkspaceKey === activeWorkspaceKey : false; const activeWorkspaceIssues = getPageIssues(scanSummary, activeWorkspaceScopeItem?.pageId ?? null); - const activeWorkspaceTargets = activeWorkspace ? buildWorkspaceTargets(activeWorkspace, activeWorkspaceIssues) : []; - const activeWorkspaceTargetId = - activeWorkspaceKey && activeWorkspaceTargets.length > 0 + const rawActiveWorkspaceTargets = activeWorkspace + ? buildWorkspaceTargets(activeWorkspace, activeWorkspaceIssues, { + includeRewriteTargets: activeStageIndex === 6, + includeSectionFallback: activeStageIndex === 6, + contentFieldTextMap: activeWorkspaceKey ? workspaceContentFieldTexts[activeWorkspaceKey] : undefined, + pageId: activeWorkspaceScopeItem?.pageId ?? activeWorkspace.pageId, + rewriteDiff, + scopeItem: activeWorkspaceScopeItem + }) + : []; + const runtimeActiveWorkspaceTargets = activeWorkspaceKey + ? getWorkspaceTargetsForRewriteRuntime( + rawActiveWorkspaceTargets, + workspaceSemanticBlockOverrides[activeWorkspaceKey] + ) + : rawActiveWorkspaceTargets; + const activeWorkspaceTargets = activeWorkspaceKey + ? filterExcludedWorkspaceTargets( + activeWorkspaceKey, + runtimeActiveWorkspaceTargets, + excludedWorkspaceTargetIds + ) + : runtimeActiveWorkspaceTargets; + const activeWorkspaceExcludedTargetCount = getExcludedWorkspaceTargetCount( + activeWorkspaceKey, + excludedWorkspaceTargetIds + ); + const activeWorkspaceRewriteTargetCount = activeWorkspaceTargets.filter( + (target) => + target.source === "rewrite_slot" || + target.source === "workspace_section" || + target.source === "content_field" + ).length; + const activeWorkspaceAuditTargetCount = activeWorkspaceTargets.filter( + (target) => target.source === "audit_issue" + ).length; + const activeWorkspaceUnsavedSectionCount = + activeWorkspace && activeWorkspaceSectionTextMap + ? getWorkspaceUnsavedSectionCount(activeWorkspace, activeWorkspaceSectionTextMap) + : 0; + const activeWorkspaceSavedOriginalChangeCount = activeWorkspace + ? getWorkspaceSavedOriginalChangeCount(activeWorkspace) + : 0; + const activeWorkspaceEditableTargetCount = activeWorkspaceTargets.filter( + (target) => target.permission === "editable" + ).length; + const isVisualComposerMode = activeStageIndex === 6 && Boolean(patchArtifact?.provenance?.gates?.visualComposer.ready); + const visualComposerMetrics = getVisualComposerMetrics({ + activeSavedOriginalChangeCount: activeWorkspaceSavedOriginalChangeCount, + activeUnsavedSectionCount: activeWorkspaceUnsavedSectionCount, + patchArtifact, + patchDryRun, + rewriteDiff, + variantsContract: visualComposerVariantContract + }); + const activeWorkspaceUnsavedTargetCount = activeWorkspaceTargets.filter((target) => { + return ( + target.permission === "editable" && + getWorkspaceTargetDraftStats(target, activeWorkspaceSectionTextMap).hasUnsavedChanges + ); + }).length; + const activeWorkspaceTargetId = + activeWorkspaceKey && activeWorkspaceTargets.length > 0 ? (activeWorkspaceTargetIds[activeWorkspaceKey] ?? activeWorkspaceTargets[0]?.id ?? "") : ""; const activeWorkspaceTarget = @@ -10321,11 +14410,22 @@ export function App() { null; const activeWorkspaceMarkers = activeWorkspaceKey ? (workspacePreviewMarkers[activeWorkspaceKey] ?? []) : []; const activeWorkspacePreviewMode = activeWorkspaceKey ? (workspacePreviewModes[activeWorkspaceKey] ?? "desktop") : "desktop"; + const isSemanticBlockEditMode = activeWorkspaceKey + ? SEMANTIC_BLOCK_EDITOR_ENABLED && Boolean(semanticBlockEditProjectModes[project.id]) + : false; const activeWorkspaceUiKey = project.id; const activeWorkspaceDrawers = WORKSPACE_DRAWER_ORDER.filter((drawer) => - (workspaceDrawers[activeWorkspaceUiKey] ?? []).includes(drawer) + (workspaceDrawers[activeWorkspaceUiKey] ?? []).includes(drawer) && + (drawer !== "rewrite" || activeStageIndex === 6) ); - const previewWorkspaceScopeId = workspacePreviewScopeIds[project.id] ?? activeWorkspaceScopeItem?.scopeId ?? null; + const defaultPreviewWorkspaceScopeItem = + selectedWorkspaceScopeItems.find((item) => item.previewKind === "page" && !item.previewTargetSelector) ?? + activeWorkspaceScopeItem; + const previewWorkspaceScopeId = + workspacePreviewScopeIds[project.id] ?? + defaultPreviewWorkspaceScopeItem?.scopeId ?? + activeWorkspaceScopeItem?.scopeId ?? + null; const previewWorkspaceScopeItem = selectedWorkspaceScopeItems.find((item) => item.scopeId === previewWorkspaceScopeId) ?? activeWorkspaceScopeItem; @@ -10333,12 +14433,15 @@ export function App() { 0, selectedWorkspaceScopeItems.findIndex((item) => item.scopeId === activeWorkspaceScopeItem?.scopeId) ); - const activeWorkspacePreviewSourcePath = - activeWorkspace && previewWorkspaceScopeItem - ? (previewWorkspaceScopeItem.previewSourcePath ?? previewWorkspaceScopeItem.sourcePath) - : ""; - const activeWorkspacePreviewFocusSelector = previewWorkspaceScopeItem?.previewTargetSelector ?? null; - const activeWorkspacePreviewFocusIndex = previewWorkspaceScopeItem?.previewTargetIndex ?? null; + const activeWorkspacePreviewSourcePath = previewWorkspaceScopeItem + ? (previewWorkspaceScopeItem.previewSourcePath ?? previewWorkspaceScopeItem.sourcePath) + : ""; + const activeWorkspacePreviewFocusSelector = + getWorkspaceScopePreviewFocusSelector(previewWorkspaceScopeItem); + const activeWorkspacePreviewFocusIndex = + getWorkspaceScopePreviewFocusIndex(previewWorkspaceScopeItem); + const activeWorkspacePreviewFocusOrder = + previewWorkspaceScopeItem && previewWorkspaceScopeItem.order >= 0 ? previewWorkspaceScopeItem.order : null; const activeWorkspaceIssueCounts = getAuditIssueCounts(activeWorkspaceIssues); const selectedCoverageIssueCount = getSelectedWorkspaceIssueCount(scanSummary, selectedWorkspaceScopeItems); const selectedAuditIssuesAll = scanSummary?.audit @@ -10655,7 +14758,7 @@ export function App() { ) : null} {scanSummary && activeStageIndex !== 1 ? ( -
+
{activeStageIndex === 5 ? null : ( <>
@@ -12050,7 +16153,17 @@ export function App() { {formatCount(acceptedStrategyDecisionContract.deferredDemand)} · модель{" "} {acceptedStrategyDecisionContract.forecastModel.label} -
+ + Доказательность:{" "} + {formatStrategyDemandEvidenceComposition(acceptedStrategyDecisionContract.demandEvidenceComposition)} + + + 07 План правок: {acceptedStrategyDecisionContract.nextStageAction} ·{" "} + {acceptedStrategyDecisionContract.landingPageExistsInScan + ? "страница есть в scan" + : "целевой URL выбран, страницу нужно создать или привязать"} + +
Не отдавать в title @@ -12352,9 +16465,11 @@ export function App() { type="button" > - {strategyPrimaryPageId + {strategyNextStageAction === "rewrite_existing_landing" ? "Зафиксировать и открыть план" - : "Зафиксировать и связать посадочную"} + : strategyNextStageAction === "create_or_prepare_landing" + ? "Зафиксировать и подготовить посадочную" + : "Зафиксировать и выбрать посадочную"}
@@ -12443,6 +16556,89 @@ export function App() {
+
+
+
+ Семантический портфель сайта + Выбранные страницы и блоки из конфигурации проекта +
+ + {strategyPrimaryTargetPath + ? `${strategyPrimaryTargetPath} выбран по текущему спросу; 07 План правок берёт selectedContract ${formatCount( + strategySelectedDemand + )}` + : "выбор первой посадочной ещё не зафиксирован"} + +
+
+
+ Страницы и темы + {strategySemanticPortfolioRows.length > 0 ? ( + strategySemanticPortfolioRows.map((row) => ( +
+
+ + {row.status} + + {row.title} + {row.subtitle} + + {getLandingPagePlanActionLabel(row.action)} · {row.readinessLabel} + +
+

+ {row.demand > 0 + ? `${getStrategyPortfolioDemandLabel(row)}. 07 План правок не использует это как базу прогноза.` + : row.sourceKind === "section" + ? "Этот блок выбран в рабочей зоне, но ещё не имеет подтверждённого спроса в текущей keyword iteration." + : "Для этой страницы пока нет подтверждённого спроса в текущей keyword iteration."} +

+
+ {row.clusters.map((cluster) => ( + {cluster} + ))} +
+ {row.semanticAnchors.length > 0 ? ( +
+ Якоря смысла +
+ {row.semanticAnchors.map((anchor) => ( + {anchor} + ))} +
+
+ ) : null} + {row.queryExamples.length > 0 ? ( +
+ Предположительные запросы +
+ {row.queryExamples.map((query) => ( + {query} + ))} +
+
+ ) : null} + {row.sourceSignals.length > 0 ? ( + + {row.sourceSignals.join(" · ")} + + ) : null} +
+ )) + ) : ( +
+
+ нет данных + Портфель страниц не собран + нужна выбранная конфигурация проекта +
+

Стратегия не нашла выбранных страниц или блоков в текущем scope проекта.

+
+ )} +
+
+
+ {isStrategyDecisionConfirmOpen && strategyDecisionContract ? (
- Поддержка + Поддерживающие темы - {strategyDecisionContract.supportClusters.length > 0 - ? strategyDecisionContract.supportClusters.join(" · ") + {strategyDecisionContract.supportThemes.length > 0 + ? strategyDecisionContract.supportThemes.join(" · ") : "не выбрана"} идёт в секции и описание, не в перегруженный title
- Не основная ставка - - {strategyDecisionContract.notPrimaryPhrases.length > 0 - ? strategyDecisionContract.notPrimaryPhrases.slice(0, 3).join(" · ") - : "широкие точные фразы"} - - статья, очередь или ручная проверка + Доказательность + {formatGroupCount(strategyDecisionContract.demandEvidenceComposition.length)} + {formatStrategyDemandEvidenceComposition(strategyDecisionContract.demandEvidenceComposition)}
-
+
+ Не основная ставка + + {strategyDecisionContract.notPrimaryPhrases.length > 0 + ? strategyDecisionContract.notPrimaryPhrases.slice(0, 3).join(" · ") + : "широкие точные фразы"} + + статья, очередь или ручная проверка +
+
+ 07 План правок + {strategyDecisionContract.nextStageAction} + + {strategyDecisionContract.landingPageExistsInScan + ? "rewrite существующей страницы из scan" + : "создать или привязать выбранный целевой URL"} + +
+
+ > + {strategyDecisionContract.nextStageAction === "rewrite_existing_landing" + ? "Зафиксировать и открыть план" + : strategyDecisionContract.nextStageAction === "create_or_prepare_landing" + ? "Зафиксировать и подготовить посадочную" + : "Зафиксировать маршрут"} +
@@ -12896,7 +17110,13 @@ export function App() { {row.clusterTitle} - {row.demand > 0 ? formatCount(row.demand) : "нужно добрать"} + + {row.demand > 0 + ? row.selectedDemand > 0 && row.selectedDemand < row.demand + ? `${formatCount(row.demand)} всего / ${formatCount(row.selectedDemand)} в план` + : formatCount(row.demand) + : "нужно добрать"} + {row.intent} {row.targetPath ?? "нужно выбрать посадочную"} @@ -13832,15 +18052,15 @@ export function App() {
- Главные сигналы спроса - Фразы с подтверждённой частотностью, которые реально влияют на стратегию. + Лидеры текущей проверки спроса + Это не вся карта сайта, а частотные фразы из зафиксированной keyword iteration.
{seoStrategy.evidence.demand.topSignals.slice(0, 8).map((signal) => (
{formatCount(signal.frequency ?? 0)} {signal.phrase} - {signal.targetPath ?? "страница не выбрана"} + {signal.targetPath ?? "страница не выбрана"} · не переопределяет semantic portfolio
))} {seoStrategy.evidence.demand.topSignals.length === 0 ? ( @@ -13919,85 +18139,16 @@ export function App() { )}
) : null} - {activeStageIndex === -1 ? ( + {activeStageIndex === 6 ? (
- Подготовка к модельной проверке - Что уже можно отдать на правку модели, а что сначала требует страницы или привязки. + Живой план правок + Страницы, поля и SEO-слоты перед будущей генерацией. Сайт не меняем.
-
-
- Провайдер модели - - {defaultModelProvider - ? `${defaultModelProvider.title} · ${getModelProviderStatusLabel(defaultModelProvider.status)}` - : "Статус провайдера не загружен"} - - - {defaultModelProvider - ? defaultModelProvider.summary - : "Статус подтянется после обновления проекта."} - -
-
- {latestModelTaskRun ? ( - <> - {getModelTaskRunStatusLabel(latestModelTaskRun.status)} - - {latestModelTaskRun.task.taskType - ? getModelTaskTypeLabel(latestModelTaskRun.task.taskType) - : "задача модели"}{" "} - · {latestModelTaskRun.estimate.inputTokenEstimate} tokens - - {formatDate(latestModelTaskRun.createdAt)} - - ) : ( - <> - задача Codex - Маршрут ещё не проверяли - Маршрут задачи фиксируется отдельно; результат анализа сохраняется как ручное доказательство. - - )} -
-
- - - -
-
- {rewritePlan ? ( <>
Существующие страницы, где уже понятны поле и секция.
- {rewritePlan.existingPageTargets.slice(0, 8).map((target) => ( -
- {target.keywordBindings.length} привязок - {target.urlPath ?? target.targetPath ?? target.pageTitle ?? "page"} - {target.sourcePath ?? target.pageId} -
- ))} + {rewritePlan.existingPageTargets.slice(0, 8).map((target) => { + const targetScopeItem = + selectedWorkspaceScopeItems.find((scopeItem) => scopeItem.pageId === target.pageId) ?? + null; + + return ( +
+ {target.keywordBindings.length} привязок + {target.urlPath ?? target.targetPath ?? target.pageTitle ?? "page"} + {target.sourcePath ?? target.pageId} + +
+ ); + })}
@@ -14119,7 +18300,7 @@ export function App() {

{materialization.state === "complete" ? "Очередь посадочных закрыта для текущего плана проверки." - : "Здесь фиксируются решения по плановым посадочным; исходники сайта пока не меняются."} + : "Здесь фиксируются решения по плановым посадочным; исходники сайта не меняются, дальше появится patch artifact."}

) : null} @@ -14178,9 +18359,9 @@ export function App() { ) : null} - {rewriteDiff && rewriteDiff.targets.length > 0 ? ( -
-
+ {rewriteDiff && rewriteDiff.targets.length > 0 ? ( +
+
Поля перед правкой В проверку идут только поля, где найден текущий текст. @@ -14233,12 +18414,235 @@ export function App() {
) : null}
-
-
- ) : null} + + + ) : null} - {rewritePlan.blockers.length > 0 || (rewriteDiff?.blockers.length ?? 0) > 0 ? ( -
+
+
+ {patchArtifact ? getPatchArtifactStateLabel(patchArtifact.state) : "patch не собран"} + + Patch artifact ·{" "} + {patchArtifact + ? `${patchArtifact.readiness.changeCount} правок · ${patchArtifact.readiness.affectedFileCount} файлов` + : "только сохранённые drafts"} + + + {patchArtifact + ? `changeset ${patchArtifact.changesetId.slice(0, 8)} · ${patchArtifact.storage.objectKey}` + : "Собирает manifest из working draft. Source tree остаётся read-only."} + + {patchArtifact ? ( + + Drafts: {patchArtifact.readiness.changedDraftCount}/{patchArtifact.readiness.draftCount} · source write:{" "} + {patchArtifact.sourceSafety.editorWritesSourceFiles ? "yes" : "no"} + + ) : null} + {patchArtifact ? ( + + Fingerprints:{" "} + {patchArtifact.readiness.sourceFingerprintCount ?? patchArtifact.sourceFingerprints?.length ?? 0}/ + {patchArtifact.readiness.affectedFileCount} files · unreadable:{" "} + {patchArtifact.readiness.sourceUnreadableCount ?? 0} + + ) : null} + {patchArtifact ? ( + + Provenance: {getPatchProvenanceLabel(patchArtifact.provenance)} · blockers:{" "} + {patchArtifact.readiness.provenanceBlockerCount ?? patchArtifact.provenance?.blockers.length ?? 0} + + ) : null} + {patchArtifact?.provenance ? ( + + Visual: {getPatchGateLabel(patchArtifact.provenance.gates?.visualComposer.ready)} · Apply:{" "} + {getPatchGateLabel(patchArtifact.provenance.gates?.productionPatchRun.ready)} + + ) : null} +
+

+ Build Patch материализует отдельный JSON manifest для dry-run и будущего production patch-run. + Он не запускает скрипт и не меняет исходники сайта. +

+
+ + +
+
+ + {patchDryRun ? ( +
+
+ {getPatchDryRunStateLabel(patchDryRun.state)} + + Checks: {patchDryRun.readiness.passedCount}/{patchDryRun.readiness.checkCount} · blockers:{" "} + {patchDryRun.readiness.blockerCount} + + + Missing source: {patchDryRun.readiness.missingSourceCount} · stale text:{" "} + {patchDryRun.readiness.staleChangeCount} · empty after:{" "} + {patchDryRun.readiness.emptyAfterCount} + + + Fingerprint mismatch: {patchDryRun.readiness.fingerprintMismatchCount ?? 0} · missing:{" "} + {patchDryRun.readiness.fingerprintMissingCount ?? 0} + + + Provenance: {getPatchProvenanceLabel(patchDryRun.provenance)} · blockers:{" "} + {patchDryRun.readiness.provenanceBlockerCount ?? patchDryRun.provenance?.blockers.length ?? 0} + + {patchDryRun.provenance ? ( + + Visual: {getPatchGateLabel(patchDryRun.provenance.gates?.visualComposer.ready)} · Apply:{" "} + {getPatchGateLabel(patchDryRun.provenance.gates?.productionPatchRun.ready)} + + ) : null} + {patchDryRun.storage.objectKey} +
+

+ Dry-run только читает текущий source tree и проверяет совместимость manifest. + Patch script не запускался. +

+
+ ) : null} + + {patchArtifact && patchArtifact.changes.length > 0 ? ( +
+ Patch preview + {patchArtifact.changes.slice(0, 6).map((change) => ( +

+ {change.sourcePath} · {change.title} · delta {change.delta >= 0 ? "+" : ""} + {change.delta} +

+ ))} +
+ ) : null} + + {patchDryRun?.provenance?.blockers.length ? ( +
+ Provenance blockers + {(patchDryRun.provenance.gates?.productionPatchRun.blockers ?? patchDryRun.provenance.blockers).map((blocker) => ( +

{blocker}

+ ))} + {(patchDryRun.provenance.gates?.visualComposer.blockers ?? []).map((blocker) => ( +

Visual: {blocker}

+ ))} + {patchDryRun.provenance.warnings.map((warning) => ( +

Warning: {warning}

+ ))} +
+ ) : null} + + {patchDryRun && patchDryRun.checks.some((check) => check.status === "blocked") ? ( +
+ Dry-run blockers +
+ {patchDryRun.checks + .filter((check) => check.status === "blocked") + .slice(0, 6) + .map((check) => { + const targetScopeItem = getPatchBlockerScopeItem(check, selectedWorkspaceScopeItems); + const sourceState = + check.sourceReadable == null + ? "unknown" + : check.sourceReadable + ? "readable" + : "missing"; + + return ( +
+
+ {getPatchFingerprintStatusLabel(check.fingerprint?.status)} + {check.sourcePath} · {check.title} + + source: {sourceState} · before: {check.beforeFound ? "found" : "missing"} · after:{" "} + {check.afterNonEmpty ? "filled" : "empty"} + + match: {getPatchSourceMatchLabel(check.sourceMatch)} + {check.blockers.join(" ")} +
+
+ + +
+
+ ); + })} +
+
+ ) : null} + + {rewritePlan.blockers.length > 0 || (rewriteDiff?.blockers.length ?? 0) > 0 ? ( +
Блокеры {[...rewritePlan.blockers, ...(rewriteDiff?.blockers ?? [])].map((action) => (

{action}

@@ -14602,11 +19006,64 @@ export function App() {
- Рабочая зона выбранного scope - Исходник только для чтения, рабочий черновик сохраняется в Postgres + MinIO. + {isVisualComposerMode ? "Visual composer" : "Рабочая зона выбранного scope"} + + {isVisualComposerMode + ? "Живой review patch-конфигурации: тексты редактируются в working draft, source tree read-only." + : "Исходник только для чтения, рабочий черновик сохраняется в Postgres + MinIO."} +
- Применение пока не трогаем + {isVisualComposerMode ? "Visual ready" : "Применение пока не трогаем"}
+ {activeStageIndex === 6 ? ( +
+
+ {visualComposerMetrics.map((metric) => ( +
+ {metric.label} + {metric.value} + {metric.detail} +
+ ))} +
+
+ + {activeWorkspaceKey && activeWorkspaceExcludedTargetCount > 0 ? ( + + ) : null} +
+
+ ) : null}
{selectedWorkspaceScopeItems.map((scopeItem) => { const workspaceKey = getWorkspaceKey(project.id, scopeItem.scopeId); @@ -14637,11 +19094,22 @@ export function App() {
0 ? "drawer-open" : ""}`}>
-
- Замечаний на активной странице: {activeWorkspaceIssues.length} - В выбранных страницах: {selectedCoverageIssueCount} - Warning: {activeWorkspaceIssueCounts.warnings} · Info: {activeWorkspaceIssueCounts.info} -
+
+ Замечаний на активной странице: {activeWorkspaceIssues.length} + В выбранных страницах: {selectedCoverageIssueCount} + Warning: {activeWorkspaceIssueCounts.warnings} · Info: {activeWorkspaceIssueCounts.info} + {activeStageIndex === 6 ? ( + <> + + Правки: {activeWorkspaceUnsavedTargetCount}/{activeWorkspaceEditableTargetCount} + + + Черновик: {activeWorkspaceUnsavedSectionCount} не сохранено ·{" "} + {activeWorkspaceSavedOriginalChangeCount} отличается от оригинала + + + ) : null} +
+ {activeStageIndex === 6 ? ( + + ) : null}
{WORKSPACE_PREVIEW_MODES.map((mode) => (
+ {SEMANTIC_BLOCK_EDITOR_ENABLED && activeStageIndex === 6 && activeWorkspaceKey ? ( +
+ {isSemanticBlockEditMode ? ( + <> + + + + + + ) : null} + +
+ ) : null}