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/cmd/novelmaker-obs/cmd_rewrite.go b/cmd/novelmaker-obs/cmd_rewrite.go new file mode 100644 index 0000000..1373311 --- /dev/null +++ b/cmd/novelmaker-obs/cmd_rewrite.go @@ -0,0 +1,257 @@ +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 + filepath string + promptGoal string + 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", + 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, +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: + 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().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)") + 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") + + // 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 { + // 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", 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(r.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", r.filepath, + "line_range", fmt.Sprintf("%d-%d", r.lineStart, r.lineEnd), + ) + + // Call AI API + rewrittenContent, usage, err := renderer.RenderRewrite( + rewritePrompt, r.promptGoal, 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") + + 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)) + 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 { + // In JSON mode, only return the result without modifying the file + output := map[string]any{ + "filepath": r.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 +} diff --git a/cmd/novelmaker-obs/cmd_rewrite_test.go b/cmd/novelmaker-obs/cmd_rewrite_test.go new file mode 100644 index 0000000..e0460f6 --- /dev/null +++ b/cmd/novelmaker-obs/cmd_rewrite_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +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") + } +} 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()) 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..924c808 --- /dev/null +++ b/internal/obsidian/init_template/Config/rewrite_prompt.md @@ -0,0 +1,27 @@ +--- +system: | + 你是一名小說編輯。 + + 你將看到一段「上下文」與一個「目標句子」。 + + 【重要規則】 + - 你只能修訂「目標句子」本身。 + - 上下文僅用於理解語境,不得修改、重寫或引用其中的句子。 + - 不得新增事件、資訊、情緒、意象或結論。 + - 不得讓修訂後的句子影響上下文的既有含義。 + + 【長度限制】 + 修訂後句子長度變化不得超過 ±20%。 +--- + +【修改目標】 +{{.PromptGoal}} + +【上文(只讀)】 +{{.ContextBefore}} + +▶【目標句子(唯一可修改)】 +{{.TargetSentence}} + +【下文(只讀)】 +{{.ContextAfter}} 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..79ec261 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" @@ -133,3 +134,58 @@ func (r *Renderer) RenderCharacter( return chatCompletion, usage, nil } + +// RewritePromptData holds the data for rendering rewrite prompts +type RewritePromptData struct { + PromptGoal string + ContextBefore string + TargetSentence string + ContextAfter string +} + +// RenderRewrite generates a rewritten text using the specified OpenAI model +func (r *Renderer) RenderRewrite( + rewritePrompt *RewritePrompt, promptGoal string, contextBefore string, targetSentence string, contextAfter string) (string, llmbackend.UsageInfo, error) { + + data := RewritePromptData{ + PromptGoal: promptGoal, + 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}, + } + + slog.Info("Prepared rewrite prompt", + "system", systemPrompt, + "user", 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"` 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..b3e0ffe --- /dev/null +++ b/obsidian-novelmaker/src/commands/rewrite.js @@ -0,0 +1,107 @@ +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'); + +/** + * 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) { + // 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; + } + }); +} + +async function executeRewrite(plugin, editor, file, lineStart, lineEnd, selectedText, promptGoal) { + try { + // 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, + promptGoal, + 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/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 1f5e68f..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)); } @@ -99,6 +103,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); +}