From 9d1c2cbb6e50ec48c22ca4c00d2831602b527fc1 Mon Sep 17 00:00:00 2001 From: Mukyu Date: Mon, 5 Jan 2026 21:37:42 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E2=9C=A8=20feat(rewrite):=20add=20rewrite?= =?UTF-8?q?=20prompt=20template=20and=20parsing=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/novelmaker-obs/cmd_config_check.go | 23 +++++++-- .../init_template/Config/rewrite_prompt.md | 31 ++++++++++++ internal/obsidian/vault.go | 32 ++++++++++++ novelmaker/render.go | 49 +++++++++++++++++++ novelmaker/struct.go | 5 ++ 5 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 internal/obsidian/init_template/Config/rewrite_prompt.md diff --git a/cmd/novelmaker-obs/cmd_config_check.go b/cmd/novelmaker-obs/cmd_config_check.go index cd90811..a4297f0 100644 --- a/cmd/novelmaker-obs/cmd_config_check.go +++ b/cmd/novelmaker-obs/cmd_config_check.go @@ -17,12 +17,12 @@ func NewConfigCheckCmd() *ConfigCheckCmd { c.cmd = &cobra.Command{ Use: "config-check", - Short: "Check if chapter and character prompt templates can be parsed successfully", - Long: `Check if chapter and character prompt templates can be parsed successfully. + Short: "Check if chapter, character, and rewrite prompt templates can be parsed successfully", + Long: `Check if chapter, character, and rewrite prompt templates can be parsed successfully. -This command reads Config/chapter_prompt.md and Config/character_prompt.md from -the vault and attempts to parse them. It reports any errors in the frontmatter -or template syntax. +This command reads Config/chapter_prompt.md, Config/character_prompt.md, and +Config/rewrite_prompt.md from the vault and attempts to parse them. It reports +any errors in the frontmatter or template syntax. Example: novelmaker-obs config-check @@ -73,6 +73,19 @@ func (c *ConfigCheckCmd) run(cmd *cobra.Command, args []string) error { } fmt.Println() + // Test rewrite prompt + fmt.Println("📄 Checking Config/rewrite_prompt.md...") + rewritePrompt, err := vault.LoadRewritePrompt() + if err != nil { + fmt.Fprintf(os.Stderr, "❌ Failed to parse rewrite prompt: %v\n", err) + return err + } + fmt.Println("✅ Rewrite prompt parsed successfully") + if rewritePrompt.System != "" { + fmt.Printf(" System prompt: %.80s...\n", rewritePrompt.System) + } + fmt.Println() + fmt.Println("🎉 All prompt templates are valid!") return nil } diff --git a/internal/obsidian/init_template/Config/rewrite_prompt.md b/internal/obsidian/init_template/Config/rewrite_prompt.md new file mode 100644 index 0000000..4b9c5f0 --- /dev/null +++ b/internal/obsidian/init_template/Config/rewrite_prompt.md @@ -0,0 +1,31 @@ +--- +system: | + 你是一名小說編輯。 + + 你將看到一段「上下文」與一個「目標句子」。 + + 【重要規則】 + - 你只能修訂「目標句子」本身。 + - 上下文僅用於理解語境,不得修改、重寫或引用其中的句子。 + - 不得新增事件、資訊、情緒、意象或結論。 + - 不得讓修訂後的句子影響上下文的既有含義。 + + 【修改目標】 + 去除 AI 味的慣用詞與過度評述語氣。 + + 【優先處理】 + - 因此、於是、顯然、不禁、總之、由此可見 + - 抽象情緒詞(感到、覺得、內心) + + 【長度限制】 + 修訂後句子長度變化不得超過 ±20%。 +--- + +【上下文(只讀)】 +{.ContextBefore} + +▶【目標句子(唯一可修改)】 +{.TargetSentence} + +【上下文(只讀)】 +{.ContextAfter} \ No newline at end of file diff --git a/internal/obsidian/vault.go b/internal/obsidian/vault.go index b24899e..f7125ea 100644 --- a/internal/obsidian/vault.go +++ b/internal/obsidian/vault.go @@ -56,6 +56,11 @@ type CharacterPromptFrontmatter struct { System string `yaml:"system"` } +// RewritePromptFrontmatter represents the YAML frontmatter for rewrite prompt +type RewritePromptFrontmatter struct { + System string `yaml:"system"` +} + const ( configDirName = "Config" worldDirName = "World" @@ -484,6 +489,33 @@ func (v *Vault) LoadCharacterPrompt() (*novelmaker.CharacterPrompt, error) { }, nil } +// LoadRewritePrompt loads the rewrite prompt template from Config/rewrite_prompt.md +func (v *Vault) LoadRewritePrompt() (*novelmaker.RewritePrompt, error) { + promptPath := filepath.Join(configDirName, "rewrite_prompt.md") + content, err := v.root.ReadFile(promptPath) + if err != nil { + return nil, fmt.Errorf("failed to read rewrite prompt file %s: %w", promptPath, err) + } + + fm, body, err := nmutil.ParseFrontmatter[RewritePromptFrontmatter](content) + if err != nil { + return nil, fmt.Errorf("failed to parse rewrite prompt frontmatter: %w", err) + } + + // Parse the assistant template + tmpl, err := template.New("rewrite_assistant").Funcs(template.FuncMap{ + "join": strings.Join, + }).Parse(body) + if err != nil { + return nil, fmt.Errorf("failed to parse rewrite assistant template: %w", err) + } + + return &novelmaker.RewritePrompt{ + System: fm.System, + AssistantTemplate: tmpl, + }, nil +} + // InitGit initializes a git repository in the vault directory func (v *Vault) InitGit() error { // Get the root path from the os.Root diff --git a/novelmaker/render.go b/novelmaker/render.go index 993fcce..f5ae2c3 100644 --- a/novelmaker/render.go +++ b/novelmaker/render.go @@ -133,3 +133,52 @@ func (r *Renderer) RenderCharacter( return chatCompletion, usage, nil } + +// RewritePromptData holds the data for rendering rewrite prompts +type RewritePromptData struct { + ContextBefore string + TargetSentence string + ContextAfter string +} + +// RenderRewrite generates a rewritten text using the specified OpenAI model +func (r *Renderer) RenderRewrite( + project *Project, rewritePrompt *RewritePrompt, contextBefore string, targetSentence string, contextAfter string) (string, llmbackend.UsageInfo, error) { + + data := RewritePromptData{ + ContextBefore: contextBefore, + TargetSentence: targetSentence, + ContextAfter: contextAfter, + } + + var buf bytes.Buffer + if err := rewritePrompt.AssistantTemplate.Execute(&buf, data); err != nil { + return "", llmbackend.UsageInfo{}, fmt.Errorf("failed to execute rewrite template: %w", err) + } + promptContent := buf.String() + + systemPrompt := rewritePrompt.System + if systemPrompt == "" { + systemPrompt = "You are a helpful assistant that rewrites and improves text for novels." + } + + msgs := []llmbackend.Message{ + {Role: llmbackend.RoleSystem, Content: systemPrompt}, + {Role: llmbackend.RoleUser, Content: promptContent}, + } + + ctx := context.Background() + if r.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, r.timeout) + defer cancel() + } + + chatCompletion, usage, err := r.llmBackend.ChatCompletion(msgs, ctx) + + if err != nil { + return "", llmbackend.UsageInfo{}, err + } + + return chatCompletion, usage, nil +} diff --git a/novelmaker/struct.go b/novelmaker/struct.go index a1d5ea9..94eb015 100644 --- a/novelmaker/struct.go +++ b/novelmaker/struct.go @@ -15,6 +15,11 @@ type CharacterPrompt struct { AssistantTemplate *template.Template } +type RewritePrompt struct { + System string + AssistantTemplate *template.Template +} + type Project struct { Name string `json:"name"` World string `json:"world"` From 88a44bc0bc2b582fbd52c18428314738438b8c4e Mon Sep 17 00:00:00 2001 From: Mukyu Date: Mon, 5 Jan 2026 21:46:52 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E2=9C=A8=20feat(rewrite):=20add=20rewrite?= =?UTF-8?q?=20command=20for=20AI-based=20line=20rewriting=20in=20chapter?= =?UTF-8?q?=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/novelmaker-obs/cmd_rewrite.go | 266 ++++++++++++++++++++++++++++++ cmd/novelmaker-obs/main.go | 2 + 2 files changed, 268 insertions(+) create mode 100644 cmd/novelmaker-obs/cmd_rewrite.go diff --git a/cmd/novelmaker-obs/cmd_rewrite.go b/cmd/novelmaker-obs/cmd_rewrite.go new file mode 100644 index 0000000..81fe4f7 --- /dev/null +++ b/cmd/novelmaker-obs/cmd_rewrite.go @@ -0,0 +1,266 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "log/slog" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/voilelab/gonovelmaker/internal/config" + "github.com/voilelab/gonovelmaker/internal/llmbackend" + "github.com/voilelab/gonovelmaker/internal/nmutil" + "github.com/voilelab/gonovelmaker/internal/obsidian" + "github.com/voilelab/gonovelmaker/novelmaker" +) + +type RewriteCmd struct { + json bool + lineStart int + lineEnd int + contextPrev int + contextNext int + backend string + model string + timeout int + + llmBackendMaker llmbackend.LLMBackendMaker + + cmd *cobra.Command +} + +func NewRewriteCmd(llmBackendMaker llmbackend.LLMBackendMaker) *RewriteCmd { + r := &RewriteCmd{ + llmBackendMaker: llmBackendMaker, + } + + r.cmd = &cobra.Command{ + Use: "rewrite [filepath]", + Short: "Rewrite a specific range of lines in a chapter file", + Long: `Rewrite a specific range of lines in a chapter file using AI. + +This command reads the specified file, extracts the target lines and context, +sends them to the AI model for rewriting, and updates the file with the new content. + +Example: + novelmaker-obs rewrite Story/001_ch1.md --line-start 10 --line-end 15 --context-prev 5 --context-next 5 + novelmaker-obs rewrite Story/001_ch1.md -s 10 -e 15 -p 5 -n 5 --model gpt-4`, + Args: cobra.ExactArgs(1), + RunE: r.run, + } + + r.cmd.Flags().BoolVarP(&r.json, "json", "j", false, "Output in JSON format") + + r.cmd.Flags().IntVarP(&r.lineStart, "line-start", "s", 0, "Starting line number to rewrite (required, 1-indexed)") + r.cmd.Flags().IntVarP(&r.lineEnd, "line-end", "e", 0, "Ending line number to rewrite (required, 1-indexed, inclusive)") + r.cmd.Flags().IntVarP(&r.contextPrev, "context-prev", "p", 3, "Number of lines before the target as context (default 3)") + r.cmd.Flags().IntVarP(&r.contextNext, "context-next", "n", 3, "Number of lines after the target as context (default 3)") + + r.cmd.MarkFlagRequired("line-start") + r.cmd.MarkFlagRequired("line-end") + + // Allow overriding config values per-command + r.cmd.Flags().StringVar(&r.backend, "backend", "", "LLM backend to use (optional, uses default if not specified)") + r.cmd.Flags().StringVar(&r.model, "model", "", "Model to use, overrides config (optional)") + r.cmd.Flags().IntVar(&r.timeout, "timeout", 0, "Timeout in seconds for the API request (optional)") + + return r +} + +func (r *RewriteCmd) run(cmd *cobra.Command, args []string) error { + filepath := args[0] + + // Validate line numbers + if r.lineStart < 1 { + return fmt.Errorf("line-start must be >= 1, got %d", r.lineStart) + } + if r.lineEnd < r.lineStart { + return fmt.Errorf("line-end (%d) must be >= line-start (%d)", r.lineEnd, r.lineStart) + } + if r.contextPrev < 0 { + return fmt.Errorf("context-prev must be >= 0, got %d", r.contextPrev) + } + if r.contextNext < 0 { + return fmt.Errorf("context-next must be >= 0, got %d", r.contextNext) + } + + // Load config + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current directory: %w", err) + } + + // Create vault + vault, err := obsidian.NewVault(cwd) + if err != nil { + return fmt.Errorf("failed to open vault: %w", err) + } + defer vault.Close() + + // Load project + project, err := vault.LoadProject() + if err != nil { + return fmt.Errorf("failed to load project: %w", err) + } + + // Get backend configuration + backend := cfg.GetBackend(r.backend) + if backend == nil { + if r.backend != "" { + return fmt.Errorf("backend '%s' not found in config", r.backend) + } + return fmt.Errorf("no LLM backend configured. Please configure at least one backend in ~/.novelmaker/config.toml") + } + + // Determine effective API settings (flags override config) + effectiveModel := nmutil.FirstNonEmptyString(r.model, backend.Model) + effectiveTimeout := time.Duration(nmutil.FirstNonZero(r.timeout, backend.Timeout)) * time.Second + + if !r.json { + fmt.Println("Rewriting lines with AI...") + fmt.Printf(" Project: %s\n", project.Name) + fmt.Printf(" Model: %s\n", effectiveModel) + fmt.Printf(" File: %s\n", filepath) + fmt.Printf(" Lines: %d-%d\n", r.lineStart, r.lineEnd) + fmt.Printf(" Context: %d lines before, %d lines after\n", r.contextPrev, r.contextNext) + } + + // Read the file and extract lines + fileLines, err := r.readFileLines(filepath) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + totalLines := len(fileLines) + if r.lineStart > totalLines { + return fmt.Errorf("line-start (%d) exceeds total lines in file (%d)", r.lineStart, totalLines) + } + if r.lineEnd > totalLines { + return fmt.Errorf("line-end (%d) exceeds total lines in file (%d)", r.lineEnd, totalLines) + } + + // Extract context and target (convert to 0-indexed) + contextStartIdx := max(0, r.lineStart-1-r.contextPrev) + targetStartIdx := r.lineStart - 1 + targetEndIdx := r.lineEnd // inclusive, so no -1 + contextEndIdx := min(totalLines, r.lineEnd+r.contextNext) + + contextBefore := strings.Join(fileLines[contextStartIdx:targetStartIdx], "\n") + targetSentence := strings.Join(fileLines[targetStartIdx:targetEndIdx], "\n") + contextAfter := strings.Join(fileLines[targetEndIdx:contextEndIdx], "\n") + + // Load rewrite prompt template from vault + rewritePrompt, err := vault.LoadRewritePrompt() + if err != nil { + return fmt.Errorf("failed to load rewrite prompt from vault: %w", err) + } + + llmBackend := r.llmBackendMaker( + backend.APIKey, + backend.BaseURL, + effectiveModel, + ) + + renderer := novelmaker.NewRenderer( + llmBackend, + effectiveTimeout, + ) + + slog.Info("Rewriting text", + "project", project.Name, + "model", effectiveModel, + "file", filepath, + "line_range", fmt.Sprintf("%d-%d", r.lineStart, r.lineEnd), + ) + + // Call AI API + rewrittenContent, usage, err := renderer.RenderRewrite( + project, rewritePrompt, contextBefore, targetSentence, contextAfter) + if err != nil { + return fmt.Errorf("failed to rewrite text: %w", err) + } + + // Replace the target lines with rewritten content + rewrittenLines := strings.Split(strings.TrimSpace(rewrittenContent), "\n") + newFileLines := make([]string, 0, len(fileLines)) + newFileLines = append(newFileLines, fileLines[:targetStartIdx]...) + newFileLines = append(newFileLines, rewrittenLines...) + newFileLines = append(newFileLines, fileLines[targetEndIdx:]...) + + // Write back to file + newContent := strings.Join(newFileLines, "\n") + if err := os.WriteFile(filepath, []byte(newContent), 0644); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + if !r.json { + fmt.Printf("\n✓ Successfully rewrote lines %d-%d!\n", r.lineStart, r.lineEnd) + fmt.Printf(" Original lines: %d\n", r.lineEnd-r.lineStart+1) + fmt.Printf(" New lines: %d\n", len(rewrittenLines)) + fmt.Printf("\nToken Usage:\n") + fmt.Printf(" Input tokens: %d\n", usage.InputTokens) + fmt.Printf(" Output tokens: %d\n", usage.OutputTokens) + fmt.Printf(" Total tokens: %d\n", usage.TotalTokens) + fmt.Printf("\nRewritten content:\n") + fmt.Printf(" %s\n", strings.ReplaceAll(rewrittenContent, "\n", "\n ")) + } else { + output := map[string]any{ + "filepath": filepath, + "original_line_count": r.lineEnd - r.lineStart + 1, + "new_line_count": len(rewrittenLines), + "input_tokens": usage.InputTokens, + "output_tokens": usage.OutputTokens, + "total_tokens": usage.TotalTokens, + "rewritten_content": rewrittenContent, + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(output); err != nil { + return fmt.Errorf("failed to encode JSON output: %w", err) + } + } + + return nil +} + +func (r *RewriteCmd) readFileLines(filepath string) ([]string, error) { + file, err := os.Open(filepath) + if err != nil { + return nil, err + } + defer file.Close() + + var lines []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return lines, nil +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/cmd/novelmaker-obs/main.go b/cmd/novelmaker-obs/main.go index db4c867..5d585d2 100644 --- a/cmd/novelmaker-obs/main.go +++ b/cmd/novelmaker-obs/main.go @@ -28,6 +28,7 @@ based on your existing content.`, updatePluginCmd := NewUpdatePluginCmd() configTestCmd := NewConfigCheckCmd() versionCmd := NewVersionCmd() + rewriteCmd := NewRewriteCmd(llmbackend.MakeOpenAI) backendCmd := cmdbackend.NewBackendCmd(llmbackend.MakeOpenAI) characterCmd := cmdcharacter.NewCharacterCmd(llmbackend.MakeOpenAI) @@ -39,6 +40,7 @@ based on your existing content.`, rootCmd.AddCommand(updatePluginCmd.cmd) rootCmd.AddCommand(configTestCmd.cmd) rootCmd.AddCommand(versionCmd.cmd) + rootCmd.AddCommand(rewriteCmd.cmd) rootCmd.AddCommand(backendCmd.Command()) rootCmd.AddCommand(characterCmd.Command()) From 642e2a1e590317ed23df304f69bcf497a1649913 Mon Sep 17 00:00:00 2001 From: Mukyu Date: Mon, 5 Jan 2026 22:44:13 +0800 Subject: [PATCH 3/9] =?UTF-8?q?=E2=9C=A8=20feat(rewrite):=20implement=20re?= =?UTF-8?q?write=20command=20with=20confirmation=20modal=20and=20context?= =?UTF-8?q?=20handling=20=E2=9C=A8=20feat(rewrite):=20enhance=20rewrite=20?= =?UTF-8?q?prompt=20template=20with=20improved=20formatting=20=E2=9C=A8=20?= =?UTF-8?q?feat(cli):=20add=20line=20range=20and=20context=20options=20to?= =?UTF-8?q?=20CLI=20command=20builder=20=E2=9C=A8=20style:=20add=20styles?= =?UTF-8?q?=20for=20rewrite=20confirmation=20modal=20and=20text=20display?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/novelmaker-obs/cmd_rewrite.go | 47 ++++---- .../init_template/Config/rewrite_prompt.md | 10 +- novelmaker/render.go | 5 + obsidian-novelmaker/main.js | 2 + obsidian-novelmaker/src/commands/rewrite.js | 102 ++++++++++++++++++ .../src/modals/rewrite-confirm.js | 100 +++++++++++++++++ obsidian-novelmaker/src/utils/cli.js | 16 +++ obsidian-novelmaker/styles.css | 30 ++++++ 8 files changed, 285 insertions(+), 27 deletions(-) create mode 100644 obsidian-novelmaker/src/commands/rewrite.js create mode 100644 obsidian-novelmaker/src/modals/rewrite-confirm.js diff --git a/cmd/novelmaker-obs/cmd_rewrite.go b/cmd/novelmaker-obs/cmd_rewrite.go index 81fe4f7..7af629f 100644 --- a/cmd/novelmaker-obs/cmd_rewrite.go +++ b/cmd/novelmaker-obs/cmd_rewrite.go @@ -19,6 +19,7 @@ import ( type RewriteCmd struct { json bool + filepath string lineStart int lineEnd int contextPrev int @@ -38,27 +39,29 @@ func NewRewriteCmd(llmBackendMaker llmbackend.LLMBackendMaker) *RewriteCmd { } r.cmd = &cobra.Command{ - Use: "rewrite [filepath]", - Short: "Rewrite a specific range of lines in a chapter file", - Long: `Rewrite a specific range of lines in a chapter file using AI. + Use: "rewrite", + Short: "Rewrite a specific range of lines in a file", + Long: `Rewrite a specific range of lines in a file using AI. This command reads the specified file, extracts the target lines and context, sends them to the AI model for rewriting, and updates the file with the new content. +The filepath should be relative to the vault root (e.g., "Story/001_ch1.md"). Example: - novelmaker-obs rewrite Story/001_ch1.md --line-start 10 --line-end 15 --context-prev 5 --context-next 5 - novelmaker-obs rewrite Story/001_ch1.md -s 10 -e 15 -p 5 -n 5 --model gpt-4`, - Args: cobra.ExactArgs(1), + novelmaker-obs rewrite --filepath Story/001_ch1.md --line-start 10 --line-end 15 --context-prev 5 --context-next 5 + novelmaker-obs rewrite -f Story/001_ch1.md -s 10 -e 15 -p 5 -n 5 --model gpt-4`, RunE: r.run, } r.cmd.Flags().BoolVarP(&r.json, "json", "j", false, "Output in JSON format") + r.cmd.Flags().StringVarP(&r.filepath, "filepath", "f", "", "Path to the file relative to vault root (required)") r.cmd.Flags().IntVarP(&r.lineStart, "line-start", "s", 0, "Starting line number to rewrite (required, 1-indexed)") r.cmd.Flags().IntVarP(&r.lineEnd, "line-end", "e", 0, "Ending line number to rewrite (required, 1-indexed, inclusive)") r.cmd.Flags().IntVarP(&r.contextPrev, "context-prev", "p", 3, "Number of lines before the target as context (default 3)") r.cmd.Flags().IntVarP(&r.contextNext, "context-next", "n", 3, "Number of lines after the target as context (default 3)") + r.cmd.MarkFlagRequired("filepath") r.cmd.MarkFlagRequired("line-start") r.cmd.MarkFlagRequired("line-end") @@ -71,8 +74,6 @@ Example: } func (r *RewriteCmd) run(cmd *cobra.Command, args []string) error { - filepath := args[0] - // Validate line numbers if r.lineStart < 1 { return fmt.Errorf("line-start must be >= 1, got %d", r.lineStart) @@ -128,13 +129,13 @@ func (r *RewriteCmd) run(cmd *cobra.Command, args []string) error { fmt.Println("Rewriting lines with AI...") fmt.Printf(" Project: %s\n", project.Name) fmt.Printf(" Model: %s\n", effectiveModel) - fmt.Printf(" File: %s\n", filepath) + fmt.Printf(" File: %s\n", r.filepath) fmt.Printf(" Lines: %d-%d\n", r.lineStart, r.lineEnd) fmt.Printf(" Context: %d lines before, %d lines after\n", r.contextPrev, r.contextNext) } // Read the file and extract lines - fileLines, err := r.readFileLines(filepath) + fileLines, err := r.readFileLines(r.filepath) if err != nil { return fmt.Errorf("failed to read file: %w", err) } @@ -177,7 +178,7 @@ func (r *RewriteCmd) run(cmd *cobra.Command, args []string) error { slog.Info("Rewriting text", "project", project.Name, "model", effectiveModel, - "file", filepath, + "file", r.filepath, "line_range", fmt.Sprintf("%d-%d", r.lineStart, r.lineEnd), ) @@ -190,18 +191,19 @@ func (r *RewriteCmd) run(cmd *cobra.Command, args []string) error { // Replace the target lines with rewritten content rewrittenLines := strings.Split(strings.TrimSpace(rewrittenContent), "\n") - newFileLines := make([]string, 0, len(fileLines)) - newFileLines = append(newFileLines, fileLines[:targetStartIdx]...) - newFileLines = append(newFileLines, rewrittenLines...) - newFileLines = append(newFileLines, fileLines[targetEndIdx:]...) - - // Write back to file - newContent := strings.Join(newFileLines, "\n") - if err := os.WriteFile(filepath, []byte(newContent), 0644); err != nil { - return fmt.Errorf("failed to write file: %w", err) - } if !r.json { + // In non-JSON mode, write to file immediately + newFileLines := make([]string, 0, len(fileLines)) + newFileLines = append(newFileLines, fileLines[:targetStartIdx]...) + newFileLines = append(newFileLines, rewrittenLines...) + newFileLines = append(newFileLines, fileLines[targetEndIdx:]...) + + newContent := strings.Join(newFileLines, "\n") + if err := os.WriteFile(r.filepath, []byte(newContent), 0644); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + fmt.Printf("\n✓ Successfully rewrote lines %d-%d!\n", r.lineStart, r.lineEnd) fmt.Printf(" Original lines: %d\n", r.lineEnd-r.lineStart+1) fmt.Printf(" New lines: %d\n", len(rewrittenLines)) @@ -212,8 +214,9 @@ func (r *RewriteCmd) run(cmd *cobra.Command, args []string) error { fmt.Printf("\nRewritten content:\n") fmt.Printf(" %s\n", strings.ReplaceAll(rewrittenContent, "\n", "\n ")) } else { + // In JSON mode, only return the result without modifying the file output := map[string]any{ - "filepath": filepath, + "filepath": r.filepath, "original_line_count": r.lineEnd - r.lineStart + 1, "new_line_count": len(rewrittenLines), "input_tokens": usage.InputTokens, diff --git a/internal/obsidian/init_template/Config/rewrite_prompt.md b/internal/obsidian/init_template/Config/rewrite_prompt.md index 4b9c5f0..69e0ecb 100644 --- a/internal/obsidian/init_template/Config/rewrite_prompt.md +++ b/internal/obsidian/init_template/Config/rewrite_prompt.md @@ -21,11 +21,11 @@ system: | 修訂後句子長度變化不得超過 ±20%。 --- -【上下文(只讀)】 -{.ContextBefore} +【上文(只讀)】 +{{.ContextBefore}} ▶【目標句子(唯一可修改)】 -{.TargetSentence} +{{.TargetSentence}} -【上下文(只讀)】 -{.ContextAfter} \ No newline at end of file +【下文(只讀)】 +{{.ContextAfter}} diff --git a/novelmaker/render.go b/novelmaker/render.go index f5ae2c3..857871d 100644 --- a/novelmaker/render.go +++ b/novelmaker/render.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "log/slog" "time" "github.com/voilelab/gonovelmaker/internal/llmbackend" @@ -167,6 +168,10 @@ func (r *Renderer) RenderRewrite( {Role: llmbackend.RoleUser, Content: promptContent}, } + slog.Info("Prepared rewrite prompt", + "system", systemPrompt, + "user", promptContent) + ctx := context.Background() if r.timeout > 0 { var cancel context.CancelFunc diff --git a/obsidian-novelmaker/main.js b/obsidian-novelmaker/main.js index 15d7f21..d5ff13e 100644 --- a/obsidian-novelmaker/main.js +++ b/obsidian-novelmaker/main.js @@ -9,6 +9,7 @@ const { registerChapterCommands } = require('./src/commands/chapter'); const { registerCharacterCommands } = require('./src/commands/character'); const { registerExportCommand } = require('./src/commands/export'); const { registerLogsCommand } = require('./src/commands/logs'); +const { registerRewriteCommand } = require('./src/commands/rewrite'); class NovelMakerPlugin extends Plugin { async onload() { @@ -23,6 +24,7 @@ class NovelMakerPlugin extends Plugin { registerCharacterCommands(this); registerExportCommand(this); registerLogsCommand(this); + registerRewriteCommand(this); } onunload() { diff --git a/obsidian-novelmaker/src/commands/rewrite.js b/obsidian-novelmaker/src/commands/rewrite.js new file mode 100644 index 0000000..5e6d1a4 --- /dev/null +++ b/obsidian-novelmaker/src/commands/rewrite.js @@ -0,0 +1,102 @@ +const { Notice } = require('obsidian'); +const LoadingModal = require('../modals/loading'); +const RewriteConfirmModal = require('../modals/rewrite-confirm'); +const { executeCLI, parseJSONOutput, buildCLICommand } = require('../utils/cli'); + +/** + * Register rewrite command + */ +function registerRewriteCommand(plugin) { + plugin.addCommand({ + id: 'rewrite-lines', + name: '改寫選取的行', + editorCheckCallback: (checking, editor, view) => { + const file = view.file; + + // Only available for markdown files + if (!file || file.extension !== 'md') { + return false; + } + + if (!checking) { + executeRewrite(plugin, editor, file); + } + + return true; + } + }); +} + +async function executeRewrite(plugin, editor, file) { + try { + // Get selection or current line + const selection = editor.getSelection(); + let lineStart, lineEnd, selectedText; + + if (selection) { + // User has selected text + const from = editor.getCursor('from'); + const to = editor.getCursor('to'); + lineStart = from.line + 1; // Convert to 1-indexed + lineEnd = to.line + 1; + selectedText = selection; + } else { + // No selection, use current line + const cursor = editor.getCursor(); + lineStart = cursor.line + 1; + lineEnd = cursor.line + 1; + selectedText = editor.getLine(cursor.line); + } + + // Default context lines + const contextPrev = 3; + const contextNext = 3; + + const loadingModal = new LoadingModal(plugin.app, '正在改寫文字...請稍候'); + loadingModal.open(); + + try { + const vaultPath = plugin.app.vault.adapter.basePath; + + const args = buildCLICommand('rewrite', { + json: true, + filepath: file.path, + lineStart, + lineEnd, + contextPrev, + contextNext, + backend: plugin.settings.backend, + timeout: plugin.settings.timeout, + }); + + const { stdout } = await executeCLI(plugin, plugin.settings.cliPath, args, vaultPath); + const output = parseJSONOutput(stdout); + + // Show confirmation modal with before/after comparison + new RewriteConfirmModal( + plugin.app, + editor, + file, + selectedText, + output.rewritten_content, + lineStart, + lineEnd, + output + ).open(); + + } catch (error) { + new Notice(`❌ 錯誤: ${error.message}`); + console.error(error); + } finally { + loadingModal.forceCloseNow(); + } + + } catch (error) { + new Notice(`❌ 錯誤: ${error.message}`); + console.error(error); + } +} + +module.exports = { + registerRewriteCommand, +}; diff --git a/obsidian-novelmaker/src/modals/rewrite-confirm.js b/obsidian-novelmaker/src/modals/rewrite-confirm.js new file mode 100644 index 0000000..a0e71ba --- /dev/null +++ b/obsidian-novelmaker/src/modals/rewrite-confirm.js @@ -0,0 +1,100 @@ +const { Modal, Setting } = require('obsidian'); + +class RewriteConfirmModal extends Modal { + constructor(app, editor, file, originalText, rewrittenText, lineStart, lineEnd, usageInfo) { + super(app); + this.editor = editor; + this.file = file; + this.originalText = originalText; + this.rewrittenText = rewrittenText; + this.lineStart = lineStart; + this.lineEnd = lineEnd; + this.usageInfo = usageInfo; + } + + onOpen() { + const { contentEl } = this; + + contentEl.createEl('h2', { text: '改寫確認' }); + + // File info + new Setting(contentEl) + .setName('檔案') + .setDesc(this.file.path); + + new Setting(contentEl) + .setName('行數範圍') + .setDesc(`第 ${this.lineStart} 行到第 ${this.lineEnd} 行`); + + // Before section + contentEl.createEl('h3', { text: '原文' }); + const beforeDiv = contentEl.createDiv({ cls: 'rewrite-before' }); + beforeDiv.createEl('pre', { + text: this.originalText, + cls: 'rewrite-text-box' + }); + + // After section + contentEl.createEl('h3', { text: '改寫後' }); + const afterDiv = contentEl.createDiv({ cls: 'rewrite-after' }); + afterDiv.createEl('pre', { + text: this.rewrittenText, + cls: 'rewrite-text-box' + }); + + // Usage info + if (this.usageInfo) { + const usageDiv = contentEl.createDiv({ cls: 'rewrite-usage-info' }); + usageDiv.createEl('p', { + text: `Token 使用量: 輸入 ${this.usageInfo.input_tokens}, 輸出 ${this.usageInfo.output_tokens}, 總計 ${this.usageInfo.total_tokens}`, + cls: 'mod-muted' + }); + } + + // Buttons + new Setting(contentEl) + .addButton((btn) => + btn + .setButtonText('取消') + .onClick(() => { + this.close(); + }) + ) + .addButton((btn) => + btn + .setButtonText('接受改寫') + .setCta() + .onClick(() => { + this.applyRewrite(); + this.close(); + }) + ); + } + + applyRewrite() { + // Get current document content + const doc = this.editor.getDoc(); + + // Convert from 1-indexed to 0-indexed + const startLine = this.lineStart - 1; + const endLine = this.lineEnd - 1; + + // Get the start of the first line and end of the last line + const from = { line: startLine, ch: 0 }; + const to = { line: endLine, ch: this.editor.getLine(endLine).length }; + + // Replace the content + this.editor.replaceRange(this.rewrittenText, from, to); + + // Show success notice + const { Notice } = require('obsidian'); + new Notice('✅ 已套用改寫內容!'); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} + +module.exports = RewriteConfirmModal; diff --git a/obsidian-novelmaker/src/utils/cli.js b/obsidian-novelmaker/src/utils/cli.js index 1f5e68f..4b9ad92 100644 --- a/obsidian-novelmaker/src/utils/cli.js +++ b/obsidian-novelmaker/src/utils/cli.js @@ -99,6 +99,22 @@ function buildCLICommand(baseCommand, options = {}) { args.push('--type', options.type); } + if (options.lineStart !== null && options.lineStart !== undefined) { + args.push('--line-start', String(options.lineStart)); + } + + if (options.lineEnd !== null && options.lineEnd !== undefined) { + args.push('--line-end', String(options.lineEnd)); + } + + if (options.contextPrev !== null && options.contextPrev !== undefined) { + args.push('--context-prev', String(options.contextPrev)); + } + + if (options.contextNext !== null && options.contextNext !== undefined) { + args.push('--context-next', String(options.contextNext)); + } + return args; } diff --git a/obsidian-novelmaker/styles.css b/obsidian-novelmaker/styles.css index d447d46..e0caf06 100644 --- a/obsidian-novelmaker/styles.css +++ b/obsidian-novelmaker/styles.css @@ -192,3 +192,33 @@ .novelmaker-backend-actions button.mod-warning:hover { background-color: var(--background-modifier-error); } + +/* Rewrite confirmation modal styling */ +.rewrite-text-box { + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + padding: 12px; + margin: 8px 0 16px 0; + font-family: var(--font-text); + font-size: 0.95em; + line-height: 1.6; + white-space: pre-wrap; + word-wrap: break-word; + max-height: 300px; + overflow-y: auto; +} + +.rewrite-before h3 { + color: var(--text-muted); +} + +.rewrite-after h3 { + color: var(--text-accent); +} + +.rewrite-usage-info { + margin: 16px 0; + padding-top: 16px; + border-top: 1px solid var(--background-modifier-border); +} From 856267d4bed6e3c07b9ace9652cc2188d3ba39eb Mon Sep 17 00:00:00 2001 From: Mukyu Date: Mon, 5 Jan 2026 23:25:44 +0800 Subject: [PATCH 4/9] =?UTF-8?q?=E2=9C=A8=20feat(rewrite):=20add=20prompt?= =?UTF-8?q?=20goal=20functionality=20for=20rewrite=20command=20and=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/novelmaker-obs/cmd_rewrite.go | 4 +- .../init_template/Config/rewrite_prompt.md | 10 +--- novelmaker/render.go | 4 +- obsidian-novelmaker/src/commands/rewrite.js | 47 ++++++++------- .../src/modals/rewrite-prompt.js | 58 +++++++++++++++++++ obsidian-novelmaker/src/utils/cli.js | 4 ++ 6 files changed, 97 insertions(+), 30 deletions(-) create mode 100644 obsidian-novelmaker/src/modals/rewrite-prompt.js diff --git a/cmd/novelmaker-obs/cmd_rewrite.go b/cmd/novelmaker-obs/cmd_rewrite.go index 7af629f..40f3539 100644 --- a/cmd/novelmaker-obs/cmd_rewrite.go +++ b/cmd/novelmaker-obs/cmd_rewrite.go @@ -20,6 +20,7 @@ import ( type RewriteCmd struct { json bool filepath string + promptGoal string lineStart int lineEnd int contextPrev int @@ -56,6 +57,7 @@ Example: r.cmd.Flags().BoolVarP(&r.json, "json", "j", false, "Output in JSON format") r.cmd.Flags().StringVarP(&r.filepath, "filepath", "f", "", "Path to the file relative to vault root (required)") + r.cmd.Flags().StringVarP(&r.promptGoal, "prompt", "g", "去除 AI 味的慣用詞與過度評述語氣", "Rewrite goal/instruction (optional, default: remove AI-like phrases)") r.cmd.Flags().IntVarP(&r.lineStart, "line-start", "s", 0, "Starting line number to rewrite (required, 1-indexed)") r.cmd.Flags().IntVarP(&r.lineEnd, "line-end", "e", 0, "Ending line number to rewrite (required, 1-indexed, inclusive)") r.cmd.Flags().IntVarP(&r.contextPrev, "context-prev", "p", 3, "Number of lines before the target as context (default 3)") @@ -184,7 +186,7 @@ func (r *RewriteCmd) run(cmd *cobra.Command, args []string) error { // Call AI API rewrittenContent, usage, err := renderer.RenderRewrite( - project, rewritePrompt, contextBefore, targetSentence, contextAfter) + project, rewritePrompt, r.promptGoal, contextBefore, targetSentence, contextAfter) if err != nil { return fmt.Errorf("failed to rewrite text: %w", err) } diff --git a/internal/obsidian/init_template/Config/rewrite_prompt.md b/internal/obsidian/init_template/Config/rewrite_prompt.md index 69e0ecb..924c808 100644 --- a/internal/obsidian/init_template/Config/rewrite_prompt.md +++ b/internal/obsidian/init_template/Config/rewrite_prompt.md @@ -10,17 +10,13 @@ system: | - 不得新增事件、資訊、情緒、意象或結論。 - 不得讓修訂後的句子影響上下文的既有含義。 - 【修改目標】 - 去除 AI 味的慣用詞與過度評述語氣。 - - 【優先處理】 - - 因此、於是、顯然、不禁、總之、由此可見 - - 抽象情緒詞(感到、覺得、內心) - 【長度限制】 修訂後句子長度變化不得超過 ±20%。 --- +【修改目標】 +{{.PromptGoal}} + 【上文(只讀)】 {{.ContextBefore}} diff --git a/novelmaker/render.go b/novelmaker/render.go index 857871d..df28d09 100644 --- a/novelmaker/render.go +++ b/novelmaker/render.go @@ -137,6 +137,7 @@ func (r *Renderer) RenderCharacter( // RewritePromptData holds the data for rendering rewrite prompts type RewritePromptData struct { + PromptGoal string ContextBefore string TargetSentence string ContextAfter string @@ -144,9 +145,10 @@ type RewritePromptData struct { // RenderRewrite generates a rewritten text using the specified OpenAI model func (r *Renderer) RenderRewrite( - project *Project, rewritePrompt *RewritePrompt, contextBefore string, targetSentence string, contextAfter string) (string, llmbackend.UsageInfo, error) { + project *Project, rewritePrompt *RewritePrompt, promptGoal string, contextBefore string, targetSentence string, contextAfter string) (string, llmbackend.UsageInfo, error) { data := RewritePromptData{ + PromptGoal: promptGoal, ContextBefore: contextBefore, TargetSentence: targetSentence, ContextAfter: contextAfter, diff --git a/obsidian-novelmaker/src/commands/rewrite.js b/obsidian-novelmaker/src/commands/rewrite.js index 5e6d1a4..b3e0ffe 100644 --- a/obsidian-novelmaker/src/commands/rewrite.js +++ b/obsidian-novelmaker/src/commands/rewrite.js @@ -1,5 +1,6 @@ const { Notice } = require('obsidian'); const LoadingModal = require('../modals/loading'); +const RewritePromptModal = require('../modals/rewrite-prompt'); const RewriteConfirmModal = require('../modals/rewrite-confirm'); const { executeCLI, parseJSONOutput, buildCLICommand } = require('../utils/cli'); @@ -19,7 +20,29 @@ function registerRewriteCommand(plugin) { } if (!checking) { - executeRewrite(plugin, editor, file); + // Get selection or current line + const selection = editor.getSelection(); + let lineStart, lineEnd, selectedText; + + if (selection) { + // User has selected text + const from = editor.getCursor('from'); + const to = editor.getCursor('to'); + lineStart = from.line + 1; // Convert to 1-indexed + lineEnd = to.line + 1; + selectedText = selection; + } else { + // No selection, use current line + const cursor = editor.getCursor(); + lineStart = cursor.line + 1; + lineEnd = cursor.line + 1; + selectedText = editor.getLine(cursor.line); + } + + // Show prompt goal input modal + new RewritePromptModal(plugin.app, async (promptGoal) => { + await executeRewrite(plugin, editor, file, lineStart, lineEnd, selectedText, promptGoal); + }).open(); } return true; @@ -27,27 +50,8 @@ function registerRewriteCommand(plugin) { }); } -async function executeRewrite(plugin, editor, file) { +async function executeRewrite(plugin, editor, file, lineStart, lineEnd, selectedText, promptGoal) { try { - // Get selection or current line - const selection = editor.getSelection(); - let lineStart, lineEnd, selectedText; - - if (selection) { - // User has selected text - const from = editor.getCursor('from'); - const to = editor.getCursor('to'); - lineStart = from.line + 1; // Convert to 1-indexed - lineEnd = to.line + 1; - selectedText = selection; - } else { - // No selection, use current line - const cursor = editor.getCursor(); - lineStart = cursor.line + 1; - lineEnd = cursor.line + 1; - selectedText = editor.getLine(cursor.line); - } - // Default context lines const contextPrev = 3; const contextNext = 3; @@ -65,6 +69,7 @@ async function executeRewrite(plugin, editor, file) { lineEnd, contextPrev, contextNext, + promptGoal, backend: plugin.settings.backend, timeout: plugin.settings.timeout, }); diff --git a/obsidian-novelmaker/src/modals/rewrite-prompt.js b/obsidian-novelmaker/src/modals/rewrite-prompt.js new file mode 100644 index 0000000..042c825 --- /dev/null +++ b/obsidian-novelmaker/src/modals/rewrite-prompt.js @@ -0,0 +1,58 @@ +const { Modal, Setting } = require('obsidian'); + +class RewritePromptModal extends Modal { + constructor(app, onSubmit) { + super(app); + this.promptGoal = '去除 AI 味的慣用詞與過度評述語氣'; + this.onSubmit = onSubmit; + } + + onOpen() { + const { contentEl } = this; + + contentEl.createEl('h2', { text: '改寫目標' }); + + contentEl.createEl('p', { + text: '請描述您想要如何改寫選取的文字:', + cls: 'setting-item-description' + }); + + new Setting(contentEl) + .setName('改寫目標') + .setDesc('例如:去除 AI 味、改善語氣、簡化句子、增加描述等') + .addTextArea((text) => { + text + .setValue(this.promptGoal) + .onChange((value) => { + this.promptGoal = value; + }); + text.inputEl.rows = 4; + text.inputEl.style.width = '100%'; + }); + + new Setting(contentEl) + .addButton((btn) => + btn + .setButtonText('取消') + .onClick(() => { + this.close(); + }) + ) + .addButton((btn) => + btn + .setButtonText('開始改寫') + .setCta() + .onClick(() => { + this.close(); + this.onSubmit(this.promptGoal); + }) + ); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} + +module.exports = RewritePromptModal; diff --git a/obsidian-novelmaker/src/utils/cli.js b/obsidian-novelmaker/src/utils/cli.js index 4b9ad92..0b3c898 100644 --- a/obsidian-novelmaker/src/utils/cli.js +++ b/obsidian-novelmaker/src/utils/cli.js @@ -79,6 +79,10 @@ function buildCLICommand(baseCommand, options = {}) { args.push('--prompt', options.prompt); } + if (options.promptGoal && options.promptGoal.trim()) { + args.push('--prompt', options.promptGoal); + } + if (options.prevCount !== null && options.prevCount !== undefined) { args.push('--prev-chapters', String(options.prevCount)); } From 88a4c924d718e1b283c8fb8495ea2e04124a4cdb Mon Sep 17 00:00:00 2001 From: Mukyu Date: Tue, 6 Jan 2026 00:00:33 +0800 Subject: [PATCH 5/9] =?UTF-8?q?=E2=9C=A8=20feat(tests):=20add=20comprehens?= =?UTF-8?q?ive=20tests=20for=20rewrite=20command=20validation=20and=20cont?= =?UTF-8?q?ext=20extraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/novelmaker-obs/cmd_rewrite_test.go | 300 +++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 cmd/novelmaker-obs/cmd_rewrite_test.go diff --git a/cmd/novelmaker-obs/cmd_rewrite_test.go b/cmd/novelmaker-obs/cmd_rewrite_test.go new file mode 100644 index 0000000..66559e1 --- /dev/null +++ b/cmd/novelmaker-obs/cmd_rewrite_test.go @@ -0,0 +1,300 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/voilelab/gonovelmaker/internal/obsidian" +) + +func TestRewriteCmd_Validation(t *testing.T) { + tests := []struct { + name string + lineStart int + lineEnd int + contextPrev int + contextNext int + wantError bool + errorMsg string + }{ + { + name: "valid line range", + lineStart: 1, + lineEnd: 5, + contextPrev: 3, + contextNext: 3, + wantError: false, + }, + { + name: "line-start less than 1", + lineStart: 0, + lineEnd: 5, + contextPrev: 3, + contextNext: 3, + wantError: true, + errorMsg: "line-start must be >= 1", + }, + { + name: "line-end before line-start", + lineStart: 10, + lineEnd: 5, + contextPrev: 3, + contextNext: 3, + wantError: true, + errorMsg: "line-end (5) must be >= line-start (10)", + }, + { + name: "negative context-prev", + lineStart: 1, + lineEnd: 5, + contextPrev: -1, + contextNext: 3, + wantError: true, + errorMsg: "context-prev must be >= 0", + }, + { + name: "negative context-next", + lineStart: 1, + lineEnd: 5, + contextPrev: 3, + contextNext: -1, + wantError: true, + errorMsg: "context-next must be >= 0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &RewriteCmd{ + lineStart: tt.lineStart, + lineEnd: tt.lineEnd, + contextPrev: tt.contextPrev, + contextNext: tt.contextNext, + } + + err := cmd.run(cmd.cmd, []string{}) + + if tt.wantError { + if err == nil { + t.Error("Expected error but got none") + } else if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("Expected error containing %q, got %q", tt.errorMsg, err.Error()) + } + } + // For valid line ranges, we don't check for success since we don't have a full test environment + }) + } +} + +func TestRewriteCmd_ReadFileLines(t *testing.T) { + tmpDir := t.TempDir() + + testFile := filepath.Join(tmpDir, "test.txt") + content := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + cmd := &RewriteCmd{} + lines, err := cmd.readFileLines(testFile) + if err != nil { + t.Fatalf("Failed to read file lines: %v", err) + } + + expectedLines := []string{"Line 1", "Line 2", "Line 3", "Line 4", "Line 5"} + if len(lines) != len(expectedLines) { + t.Errorf("Expected %d lines, got %d", len(expectedLines), len(lines)) + } + + for i, line := range lines { + if line != expectedLines[i] { + t.Errorf("Line %d: expected %q, got %q", i+1, expectedLines[i], line) + } + } +} + +func TestRewriteCmd_ReadFileLines_NonExistent(t *testing.T) { + cmd := &RewriteCmd{} + _, err := cmd.readFileLines("/nonexistent/file.txt") + if err == nil { + t.Error("Expected error for non-existent file, got none") + } +} + +func TestRewriteCmd_ContextExtraction(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "test-vault-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Initialize vault structure + vault, err := obsidian.NewVault(tmpDir) + if err != nil { + t.Fatalf("Failed to create vault: %v", err) + } + defer vault.Close() + + if err := vault.Initialize(); err != nil { + t.Fatalf("Failed to initialize vault: %v", err) + } + + // Create a test file + storyDir := filepath.Join(tmpDir, "Story") + testFile := filepath.Join(storyDir, "test.md") + lines := []string{ + "Line 1", + "Line 2", + "Line 3", + "Line 4 - Target Start", + "Line 5 - Target", + "Line 6 - Target End", + "Line 7", + "Line 8", + "Line 9", + "Line 10", + } + content := strings.Join(lines, "\n") + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + tests := []struct { + name string + lineStart int + lineEnd int + contextPrev int + contextNext int + expectedBefore string + expectedTarget string + expectedAfter string + wantLineCountError bool + }{ + { + name: "middle range with context", + lineStart: 4, + lineEnd: 6, + contextPrev: 2, + contextNext: 2, + expectedBefore: "Line 2\nLine 3", + expectedTarget: "Line 4 - Target Start\nLine 5 - Target\nLine 6 - Target End", + expectedAfter: "Line 7\nLine 8", + }, + { + name: "start of file", + lineStart: 1, + lineEnd: 2, + contextPrev: 3, + contextNext: 2, + expectedBefore: "", + expectedTarget: "Line 1\nLine 2", + expectedAfter: "Line 3\nLine 4 - Target Start", + }, + { + name: "end of file", + lineStart: 9, + lineEnd: 10, + contextPrev: 2, + contextNext: 5, + expectedBefore: "Line 7\nLine 8", + expectedTarget: "Line 9\nLine 10", + expectedAfter: "", + }, + { + name: "line range exceeds file", + lineStart: 15, + lineEnd: 20, + contextPrev: 2, + contextNext: 2, + wantLineCountError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &RewriteCmd{} + fileLines, err := cmd.readFileLines(testFile) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + totalLines := len(fileLines) + + // Check for line count errors + if tt.wantLineCountError { + if tt.lineStart <= totalLines && tt.lineEnd <= totalLines { + t.Error("Expected line count error but lines are within range") + } + return + } + + if tt.lineStart > totalLines || tt.lineEnd > totalLines { + t.Skip("Line range exceeds file length") + } + + // Extract context (same logic as cmd_rewrite.go) + contextStartIdx := max(0, tt.lineStart-1-tt.contextPrev) + targetStartIdx := tt.lineStart - 1 + targetEndIdx := tt.lineEnd + contextEndIdx := min(totalLines, tt.lineEnd+tt.contextNext) + + contextBefore := strings.Join(fileLines[contextStartIdx:targetStartIdx], "\n") + targetSentence := strings.Join(fileLines[targetStartIdx:targetEndIdx], "\n") + contextAfter := strings.Join(fileLines[targetEndIdx:contextEndIdx], "\n") + + if contextBefore != tt.expectedBefore { + t.Errorf("Context before mismatch:\nExpected: %q\nGot: %q", tt.expectedBefore, contextBefore) + } + if targetSentence != tt.expectedTarget { + t.Errorf("Target sentence mismatch:\nExpected: %q\nGot: %q", tt.expectedTarget, targetSentence) + } + if contextAfter != tt.expectedAfter { + t.Errorf("Context after mismatch:\nExpected: %q\nGot: %q", tt.expectedAfter, contextAfter) + } + }) + } +} + +func TestRewriteCmd_PromptGoalDefault(t *testing.T) { + cmd := NewRewriteCmd(nil) + + // Check that the default prompt goal is set + defaultGoal := "去除 AI 味的慣用詞與過度評述語氣" + + // Get the flag value + flag := cmd.cmd.Flags().Lookup("prompt") + if flag == nil { + t.Fatal("prompt flag not found") + } + + if flag.DefValue != defaultGoal { + t.Errorf("Expected default prompt goal %q, got %q", defaultGoal, flag.DefValue) + } +} + +func TestRewritePromptTemplate(t *testing.T) { + t.Run("rewrite prompt template exists and is valid", func(t *testing.T) { + tmpDir := "../../internal/obsidian/init_template" + + vault, err := obsidian.NewVault(tmpDir) + if err != nil { + t.Fatalf("failed to create vault: %v", err) + } + + rewritePrompt, err := vault.LoadRewritePrompt() + if err != nil { + t.Errorf("failed to load rewrite prompt: %v", err) + } + if rewritePrompt == nil { + t.Error("rewrite prompt should not be nil") + } + if rewritePrompt != nil && rewritePrompt.System == "" { + t.Error("rewrite prompt system should not be empty") + } + if rewritePrompt != nil && rewritePrompt.AssistantTemplate == nil { + t.Error("rewrite prompt template should not be nil") + } + }) +} From 584e8ad5ed886e593f89fb5b5e0426265d2a3e83 Mon Sep 17 00:00:00 2001 From: Mukyu Date: Tue, 6 Jan 2026 22:41:55 +0800 Subject: [PATCH 6/9] =?UTF-8?q?=F0=9F=A7=B9=20refactor(cmd=5Frewrite):=20r?= =?UTF-8?q?emove=20min/max?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/novelmaker-obs/cmd_rewrite.go | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/cmd/novelmaker-obs/cmd_rewrite.go b/cmd/novelmaker-obs/cmd_rewrite.go index 40f3539..05c3b39 100644 --- a/cmd/novelmaker-obs/cmd_rewrite.go +++ b/cmd/novelmaker-obs/cmd_rewrite.go @@ -255,17 +255,3 @@ func (r *RewriteCmd) readFileLines(filepath string) ([]string, error) { return lines, nil } - -func max(a, b int) int { - if a > b { - return a - } - return b -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} From b5aba81fb97527ada3bd277b1687bccbb82fbef0 Mon Sep 17 00:00:00 2001 From: Mukyu Date: Tue, 6 Jan 2026 22:42:39 +0800 Subject: [PATCH 7/9] =?UTF-8?q?=F0=9F=A7=B9=20refactor(tests):=20remove=20?= =?UTF-8?q?unused=20context=20extraction=20tests=20and=20clean=20up=20impo?= =?UTF-8?q?rts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/novelmaker-obs/cmd_rewrite_test.go | 178 ------------------------- 1 file changed, 178 deletions(-) diff --git a/cmd/novelmaker-obs/cmd_rewrite_test.go b/cmd/novelmaker-obs/cmd_rewrite_test.go index 66559e1..e0460f6 100644 --- a/cmd/novelmaker-obs/cmd_rewrite_test.go +++ b/cmd/novelmaker-obs/cmd_rewrite_test.go @@ -5,8 +5,6 @@ import ( "path/filepath" "strings" "testing" - - "github.com/voilelab/gonovelmaker/internal/obsidian" ) func TestRewriteCmd_Validation(t *testing.T) { @@ -122,179 +120,3 @@ func TestRewriteCmd_ReadFileLines_NonExistent(t *testing.T) { t.Error("Expected error for non-existent file, got none") } } - -func TestRewriteCmd_ContextExtraction(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "test-vault-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - // Initialize vault structure - vault, err := obsidian.NewVault(tmpDir) - if err != nil { - t.Fatalf("Failed to create vault: %v", err) - } - defer vault.Close() - - if err := vault.Initialize(); err != nil { - t.Fatalf("Failed to initialize vault: %v", err) - } - - // Create a test file - storyDir := filepath.Join(tmpDir, "Story") - testFile := filepath.Join(storyDir, "test.md") - lines := []string{ - "Line 1", - "Line 2", - "Line 3", - "Line 4 - Target Start", - "Line 5 - Target", - "Line 6 - Target End", - "Line 7", - "Line 8", - "Line 9", - "Line 10", - } - content := strings.Join(lines, "\n") - if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { - t.Fatalf("Failed to create test file: %v", err) - } - - tests := []struct { - name string - lineStart int - lineEnd int - contextPrev int - contextNext int - expectedBefore string - expectedTarget string - expectedAfter string - wantLineCountError bool - }{ - { - name: "middle range with context", - lineStart: 4, - lineEnd: 6, - contextPrev: 2, - contextNext: 2, - expectedBefore: "Line 2\nLine 3", - expectedTarget: "Line 4 - Target Start\nLine 5 - Target\nLine 6 - Target End", - expectedAfter: "Line 7\nLine 8", - }, - { - name: "start of file", - lineStart: 1, - lineEnd: 2, - contextPrev: 3, - contextNext: 2, - expectedBefore: "", - expectedTarget: "Line 1\nLine 2", - expectedAfter: "Line 3\nLine 4 - Target Start", - }, - { - name: "end of file", - lineStart: 9, - lineEnd: 10, - contextPrev: 2, - contextNext: 5, - expectedBefore: "Line 7\nLine 8", - expectedTarget: "Line 9\nLine 10", - expectedAfter: "", - }, - { - name: "line range exceeds file", - lineStart: 15, - lineEnd: 20, - contextPrev: 2, - contextNext: 2, - wantLineCountError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cmd := &RewriteCmd{} - fileLines, err := cmd.readFileLines(testFile) - if err != nil { - t.Fatalf("Failed to read file: %v", err) - } - - totalLines := len(fileLines) - - // Check for line count errors - if tt.wantLineCountError { - if tt.lineStart <= totalLines && tt.lineEnd <= totalLines { - t.Error("Expected line count error but lines are within range") - } - return - } - - if tt.lineStart > totalLines || tt.lineEnd > totalLines { - t.Skip("Line range exceeds file length") - } - - // Extract context (same logic as cmd_rewrite.go) - contextStartIdx := max(0, tt.lineStart-1-tt.contextPrev) - targetStartIdx := tt.lineStart - 1 - targetEndIdx := tt.lineEnd - contextEndIdx := min(totalLines, tt.lineEnd+tt.contextNext) - - contextBefore := strings.Join(fileLines[contextStartIdx:targetStartIdx], "\n") - targetSentence := strings.Join(fileLines[targetStartIdx:targetEndIdx], "\n") - contextAfter := strings.Join(fileLines[targetEndIdx:contextEndIdx], "\n") - - if contextBefore != tt.expectedBefore { - t.Errorf("Context before mismatch:\nExpected: %q\nGot: %q", tt.expectedBefore, contextBefore) - } - if targetSentence != tt.expectedTarget { - t.Errorf("Target sentence mismatch:\nExpected: %q\nGot: %q", tt.expectedTarget, targetSentence) - } - if contextAfter != tt.expectedAfter { - t.Errorf("Context after mismatch:\nExpected: %q\nGot: %q", tt.expectedAfter, contextAfter) - } - }) - } -} - -func TestRewriteCmd_PromptGoalDefault(t *testing.T) { - cmd := NewRewriteCmd(nil) - - // Check that the default prompt goal is set - defaultGoal := "去除 AI 味的慣用詞與過度評述語氣" - - // Get the flag value - flag := cmd.cmd.Flags().Lookup("prompt") - if flag == nil { - t.Fatal("prompt flag not found") - } - - if flag.DefValue != defaultGoal { - t.Errorf("Expected default prompt goal %q, got %q", defaultGoal, flag.DefValue) - } -} - -func TestRewritePromptTemplate(t *testing.T) { - t.Run("rewrite prompt template exists and is valid", func(t *testing.T) { - tmpDir := "../../internal/obsidian/init_template" - - vault, err := obsidian.NewVault(tmpDir) - if err != nil { - t.Fatalf("failed to create vault: %v", err) - } - - rewritePrompt, err := vault.LoadRewritePrompt() - if err != nil { - t.Errorf("failed to load rewrite prompt: %v", err) - } - if rewritePrompt == nil { - t.Error("rewrite prompt should not be nil") - } - if rewritePrompt != nil && rewritePrompt.System == "" { - t.Error("rewrite prompt system should not be empty") - } - if rewritePrompt != nil && rewritePrompt.AssistantTemplate == nil { - t.Error("rewrite prompt template should not be nil") - } - }) -} From 994ecfd60c870ed67181b269d3c451f7c471df69 Mon Sep 17 00:00:00 2001 From: Mukyu Date: Tue, 6 Jan 2026 23:30:56 +0800 Subject: [PATCH 8/9] =?UTF-8?q?=F0=9F=94=A7=20refactor(render):=20remove?= =?UTF-8?q?=20project=20parameter=20from=20RenderRewrite=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/novelmaker-obs/cmd_rewrite.go | 2 +- novelmaker/render.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/novelmaker-obs/cmd_rewrite.go b/cmd/novelmaker-obs/cmd_rewrite.go index 05c3b39..d96e3cb 100644 --- a/cmd/novelmaker-obs/cmd_rewrite.go +++ b/cmd/novelmaker-obs/cmd_rewrite.go @@ -186,7 +186,7 @@ func (r *RewriteCmd) run(cmd *cobra.Command, args []string) error { // Call AI API rewrittenContent, usage, err := renderer.RenderRewrite( - project, rewritePrompt, r.promptGoal, contextBefore, targetSentence, contextAfter) + rewritePrompt, r.promptGoal, contextBefore, targetSentence, contextAfter) if err != nil { return fmt.Errorf("failed to rewrite text: %w", err) } diff --git a/novelmaker/render.go b/novelmaker/render.go index df28d09..79ec261 100644 --- a/novelmaker/render.go +++ b/novelmaker/render.go @@ -145,7 +145,7 @@ type RewritePromptData struct { // RenderRewrite generates a rewritten text using the specified OpenAI model func (r *Renderer) RenderRewrite( - project *Project, rewritePrompt *RewritePrompt, promptGoal string, contextBefore string, targetSentence string, contextAfter string) (string, llmbackend.UsageInfo, error) { + rewritePrompt *RewritePrompt, promptGoal string, contextBefore string, targetSentence string, contextAfter string) (string, llmbackend.UsageInfo, error) { data := RewritePromptData{ PromptGoal: promptGoal, From 41cab33d9711560c8d6f185332aa3f0b6a391f9e Mon Sep 17 00:00:00 2001 From: Mukyu Date: Tue, 6 Jan 2026 23:31:54 +0800 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=93=9D=20docs(cmd=5Frewrite):=20updat?= =?UTF-8?q?e=20command=20description=20to=20clarify=20file=20update=20proc?= =?UTF-8?q?ess?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/novelmaker-obs/cmd_rewrite.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/novelmaker-obs/cmd_rewrite.go b/cmd/novelmaker-obs/cmd_rewrite.go index d96e3cb..1373311 100644 --- a/cmd/novelmaker-obs/cmd_rewrite.go +++ b/cmd/novelmaker-obs/cmd_rewrite.go @@ -45,7 +45,7 @@ func NewRewriteCmd(llmBackendMaker llmbackend.LLMBackendMaker) *RewriteCmd { Long: `Rewrite a specific range of lines in a file using AI. This command reads the specified file, extracts the target lines and context, -sends them to the AI model for rewriting, and updates the file with the new content. +and sends them to the AI model for rewriting. The filepath should be relative to the vault root (e.g., "Story/001_ch1.md"). Example: