| 1 | import type { ThinkingStage } from '@shared/thinking' |
| 2 | import { routeThinkingIntent } from './intent-router' |
| 3 | |
| 4 | export const VALID_TRANSITIONS: Record<ThinkingStage, ThinkingStage[]> = { |
| 5 | collect: ['collect', 'outline'], |
| 6 | outline: ['collect', 'draft', 'ready'], |
| 7 | draft: ['collect', 'outline', 'refine', 'ready'], |
| 8 | refine: ['collect', 'outline', 'draft', 'ready'], |
| 9 | ready: ['collect', 'outline'] |
| 10 | } |
| 11 | |
| 12 | export function checkStageTransition( |
| 13 | currentStage: ThinkingStage, |
| 14 | thinkingMd: string |
| 15 | ): ThinkingStage { |
| 16 | const pageCount = countPageHeadings(thinkingMd) |
| 17 | const hasTopic = hasNonEmptySection(thinkingMd, 'Topic') |
| 18 | |
| 19 | if (currentStage === 'collect' && hasTopic && pageCount >= 2) { |
| 20 | return 'outline' |
| 21 | } |
| 22 | |
| 23 | return currentStage |
| 24 | } |
| 25 | |
| 26 | export function detectStageFallback(userMessage: string): ThinkingStage | null { |
| 27 | return routeThinkingIntent({ userMessage }).requestedStage |
| 28 | } |
| 29 | |
| 30 | export function resolveRequestedStage(args: { |
| 31 | currentStage: ThinkingStage |
| 32 | requestedStage: ThinkingStage | null |
| 33 | thinkingMd: string |
| 34 | }): ThinkingStage | null { |
| 35 | if (!args.requestedStage) return null |
| 36 | if (args.requestedStage === args.currentStage) return args.requestedStage |
| 37 | if (!VALID_TRANSITIONS[args.currentStage].includes(args.requestedStage)) return null |
| 38 | if (!hasThinkingContentForStage(args.requestedStage, args.thinkingMd)) return null |
| 39 | |
| 40 | return args.requestedStage |
| 41 | } |
| 42 | |
| 43 | export function isValidTransition(from: ThinkingStage, to: ThinkingStage): boolean { |
| 44 | return VALID_TRANSITIONS[from].includes(to) |
| 45 | } |
| 46 | |
| 47 | function hasThinkingContentForStage(stage: ThinkingStage, thinkingMd: string): boolean { |
| 48 | if (stage === 'collect') return true |
| 49 | |
| 50 | const pageCount = countPageHeadings(thinkingMd) |
| 51 | const hasTopic = hasNonEmptySection(thinkingMd, 'Topic') |
| 52 | if (stage === 'outline') return hasTopic && pageCount >= 2 |
| 53 | |
| 54 | return hasTopic && hasCompletePagePlan(thinkingMd) |
| 55 | } |
| 56 | |
| 57 | export function isRestartRequest(userMessage: string): boolean { |
| 58 | return /let's start over|start over|从头开始|重新开始/i.test(userMessage) |
| 59 | } |
| 60 | |
| 61 | function countPageHeadings(thinkingMd: string): number { |
| 62 | const matches = thinkingMd.match(/^##\s*Page\s+\d+\s*:/gm) |
| 63 | return matches ? matches.length : 0 |
| 64 | } |
| 65 | |
| 66 | function hasCompletePagePlan(thinkingMd: string): boolean { |
| 67 | const pageSections = getPageSections(thinkingMd) |
| 68 | return pageSections.length >= 2 && pageSections.every(hasCompletePageSection) |
| 69 | } |
| 70 | |
| 71 | function getPageSections(thinkingMd: string): string[] { |
| 72 | const headingRegex = /^##\s*Page\s+\d+\s*:/gm |
| 73 | const headings = Array.from(thinkingMd.matchAll(headingRegex)) |
| 74 | return headings.map((heading, index) => { |
| 75 | const start = heading.index || 0 |
| 76 | const next = headings[index + 1] |
| 77 | const end = typeof next?.index === 'number' ? next.index : thinkingMd.length |
| 78 | return thinkingMd.slice(start, end).trim() |
| 79 | }) |
| 80 | } |
| 81 | |
| 82 | function hasCompletePageSection(pageSection: string): boolean { |
| 83 | const hasTitle = /^##\s*Page\s+\d+\s*:\s*\S+/m.test(pageSection) |
| 84 | const hasRole = /^-\s*Role:\s*\S+/mi.test(pageSection) |
| 85 | const hasObjective = /^-\s*Objective:\s*\S+/mi.test(pageSection) |
| 86 | const contentLines = pageSection |
| 87 | .split('\n') |
| 88 | .map((line) => line.trim()) |
| 89 | .filter(Boolean) |
| 90 | .filter((line) => !/^##\s*Page\s+\d+\s*:/i.test(line)) |
| 91 | .filter((line) => !/^-\s*(Role|Objective):/i.test(line)) |
| 92 | const hasSummary = contentLines.some((line) => !line.startsWith('- ')) |
| 93 | const hasKeyPoints = contentLines.some((line) => /^-\s+\S+/.test(line)) |
| 94 | |
| 95 | return hasTitle && hasRole && hasObjective && hasSummary && hasKeyPoints |
| 96 | } |
| 97 | |
| 98 | function hasNonEmptySection(markdown: string, heading: string): boolean { |
| 99 | const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') |
| 100 | const inline = markdown.match(new RegExp(`^##\\s*${escaped}\\s*:\\s*(.+)`, 'm')) |
| 101 | if (inline?.[1]?.trim()) return true |
| 102 | const block = markdown.match(new RegExp(`^##\\s*${escaped}\\s*\\n([\\s\\S]*?)(?=^##\\s+|(?![\\s\\S]))`, 'm')) |
| 103 | return Boolean(block?.[1]?.trim()) |
| 104 | } |
| 105 |