Files
dingtalk-workspace-cli/internal/helpers/doc_write_pipeline.go

270 lines
9.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package helpers
import (
"context"
"encoding/json"
"fmt"
"os/signal"
"strings"
"syscall"
"unicode/utf8"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
const (
// initialChunkSize is the first attempted chunk size (rune count).
// Server-side OSS delta resolution is now fixed, so large chunks are safe.
initialChunkSize = 10000
// longContentWarningThreshold triggers a hint to use --content-file.
longContentWarningThreshold = 2048
)
// contentInputSource 标记内容的输入来源。
type contentInputSource int
const (
sourceContentFlag contentInputSource = iota // --content "literal"
sourceContentFile // --content-file path
sourceStdin // --content -
)
// detectContentSource 根据 cobra.Command 的 flags 判断内容输入来源。
func detectContentSource(cmd *cobra.Command) contentInputSource {
if filePath := flagOrFallback(cmd, "content-file", "content-path"); filePath != "" {
return sourceContentFile
}
if raw, _ := cmd.Flags().GetString("content"); raw == "-" {
return sourceStdin
}
if raw, _ := cmd.Flags().GetString("content"); raw == "" {
if md, _ := cmd.Flags().GetString("markdown"); md == "-" {
return sourceStdin
}
}
return sourceContentFlag
}
// DocWriteResult is the structured output of the write pipeline.
type DocWriteResult struct {
Success bool `json:"success"`
NodeID string `json:"nodeId"`
ChunksWritten int `json:"chunksWritten"`
ServerResponse json.RawMessage `json:"serverResponse,omitempty"`
}
// docWritePipeline is the unified entry point for doc create/update with
// automatic chunking.
//
// Phases:
//
// 0. Pre-check: warn if --content literal is long
// 1. Strategy: single write (≤initialChunkSize) or chunked
// 2. Write: single call or adaptive chunked writes
// 3. Output: JSON result
func docWritePipeline(cmd *cobra.Command, toolName string, toolArgs map[string]any,
markdown string, operation string) error {
// Phase 0: pre-check — guide long --content literals toward --content-file
if markdown != "" && detectContentSource(cmd) == sourceContentFlag &&
len(markdown) > longContentWarningThreshold {
deps.Out.PrintInfo("[WARN] 内容较长 (>2KB),建议使用 --content-file 传入以避免 shell escape 问题")
}
// Strip control characters and dangerous Unicode that the server rejects.
markdown = stripInputUnsafeChars(markdown)
if _, hasKey := toolArgs["markdown"]; hasKey {
toolArgs["markdown"] = markdown
}
runeCount := utf8.RuneCountInString(markdown)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Phase 1+2: strategy selection and write
var nodeID string
var chunksWritten int
var lastResponse string
var writeErr error
if markdown == "" || runeCount <= initialChunkSize {
// Single write path
nodeID, lastResponse, writeErr = singleWrite(ctx, toolName, toolArgs)
chunksWritten = 1
if writeErr != nil && isTimeoutError(writeErr.Error()) {
// The server may have committed the write before the client observed the
// timeout. Replaying create/append here can duplicate a document or
// content, so fail closed and require inspection before any retry.
writeErr = docWriteUnknownStateError(operation, nodeID, "single_write", 0, 1, writeErr)
}
} else {
// Chunked write path
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 内容较长 (%d 字符),自动分片写入...", runeCount))
nodeID, chunksWritten, lastResponse, writeErr = chunkedWrite(ctx, toolName, toolArgs, markdown, operation, initialChunkSize)
}
if writeErr != nil {
return writeErr
}
// Phase 3: output
result := DocWriteResult{
Success: true,
NodeID: nodeID,
ChunksWritten: chunksWritten,
}
if json.Valid([]byte(lastResponse)) {
result.ServerResponse = json.RawMessage(lastResponse)
}
return deps.Out.PrintJSON(result)
}
// singleWrite performs a single MCP tool call and returns nodeId + raw server response.
func singleWrite(ctx context.Context, toolName string, toolArgs map[string]any) (string, string, error) {
resultText, err := callMCPToolReturnText(ctx, toolName, toolArgs)
if err != nil {
return "", resultText, err
}
nodeID := extractNodeIDFromResult(resultText)
return nodeID, resultText, nil
}
// chunkedWrite performs adaptive chunked writing.
// For doc create: first chunk creates the document directly (with content), rest append.
// For doc update with overwrite: first chunk uses overwrite, rest use append.
// Returns nodeID, chunks written, last server response text, and error.
func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
markdown string, operation string, startChunkSize int) (string, int, string, error) {
var nodeID string
var lastResponse string
chunkSize := startChunkSize
chunks := splitMarkdownSafe(markdown, chunkSize)
writtenCount := 0
// --- Write first chunk ---
if toolName == "create_document" {
createArgs := make(map[string]any)
for k, v := range toolArgs {
createArgs[k] = v
}
createArgs["markdown"] = chunks[0]
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 写入分片 (1/%d),%d 字符 (create)...",
len(chunks), utf8.RuneCountInString(chunks[0])))
resultText, err := callMCPToolReturnText(ctx, "create_document", createArgs)
if err != nil {
if isTimeoutError(err.Error()) {
return "", 0, resultText, docWriteUnknownStateError(operation, "", "chunk_1", 0, len(chunks), err)
}
return "", 0, resultText, fmt.Errorf("创建文档失败: %w", err)
}
nodeID = extractNodeIDFromResult(resultText)
if nodeID == "" {
return "", 0, resultText, fmt.Errorf("创建文档成功但无法提取 nodeId")
}
lastResponse = resultText
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 文档已创建 (nodeId=%s)", nodeID))
} else {
if id, ok := toolArgs["nodeId"].(string); ok {
nodeID = id
}
firstMode := "append"
if m, ok := toolArgs["mode"].(string); ok {
firstMode = m
}
updateArgs := map[string]any{
"nodeId": nodeID,
"markdown": chunks[0],
"mode": firstMode,
}
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 写入分片 (1/%d),%d 字符 (%s)...",
len(chunks), utf8.RuneCountInString(chunks[0]), firstMode))
resultText, err := callMCPToolReturnText(ctx, "update_document", updateArgs)
if err != nil {
if isTimeoutError(err.Error()) {
return nodeID, 0, resultText, docWriteUnknownStateError(operation, nodeID, "chunk_1", 0, len(chunks), err)
}
return nodeID, 0, resultText, fmt.Errorf("第 1 片写入失败: %w", err)
}
lastResponse = resultText
}
writtenCount = 1
// --- Write remaining chunks with append ---
for i := 1; i < len(chunks); i++ {
if ctx.Err() != nil {
return nodeID, writtenCount, lastResponse, fmt.Errorf("写入被中断,已完成 %d/%d 片", writtenCount, len(chunks))
}
chunk := chunks[i]
preview := chunk
if len(preview) > 80 {
preview = preview[:80]
}
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 写入分片 (%d/%d),%d 字符, preview=[%s]...",
i+1, len(chunks), utf8.RuneCountInString(chunk), preview))
updateArgs := map[string]any{
"nodeId": nodeID,
"markdown": chunk,
"mode": "append",
}
resultText, err := callMCPToolReturnText(ctx, "update_document", updateArgs)
if err != nil {
if isTimeoutError(err.Error()) {
return nodeID, writtenCount, resultText, docWriteUnknownStateError(
operation, nodeID, fmt.Sprintf("chunk_%d", i+1), writtenCount, len(chunks), err,
)
}
return nodeID, writtenCount, resultText, fmt.Errorf("分片 %d 写入失败: %w", writtenCount+1, err)
}
lastResponse = resultText
writtenCount++
}
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 全部 %d 个分片写入完成", writtenCount))
return nodeID, writtenCount, lastResponse, nil
}
func docWriteUnknownStateError(operation, nodeID, stage string, written, total int, cause error) error {
details := map[string]any{
"status": "unknown",
"nodeId": nodeID,
"chunksWritten": written,
"chunksTotal": total,
"failedStage": stage,
}
return apperrors.NewAPI(
"文档写入响应超时,服务端提交状态未知;为避免重复创建或重复追加,已停止自动重试",
apperrors.WithOperation(operation),
apperrors.WithReason("doc_write_commit_unknown"),
apperrors.WithFailureStage(stage),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("先读取目标文档确认实际写入状态", "仅在确认服务端未提交后重新执行"),
apperrors.WithDetails(details),
apperrors.WithCause(cause),
)
}
// isTimeoutError checks if an error message indicates a server-side timeout.
func isTimeoutError(msg string) bool {
lower := strings.ToLower(msg)
return strings.Contains(lower, "timeout") ||
strings.Contains(lower, "hsftimeoutexception")
}
// extractNodeIDFromResult 从 MCP 工具返回的 JSON 文本中提取 nodeId 字段。
func extractNodeIDFromResult(resultText string) string {
var result map[string]any
if err := json.Unmarshal([]byte(resultText), &result); err != nil {
return ""
}
if nodeID, ok := result["nodeId"].(string); ok && nodeID != "" {
return nodeID
}
return ""
}