Files
2026-07-13 13:33:09 +08:00

752 lines
22 KiB
Go

package plan
import (
"fmt"
"log"
"net/http"
"plandex-server/db"
"plandex-server/notify"
"plandex-server/types"
"regexp"
"runtime/debug"
"strings"
"time"
shared "plandex-shared"
"github.com/davecgh/go-spew/spew"
)
const verboseLogging = false
var openingTagRegex = regexp.MustCompile(`<PlandexBlock\s+lang="(.+?)"\s+path="(.+?)".*?>`)
type processChunkResult struct {
shouldReturn bool
shouldStop bool
}
func (state *activeTellStreamState) processChunk(choice types.ExtendedChatCompletionStreamChoice) processChunkResult {
req := state.req
// missingFileResponse := state.missingFileResponse
processor := state.chunkProcessor
replyParser := state.replyParser
plan := state.plan
planId := plan.Id
branch := state.branch
active := GetActivePlan(planId, branch)
if active == nil {
state.onActivePlanMissingError()
return processChunkResult{}
}
defer func() {
if r := recover(); r != nil {
log.Printf("processChunk: Panic: %v\n%s\n", r, string(debug.Stack()))
go notify.NotifyErr(notify.SeverityError, fmt.Errorf("processChunk: Panic: %v\n%s", r, string(debug.Stack())))
active.StreamDoneCh <- &shared.ApiError{
Type: shared.ApiErrorTypeOther,
Status: http.StatusInternalServerError,
Msg: fmt.Sprintf("Panic in processChunk: %v\n%s", r, string(debug.Stack())),
}
}
}()
delta := choice.Delta
content := delta.Content
baseModelConfig := state.modelConfig.GetBaseModelConfig(state.authVars, state.settings, state.orgUserConfig)
if baseModelConfig.IncludeReasoning && !baseModelConfig.HideReasoning && delta.Reasoning != "" {
content = delta.Reasoning
}
if content == "" {
return processChunkResult{}
}
processor.chunksReceived++
if verboseLogging {
log.Printf("Adding chunk to parser: %s\n", content)
log.Printf("fileOpen: %v\n", processor.fileOpen)
}
replyParser.AddChunk(content, true)
parserRes := replyParser.Read()
if !processor.fileOpen && parserRes.CurrentFilePath != "" {
if verboseLogging {
log.Printf("File open: %s\n", parserRes.CurrentFilePath)
}
processor.fileOpen = true
}
if processor.fileOpen && strings.HasSuffix(active.CurrentReplyContent+content, "</PlandexBlock>") {
if verboseLogging {
log.Println("FinishAndRead because of closing tag")
}
parserRes = replyParser.FinishAndRead()
processor.fileOpen = false
}
if processor.fileOpen && parserRes.CurrentFilePath == "" {
if verboseLogging {
log.Println("File open but current file path is empty, closing file")
}
processor.fileOpen = false
}
operations := parserRes.Operations
state.replyNumTokens = parserRes.TotalTokens
currentFile := parserRes.CurrentFilePath
// log.Printf("currentFile: %s\n", currentFile)
// log.Println("files:")
// spew.Dump(files)
// Handle file that is present in project paths but not in context
// Prompt user for what to do on the client side, stop the stream, and wait for user response before proceeding
bufferOrStreamRes := processor.bufferOrStream(content, &parserRes, state.currentStage, state.manualStop)
if currentFile != "" &&
!req.IsChatOnly &&
active.ContextsByPath[currentFile] == nil &&
req.ProjectPaths[currentFile] &&
!active.AllowOverwritePaths[currentFile] {
return state.handleMissingFile(bufferOrStreamRes.content, currentFile, bufferOrStreamRes.blockLang)
}
UpdateActivePlan(planId, branch, func(ap *types.ActivePlan) {
ap.CurrentReplyContent += content
ap.NumTokens++
})
if verboseLogging {
log.Println("processor before bufferOrStream")
spew.Dump(processor)
log.Println("maybeFilePath", parserRes.MaybeFilePath)
log.Println("currentFilePath", parserRes.CurrentFilePath)
log.Println("bufferOrStreamRes")
spew.Dump(bufferOrStreamRes)
}
if bufferOrStreamRes.shouldStream {
active.Stream(shared.StreamMessage{
Type: shared.StreamMessageReply,
ReplyChunk: bufferOrStreamRes.content,
})
}
if verboseLogging {
log.Println("processor after bufferOrStream")
spew.Dump(processor)
}
if !req.IsChatOnly && len(operations) > len(processor.replyOperations) {
state.handleNewOperations(&parserRes)
}
return processChunkResult{
shouldStop: bufferOrStreamRes.shouldStop,
}
}
type bufferOrStreamResult struct {
shouldStream bool
content string
blockLang string
shouldStop bool
}
func (processor *chunkProcessor) bufferOrStream(content string, parserRes *types.ReplyParserRes, currentStage shared.CurrentStage, manualStopSequences []string) bufferOrStreamResult {
if len(manualStopSequences) > 0 {
for _, stopSequence := range manualStopSequences {
// if the chunk contains the entire stop sequence, stream everything before it then caller can stop the stream
if strings.Contains(content, stopSequence) {
split := strings.Split(content, stopSequence)
if len(split) > 1 {
return bufferOrStreamResult{
shouldStream: true,
content: split[0],
shouldStop: true,
}
} else {
// there was nothing before the stop sequence, so nothing to stream
return bufferOrStreamResult{
shouldStream: false,
shouldStop: true,
}
}
}
// otherwise if the buffer plus chunk contains the stop sequence, don't stream anything and stop the stream
if strings.Contains(processor.contentBuffer+content, stopSequence) {
log.Printf("bufferOrStream - stop sequence found in buffer plus chunk\n")
split := strings.Split(content, stopSequence)
if len(split) > 1 {
// we'll stream the part before the stop sequence
return bufferOrStreamResult{
shouldStream: true,
content: split[0],
shouldStop: true,
}
} else {
// there was nothing before the stop sequence, so nothing to stream
return bufferOrStreamResult{
shouldStream: false,
shouldStop: true,
}
}
}
// otherwise if the buffer plus chunk ends with a prefix of the stop sequence, buffer it and continue
toCheck := processor.contentBuffer + content
tailLen := len(stopSequence) - 1
if tailLen > len(toCheck) {
tailLen = len(toCheck)
}
suffix := toCheck[len(toCheck)-tailLen:]
if strings.HasPrefix(stopSequence, suffix) {
log.Printf("bufferOrStream - stop sequence prefix found in buffer plus chunk. buffer and continue\n")
processor.contentBuffer += content
return bufferOrStreamResult{
shouldStream: false,
content: content,
}
}
}
}
// apart from manual stop sequences, no buffering in planning stages
if currentStage.TellStage == shared.TellStagePlanning {
return bufferOrStreamResult{
shouldStream: true,
content: content,
}
}
var shouldStream bool
var blockLang string
awaitingTag := processor.awaitingBlockOpeningTag || processor.awaitingBlockClosingTag || processor.awaitingOpClosingTag
awaitingAny := awaitingTag || processor.awaitingBackticks
if awaitingAny {
if verboseLogging {
log.Println("awaitingAny")
}
processor.contentBuffer += content
content = processor.contentBuffer
if verboseLogging {
log.Printf("awaitingBlockOpeningTag: %v\n", processor.awaitingBlockOpeningTag)
log.Printf("awaitingBlockClosingTag: %v\n", processor.awaitingBlockClosingTag)
log.Printf("awaitingBackticks: %v\n", processor.awaitingBackticks)
log.Printf("awaitingOpClosingTag: %v\n", processor.awaitingOpClosingTag)
log.Printf("content: %q\n", content)
}
}
if processor.awaitingBackticks {
if strings.Contains(content, "```") {
processor.awaitingBackticks = false
content = strings.ReplaceAll(content, "```", "\\`\\`\\`")
if !(processor.awaitingBlockOpeningTag || processor.awaitingBlockClosingTag) {
shouldStream = true
}
} else if !strings.HasSuffix(content, "`") {
// fewer than 3 backticks, no need to escape
processor.awaitingBackticks = false
if !(processor.awaitingBlockOpeningTag || processor.awaitingBlockClosingTag) {
shouldStream = true
}
}
}
if awaitingTag {
if verboseLogging {
log.Println("awaitingTag")
}
if processor.awaitingBlockOpeningTag {
if verboseLogging {
log.Println("processor.awaitingBlockOpeningTag")
}
var matchedPrefix bool
if parserRes.CurrentFilePath != "" {
matched, replaced := replaceCodeBlockOpeningTag(content, func(lang string) string {
blockLang = lang
return "```" + lang
})
if matched {
shouldStream = true
processor.awaitingBlockOpeningTag = false
processor.fileOpen = true
content = replaced
} else {
// tag is missing - something is wrong - we shouldn't be here but let's try to recover anyway
if verboseLogging {
log.Printf("Opening <PlandexBlock> tag is missing even though parserRes.CurrentFile is set - something is wrong: %s\n", content)
}
processor.awaitingBlockOpeningTag = false
processor.fileOpen = false
content += "\n```" // add ``` to the end of the line to close the markdown code block
shouldStream = true
}
} else {
split := strings.Split(content, "<")
if len(split) > 1 {
last := split[len(split)-1]
if verboseLogging {
log.Printf("last: %s\n", last)
}
if strings.HasPrefix(`PlandexBlock lang="`, last) {
if verboseLogging {
log.Println("strings.HasPrefix(`PlandexBlock lang=", last)
}
shouldStream = false
matchedPrefix = true
} else if strings.HasPrefix(last, `PlandexBlock lang="`) {
if verboseLogging {
log.Println("partialOpeningTagRegex.MatchString(last)")
}
shouldStream = false
matchedPrefix = true
} else {
if verboseLogging {
log.Println("partialOpeningTagRegex.MatchString(last) is false")
}
}
}
}
if !matchedPrefix && parserRes.MaybeFilePath == "" && parserRes.CurrentFilePath == "" {
// wasn't really a file path / code block
processor.awaitingBlockOpeningTag = false
shouldStream = true
}
} else if processor.awaitingBlockClosingTag {
if parserRes.CurrentFilePath == "" {
if strings.Contains(content, "</PlandexBlock>") {
shouldStream = true
processor.awaitingBlockClosingTag = false
processor.fileOpen = false
// replace </PlandexBlock> with ``` to close the markdown code block
content = strings.ReplaceAll(content, "</PlandexBlock>", "```")
} else {
log.Printf("Closing </PlandexBlock> tag is missing even though parserRes.CurrentOperation is nil - something is wrong: %s\n", content)
processor.awaitingBlockClosingTag = false
shouldStream = true
}
}
} else if processor.awaitingOpClosingTag {
if verboseLogging {
log.Printf("awaitingOpClosingTag: %v\n", processor.awaitingOpClosingTag)
}
if strings.Contains(content, "<EndPlandexFileOps/>") {
if verboseLogging {
log.Printf("Found <EndPlandexFileOps/>\n")
}
processor.awaitingOpClosingTag = false
content = strings.Replace(content, "\n<EndPlandexFileOps/>", "", 1)
content = strings.Replace(content, "<EndPlandexFileOps/>", "", 1)
shouldStream = true
}
}
} else {
if verboseLogging {
log.Println("not awaiting tag")
}
if parserRes.MaybeFilePath != "" && parserRes.CurrentFilePath == "" {
processor.awaitingBlockOpeningTag = true
} else {
// this will set processor.awaitingBlockOpeningTag to true if the content starts with any prefix of<PlandexBlock lang=" *or* any prefix of a full opening tag
// if the full tag is in the content, it will later get set to false again when the full tag is handled
split := strings.Split(content, "<")
if len(split) > 1 {
last := split[len(split)-1]
if strings.HasPrefix(`PlandexBlock lang="`, last) {
processor.awaitingBlockOpeningTag = true
} else if strings.HasPrefix(last, `PlandexBlock lang="`) {
processor.awaitingBlockOpeningTag = true
}
}
}
if parserRes.CurrentFilePath != "" {
if verboseLogging {
log.Println("parserRes.CurrentFilePath != \"\"")
}
if strings.Contains(content, "</PlandexBlock>") {
if verboseLogging {
log.Println("strings.Contains(content, \"</PlandexBlock>\")")
}
processor.awaitingBlockClosingTag = true
} else {
if verboseLogging {
log.Println("not strings.Contains(content, \"</PlandexBlock>\")")
}
split := strings.Split(content, "<")
// log.Printf("split: %v\n", split)
if len(split) > 1 {
if verboseLogging {
log.Println("len(split) > 1")
}
last := split[len(split)-1]
// log.Printf("last: %s\n", last)
if strings.HasPrefix("/PlandexBlock>", last) {
if verboseLogging {
log.Println("strings.HasPrefix(\"/PlandexBlock>\", last)")
}
processor.awaitingBlockClosingTag = true
}
}
}
} else if parserRes.FileOperationBlockOpen() {
if verboseLogging {
log.Println("parserRes.FileOperationBlockOpen()")
}
if strings.Contains(content, "<EndPlandexFileOps/>") {
if verboseLogging {
log.Println("strings.Contains(content, \"<EndPlandexFileOps/>\")")
}
processor.awaitingOpClosingTag = true
} else {
if verboseLogging {
log.Println("not strings.Contains(content, \"<EndPlandexFileOps/>\")")
}
split := strings.Split(content, "<")
if len(split) > 1 {
if verboseLogging {
log.Println("len(split) > 1")
}
last := split[len(split)-1]
if strings.HasPrefix("EndPlandexFileOps/>", last) {
if verboseLogging {
log.Println("strings.HasPrefix(\"EndPlandexFileOps/>\", last)")
}
processor.awaitingOpClosingTag = true
}
}
}
} else if strings.Contains(content, "</PlandexBlock>") {
if verboseLogging {
log.Println("strings.Contains(content, \"</PlandexBlock>\")")
}
content = strings.Replace(content, "</PlandexBlock>", "```", 1)
} else if strings.Contains(content, "<EndPlandexFileOps/>") {
if verboseLogging {
log.Println("strings.Contains(content, \"<EndPlandexFileOps/>\")")
}
content = strings.Replace(content, "\n<EndPlandexFileOps/>", "", 1)
content = strings.Replace(content, "<EndPlandexFileOps/>", "", 1)
}
if processor.fileOpen && (strings.Contains(content, "```") || strings.HasSuffix(content, "`")) {
if verboseLogging {
log.Println("processor.fileOpen && (strings.Contains(content, \"```\") || strings.HasSuffix(content, \"`\"))")
}
processor.awaitingBackticks = true
}
var matchedOpeningTag bool
if processor.fileOpen {
if verboseLogging {
log.Println("processor.fileOpen")
}
var replaced string
matchedOpeningTag, replaced = replaceCodeBlockOpeningTag(content, func(lang string) string {
blockLang = lang
return "```" + lang
})
if verboseLogging {
log.Println("matchedOpeningTag", matchedOpeningTag)
log.Println("replaced", replaced)
}
if matchedOpeningTag {
processor.awaitingBlockOpeningTag = false
content = replaced
}
}
shouldStream = !processor.awaitingBlockOpeningTag && !processor.awaitingBlockClosingTag && !processor.awaitingOpClosingTag && !processor.awaitingBackticks
if verboseLogging {
log.Println("processor.awaitingBlockOpeningTag", processor.awaitingBlockOpeningTag)
log.Println("processor.awaitingBlockClosingTag", processor.awaitingBlockClosingTag)
log.Println("processor.awaitingOpClosingTag", processor.awaitingOpClosingTag)
log.Println("processor.awaitingBackticks", processor.awaitingBackticks)
log.Println("shouldStream", shouldStream)
}
}
if verboseLogging {
log.Println("returning bufferOrStreamResult")
log.Println("shouldStream", shouldStream)
log.Println("content", content)
log.Println("blockLang", blockLang)
}
if shouldStream {
processor.contentBuffer = ""
} else {
processor.contentBuffer = content
}
return bufferOrStreamResult{
shouldStream: shouldStream,
content: content,
blockLang: blockLang,
}
}
func (state *activeTellStreamState) handleNewOperations(parserRes *types.ReplyParserRes) {
processor := state.chunkProcessor
plan := state.plan
planId := plan.Id
branch := state.branch
clients := state.clients
auth := state.auth
authVars := state.authVars
req := state.req
replyId := state.replyId
currentOrgId := state.currentOrgId
currentUserId := state.currentUserId
settings := state.settings
operations := parserRes.Operations
log.Printf("%d new operations\n", len(operations)-len(processor.replyOperations))
for i, op := range operations {
if i < len(processor.replyOperations) {
continue
}
log.Printf("Detected operation: %s\n", op.Name())
if req.BuildMode == shared.BuildModeAuto {
log.Printf("Queuing build for %s\n", op.Name())
// log.Println("Content:")
// log.Println(strconv.Quote(op.Content))
buildState := &activeBuildStreamState{
modelStreamId: state.modelStreamId,
clients: clients,
authVars: authVars,
auth: auth,
currentOrgId: currentOrgId,
currentUserId: currentUserId,
plan: plan,
branch: branch,
settings: settings,
modelContext: state.modelContext,
orgUserConfig: state.orgUserConfig,
}
var opContentTokens int
if op.Type == shared.OperationTypeFile {
opContentTokens = shared.GetNumTokensEstimate(op.Content)
} else {
opContentTokens = op.NumTokens
}
// log.Printf("buildState.queueBuilds - op.Description:\n%s\n", op.Description)
buildState.queueBuilds([]*types.ActiveBuild{{
ReplyId: replyId,
FileDescription: op.Description,
FileContent: op.Content,
FileContentTokens: opContentTokens,
Path: op.Path,
MoveDestination: op.Destination,
IsMoveOp: op.Type == shared.OperationTypeMove,
IsRemoveOp: op.Type == shared.OperationTypeRemove,
IsResetOp: op.Type == shared.OperationTypeReset,
}})
}
processor.replyOperations = append(processor.replyOperations, op)
UpdateActivePlan(planId, branch, func(ap *types.ActivePlan) {
ap.Operations = append(ap.Operations, op)
})
}
}
func (state *activeTellStreamState) handleMissingFile(content, currentFile, blockLang string) processChunkResult {
branch := state.branch
plan := state.plan
planId := plan.Id
replyParser := state.replyParser
iteration := state.iteration
clients := state.clients
auth := state.auth
req := state.req
authVars := state.authVars
active := GetActivePlan(planId, branch)
if active == nil {
state.onActivePlanMissingError()
return processChunkResult{}
}
log.Printf("Attempting to overwrite a file that isn't in context: %s\n", currentFile)
// attempting to overwrite a file that isn't in context
// we will stop the stream and ask the user what to do
err := db.SetPlanStatus(planId, branch, shared.PlanStatusMissingFile, "")
if err != nil {
log.Printf("Error setting plan %s status to prompting: %v\n", planId, err)
go notify.NotifyErr(notify.SeverityError, fmt.Errorf("error setting plan %s status to prompting: %v", planId, err))
active.StreamDoneCh <- &shared.ApiError{
Type: shared.ApiErrorTypeOther,
Status: http.StatusInternalServerError,
Msg: fmt.Sprintf("Error setting plan status to prompting: %v", err),
}
return processChunkResult{}
}
var trimmedReply string
UpdateActivePlan(planId, branch, func(ap *types.ActivePlan) {
ap.MissingFilePath = currentFile
trimmedReply = replyParser.GetReplyForMissingFile()
ap.CurrentReplyContent = trimmedReply
})
// log.Println("Content:")
// log.Println(content)
// log.Println("Block lang:")
// log.Println(blockLang)
// log.Println("Trimmed content:")
// log.Println(trimmedReply)
// try to replace the code block opening tag in the chunk with an empty string
// this will remove the code block opening tag if it exists
splitBy := "```" + blockLang
split := strings.Split(content, splitBy)
chunkToStream := split[0] + splitBy + "\n"
// log.Printf("chunkToStream: %s\n", chunkToStream)
if chunkToStream != "" {
log.Printf("Streaming remaining chunk before missing file prompt: %s\n", chunkToStream)
active.Stream(shared.StreamMessage{
Type: shared.StreamMessageReply,
ReplyChunk: chunkToStream,
})
active.FlushStreamBuffer()
time.Sleep(20 * time.Millisecond)
}
log.Printf("Prompting user for missing file: %s\n", currentFile)
active.Stream(shared.StreamMessage{
Type: shared.StreamMessagePromptMissingFile,
MissingFilePath: currentFile,
MissingFileAutoContext: active.AutoContext,
})
log.Printf("Stopping stream for missing file: %s\n", currentFile)
// log.Printf("Chunk content: %s\n", content)
// log.Printf("Current reply content: %s\n", active.CurrentReplyContent)
// stop stream for now
active.CancelModelStreamFn()
log.Printf("Stopped stream for missing file: %s\n", currentFile)
// wait for user response to come in
var userChoice shared.RespondMissingFileChoice
select {
case <-active.Ctx.Done():
log.Println("Context cancelled while waiting for missing file response")
state.execHookOnStop(false)
return processChunkResult{shouldReturn: true}
case <-time.After(30 * time.Minute): // long timeout here since we're waiting for user input
log.Println("Timeout waiting for missing file choice")
state.onError(onErrorParams{
streamErr: fmt.Errorf("timeout waiting for missing file choice"),
storeDesc: true,
})
return processChunkResult{}
case userChoice = <-active.MissingFileResponseCh:
}
log.Printf("User choice for missing file: %s\n", userChoice)
active.ResetModelCtx()
UpdateActivePlan(planId, branch, func(ap *types.ActivePlan) {
ap.MissingFilePath = ""
ap.CurrentReplyContent = replyParser.GetReplyForMissingFile()
})
log.Println("Continuing stream")
// continue plan
execTellPlan(execTellPlanParams{
clients: clients,
plan: plan,
branch: branch,
auth: auth,
req: req,
iteration: iteration, // keep the same iteration
missingFileResponse: userChoice,
authVars: authVars,
})
return processChunkResult{shouldReturn: true}
}
func getCroppedChunk(uncropped, cropped, chunk string) string {
uncroppedIdx := strings.Index(uncropped, chunk)
if uncroppedIdx == -1 {
return ""
}
croppedChunk := cropped[uncroppedIdx:]
return croppedChunk
}
func replaceCodeBlockOpeningTag(content string, replaceWithFn func(lang string) string) (bool, string) {
// check for opening tag matching <PlandexBlock lang="..." path="...">
match := openingTagRegex.FindStringSubmatch(content)
if match != nil {
// Found complete opening tag with lang and path attributes
lang := match[1] // Extract the language from the first capture group
return true, strings.Replace(content, match[0], replaceWithFn(lang), 1)
} else if strings.Contains(content, "<PlandexBlock>") {
// This is a fallback case that should probably be removed since we now require both attributes
return true, strings.Replace(content, "<PlandexBlock>", replaceWithFn(""), 1)
}
return false, ""
}