add ai feature
This commit is contained in:
121
internal/ai/apply.go
Normal file
121
internal/ai/apply.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"github.com/rogeecn/renamer/internal/ai/flow"
|
||||
"github.com/rogeecn/renamer/internal/history"
|
||||
"github.com/rogeecn/renamer/internal/output"
|
||||
)
|
||||
|
||||
// ApplyMetadata captures contextual information persisted alongside ledger entries.
|
||||
type ApplyMetadata struct {
|
||||
Prompt string
|
||||
PromptHistory []string
|
||||
Notes []string
|
||||
Model string
|
||||
SequenceSeparator string
|
||||
}
|
||||
|
||||
// toMap converts metadata into a ledger-friendly map.
|
||||
func (m ApplyMetadata) toMap(warnings []string) map[string]any {
|
||||
data := history.BuildAIMetadata(m.Prompt, m.PromptHistory, m.Notes, m.Model, warnings)
|
||||
if m.SequenceSeparator != "" {
|
||||
data["sequenceSeparator"] = m.SequenceSeparator
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// Apply executes the rename suggestions, records a ledger entry, and emits progress updates.
|
||||
func Apply(ctx context.Context, workingDir string, suggestions []flow.Suggestion, validation ValidationResult, meta ApplyMetadata, writer io.Writer) (history.Entry, error) {
|
||||
entry := history.Entry{Command: "ai"}
|
||||
|
||||
if len(suggestions) == 0 {
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
reporter := output.NewProgressReporter(writer, len(suggestions))
|
||||
|
||||
sort.SliceStable(suggestions, func(i, j int) bool {
|
||||
return suggestions[i].Original > suggestions[j].Original
|
||||
})
|
||||
|
||||
operations := make([]history.Operation, 0, len(suggestions))
|
||||
|
||||
revert := func() error {
|
||||
for i := len(operations) - 1; i >= 0; i-- {
|
||||
op := operations[i]
|
||||
source := filepath.Join(workingDir, filepath.FromSlash(op.To))
|
||||
destination := filepath.Join(workingDir, filepath.FromSlash(op.From))
|
||||
if err := os.Rename(source, destination); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, suggestion := range suggestions {
|
||||
if err := ctx.Err(); err != nil {
|
||||
_ = revert()
|
||||
return history.Entry{}, err
|
||||
}
|
||||
|
||||
fromRel := flowToKey(suggestion.Original)
|
||||
toRel := flowToKey(suggestion.Suggested)
|
||||
|
||||
fromAbs := filepath.Join(workingDir, filepath.FromSlash(fromRel))
|
||||
toAbs := filepath.Join(workingDir, filepath.FromSlash(toRel))
|
||||
|
||||
if fromAbs == toAbs {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := ensureParentDir(toAbs); err != nil {
|
||||
_ = revert()
|
||||
return history.Entry{}, err
|
||||
}
|
||||
|
||||
if err := os.Rename(fromAbs, toAbs); err != nil {
|
||||
_ = revert()
|
||||
return history.Entry{}, err
|
||||
}
|
||||
|
||||
operations = append(operations, history.Operation{From: fromRel, To: toRel})
|
||||
if err := reporter.Step(fromRel, toRel); err != nil {
|
||||
_ = revert()
|
||||
return history.Entry{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(operations) == 0 {
|
||||
return entry, reporter.Complete()
|
||||
}
|
||||
|
||||
if err := reporter.Complete(); err != nil {
|
||||
_ = revert()
|
||||
return history.Entry{}, err
|
||||
}
|
||||
|
||||
entry.Operations = operations
|
||||
entry.Metadata = meta.toMap(validation.Warnings)
|
||||
|
||||
if err := history.Append(workingDir, entry); err != nil {
|
||||
_ = revert()
|
||||
return history.Entry{}, err
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func ensureParentDir(path string) error {
|
||||
dir := filepath.Dir(path)
|
||||
if dir == "." || dir == "" {
|
||||
return nil
|
||||
}
|
||||
return os.MkdirAll(dir, 0o755)
|
||||
}
|
||||
59
internal/ai/client.go
Normal file
59
internal/ai/client.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/rogeecn/renamer/internal/ai/flow"
|
||||
)
|
||||
|
||||
// Runner executes the rename flow and returns structured suggestions.
|
||||
type Runner func(ctx context.Context, input *flow.RenameFlowInput) (*flow.Output, error)
|
||||
|
||||
// Client orchestrates flow invocation for callers such as the CLI command.
|
||||
type Client struct {
|
||||
runner Runner
|
||||
}
|
||||
|
||||
// ClientOption customises the AI client behaviour.
|
||||
type ClientOption func(*Client)
|
||||
|
||||
// WithRunner overrides the flow runner implementation (useful for tests).
|
||||
func WithRunner(r Runner) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.runner = r
|
||||
}
|
||||
}
|
||||
|
||||
// NewClient constructs a Client with the default Genkit-backed runner.
|
||||
func NewClient(opts ...ClientOption) *Client {
|
||||
client := &Client{}
|
||||
client.runner = func(ctx context.Context, input *flow.RenameFlowInput) (*flow.Output, error) {
|
||||
creds, err := LoadCredentials()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return runRenameFlow(ctx, input, creds)
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(client)
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
// Suggest executes the rename flow and returns structured suggestions.
|
||||
func (c *Client) Suggest(ctx context.Context, input *flow.RenameFlowInput) (*flow.Output, error) {
|
||||
if c == nil {
|
||||
return nil, ErrClientNotInitialized
|
||||
}
|
||||
if c.runner == nil {
|
||||
return nil, ErrRunnerNotConfigured
|
||||
}
|
||||
return c.runner(ctx, input)
|
||||
}
|
||||
|
||||
// ErrClientNotInitialized indicates the client receiver was nil.
|
||||
var ErrClientNotInitialized = errors.New("ai client not initialized")
|
||||
|
||||
// ErrRunnerNotConfigured indicates the client runner is missing.
|
||||
var ErrRunnerNotConfigured = errors.New("ai client runner not configured")
|
||||
41
internal/ai/config.go
Normal file
41
internal/ai/config.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
var apiKeyEnvVars = []string{
|
||||
"GOOGLE_API_KEY",
|
||||
"GEMINI_API_KEY",
|
||||
"RENAMER_AI_KEY",
|
||||
}
|
||||
|
||||
// Credentials encapsulates the values required to authenticate with the AI provider.
|
||||
type Credentials struct {
|
||||
APIKey string
|
||||
}
|
||||
|
||||
// LoadCredentials returns the AI credentials sourced from environment variables.
|
||||
func LoadCredentials() (Credentials, error) {
|
||||
for _, env := range apiKeyEnvVars {
|
||||
if key, ok := os.LookupEnv(env); ok && key != "" {
|
||||
return Credentials{APIKey: key}, nil
|
||||
}
|
||||
}
|
||||
return Credentials{}, errors.New("AI provider key missing; set GOOGLE_API_KEY (recommended), GEMINI_API_KEY, or RENAMER_AI_KEY")
|
||||
}
|
||||
|
||||
// MaskedCredentials returns a redacted view of the credentials for logging purposes.
|
||||
func MaskedCredentials(creds Credentials) string {
|
||||
if creds.APIKey == "" {
|
||||
return "(empty)"
|
||||
}
|
||||
|
||||
if len(creds.APIKey) <= 6 {
|
||||
return "***"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s***", creds.APIKey[:3])
|
||||
}
|
||||
3
internal/ai/flow/doc.go
Normal file
3
internal/ai/flow/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package flow
|
||||
|
||||
// Package flow hosts the Genkit rename flow implementation.
|
||||
7
internal/ai/flow/flow_test.go
Normal file
7
internal/ai/flow/flow_test.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package flow_test
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRenameFlowStub(t *testing.T) {
|
||||
t.Skip("rename flow implementation pending")
|
||||
}
|
||||
50
internal/ai/flow/json.go
Normal file
50
internal/ai/flow/json.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Suggestion represents a single rename mapping emitted by the Genkit flow.
|
||||
type Suggestion struct {
|
||||
Original string `json:"original"`
|
||||
Suggested string `json:"suggested"`
|
||||
}
|
||||
|
||||
// Output wraps the list of suggestions returned by the flow.
|
||||
type Output struct {
|
||||
Suggestions []Suggestion `json:"suggestions"`
|
||||
}
|
||||
|
||||
var (
|
||||
errEmptyResponse = errors.New("genkit flow returned empty response")
|
||||
errMissingSuggestions = errors.New("genkit flow response missing suggestions")
|
||||
)
|
||||
|
||||
// ParseOutput converts the raw JSON payload into a structured Output.
|
||||
func ParseOutput(raw []byte) (Output, error) {
|
||||
if len(raw) == 0 {
|
||||
return Output{}, errEmptyResponse
|
||||
}
|
||||
|
||||
var out Output
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return Output{}, fmt.Errorf("failed to decode genkit output: %w", err)
|
||||
}
|
||||
|
||||
if len(out.Suggestions) == 0 {
|
||||
return Output{}, errMissingSuggestions
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MarshalInput serialises the flow input for logging or replay.
|
||||
func MarshalInput(input any) ([]byte, error) {
|
||||
buf, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode genkit input: %w", err)
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
29
internal/ai/flow/prompt.tmpl
Normal file
29
internal/ai/flow/prompt.tmpl
Normal file
@@ -0,0 +1,29 @@
|
||||
你是一个智能文件重命名助手。你的任务是根据用户提供的文件名列表和命名指令,为每个文件生成一个清晰、统一的新名称。
|
||||
|
||||
规则:
|
||||
1. 保持原始文件的扩展名不变。
|
||||
2. 新文件名中不允许包含非法字符,如 / \\ : * ? \" < > |。
|
||||
3. 如果需要添加序列号,请先按文件所在的目录维度分组,对每个目录内部的文件进行稳定排序(建议使用原始文件名自然序),序列号放在文件名的开头(例如 "01.假期照片.jpg"),不要放在结尾。序列号和名称之间默认使用句点 (.) 分隔,如果调用方提供了其他分隔符,则使用对应字符。
|
||||
4. 严格按照以下 JSON 格式返回你的建议,不要包含任何额外的解释或 Markdown 标记。
|
||||
|
||||
[INPUT]
|
||||
用户命名指令: "{{ .UserPrompt }}"
|
||||
文件名列表:
|
||||
{{- range .FileNames }}
|
||||
- {{ . }}
|
||||
{{- end }}
|
||||
|
||||
[OUTPUT]
|
||||
请在这里输出你的 JSON 结果,格式如下:
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"original": "原始文件名1.ext",
|
||||
"suggested": "建议的新文件名1.ext"
|
||||
},
|
||||
{
|
||||
"original": "原始文件名2.ext",
|
||||
"suggested": "建议的新文件名2.ext"
|
||||
}
|
||||
]
|
||||
}
|
||||
34
internal/ai/flow/prompt_test.go
Normal file
34
internal/ai/flow/prompt_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package flow_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rogeecn/renamer/internal/ai/flow"
|
||||
)
|
||||
|
||||
func TestRenderPromptIncludesFilesAndPrompt(t *testing.T) {
|
||||
input := flow.RenameFlowInput{
|
||||
FileNames: []string{"IMG_0001.jpg", "albums/Day 1.png"},
|
||||
UserPrompt: "按地点重新命名",
|
||||
}
|
||||
|
||||
rendered, err := flow.RenderPrompt(input)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderPrompt error: %v", err)
|
||||
}
|
||||
|
||||
for _, expected := range []string{"IMG_0001.jpg", "albums/Day 1.png"} {
|
||||
if !strings.Contains(rendered, expected) {
|
||||
t.Fatalf("prompt missing filename %q: %s", expected, rendered)
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.Contains(rendered, "按地点重新命名") {
|
||||
t.Fatalf("prompt missing user guidance: %s", rendered)
|
||||
}
|
||||
|
||||
if !strings.Contains(rendered, "suggestions") {
|
||||
t.Fatalf("prompt missing JSON structure guidance: %s", rendered)
|
||||
}
|
||||
}
|
||||
197
internal/ai/flow/rename_flow.go
Normal file
197
internal/ai/flow/rename_flow.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/template"
|
||||
"unicode"
|
||||
|
||||
"github.com/firebase/genkit/go/core"
|
||||
"github.com/firebase/genkit/go/genkit"
|
||||
)
|
||||
|
||||
//go:embed prompt.tmpl
|
||||
var promptTemplateSource string
|
||||
|
||||
var (
|
||||
promptTemplate = template.Must(template.New("renameFlowPrompt").Parse(promptTemplateSource))
|
||||
)
|
||||
|
||||
// RenameFlowInput mirrors the JSON payload passed into the Genkit flow.
|
||||
type RenameFlowInput struct {
|
||||
FileNames []string `json:"fileNames"`
|
||||
UserPrompt string `json:"userPrompt"`
|
||||
SequenceSeparator string `json:"sequenceSeparator,omitempty"`
|
||||
}
|
||||
|
||||
// Validate ensures the flow input is well formed.
|
||||
func (in *RenameFlowInput) Validate() error {
|
||||
if in == nil {
|
||||
return errors.New("rename flow input cannot be nil")
|
||||
}
|
||||
if len(in.FileNames) == 0 {
|
||||
return errors.New("no file names provided to rename flow")
|
||||
}
|
||||
if len(in.FileNames) > 200 {
|
||||
return fmt.Errorf("rename flow supports up to 200 files per invocation (received %d)", len(in.FileNames))
|
||||
}
|
||||
normalized := make([]string, len(in.FileNames))
|
||||
for i, name := range in.FileNames {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("file name at index %d is empty", i)
|
||||
}
|
||||
normalized[i] = toSlash(trimmed)
|
||||
}
|
||||
// Ensure no duplicates to simplify downstream validation.
|
||||
if dup := firstDuplicate(normalized); dup != "" {
|
||||
return fmt.Errorf("duplicate file name %q detected in flow input", dup)
|
||||
}
|
||||
in.FileNames = normalized
|
||||
|
||||
sep := strings.TrimSpace(in.SequenceSeparator)
|
||||
if sep == "" {
|
||||
sep = "."
|
||||
}
|
||||
if strings.ContainsAny(sep, "/\\") {
|
||||
return fmt.Errorf("sequence separator %q cannot contain path separators", sep)
|
||||
}
|
||||
if strings.ContainsAny(sep, "\n\r") {
|
||||
return errors.New("sequence separator cannot contain newline characters")
|
||||
}
|
||||
in.SequenceSeparator = sep
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderPrompt materialises the prompt template for the provided input.
|
||||
func RenderPrompt(input RenameFlowInput) (string, error) {
|
||||
if err := input.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
if err := promptTemplate.Execute(&builder, input); err != nil {
|
||||
return "", fmt.Errorf("render rename prompt: %w", err)
|
||||
}
|
||||
return builder.String(), nil
|
||||
}
|
||||
|
||||
// Define registers the rename flow on the supplied Genkit instance.
|
||||
func Define(g *genkit.Genkit) *core.Flow[*RenameFlowInput, *Output, struct{}] {
|
||||
if g == nil {
|
||||
panic("genkit instance cannot be nil")
|
||||
}
|
||||
return genkit.DefineFlow(g, "renameFlow", flowFn)
|
||||
}
|
||||
|
||||
func flowFn(ctx context.Context, input *RenameFlowInput) (*Output, error) {
|
||||
if err := input.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prefix := slugify(input.UserPrompt)
|
||||
suggestions := make([]Suggestion, 0, len(input.FileNames))
|
||||
dirCounters := make(map[string]int)
|
||||
|
||||
for _, name := range input.FileNames {
|
||||
suggestion := deterministicSuggestion(name, prefix, dirCounters, input.SequenceSeparator)
|
||||
suggestions = append(suggestions, Suggestion{
|
||||
Original: name,
|
||||
Suggested: suggestion,
|
||||
})
|
||||
}
|
||||
|
||||
sort.SliceStable(suggestions, func(i, j int) bool {
|
||||
return suggestions[i].Original < suggestions[j].Original
|
||||
})
|
||||
|
||||
return &Output{Suggestions: suggestions}, nil
|
||||
}
|
||||
|
||||
func deterministicSuggestion(rel string, promptPrefix string, dirCounters map[string]int, separator string) string {
|
||||
rel = toSlash(rel)
|
||||
dir := path.Dir(rel)
|
||||
if dir == "." {
|
||||
dir = ""
|
||||
}
|
||||
|
||||
base := path.Base(rel)
|
||||
ext := path.Ext(base)
|
||||
name := strings.TrimSuffix(base, ext)
|
||||
|
||||
sanitizedName := slugify(name)
|
||||
|
||||
candidate := sanitizedName
|
||||
if promptPrefix != "" {
|
||||
switch {
|
||||
case candidate == "":
|
||||
candidate = promptPrefix
|
||||
default:
|
||||
candidate = fmt.Sprintf("%s-%s", promptPrefix, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
if candidate == "" {
|
||||
candidate = "renamed"
|
||||
}
|
||||
|
||||
counterKey := dir
|
||||
dirCounters[counterKey]++
|
||||
seq := dirCounters[counterKey]
|
||||
|
||||
sep := separator
|
||||
if sep == "" {
|
||||
sep = "."
|
||||
}
|
||||
numbered := fmt.Sprintf("%02d%s%s", seq, sep, candidate)
|
||||
proposed := numbered + ext
|
||||
if dir != "" {
|
||||
return path.Join(dir, proposed)
|
||||
}
|
||||
return proposed
|
||||
}
|
||||
|
||||
func slugify(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(value))
|
||||
lastHyphen := false
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
||||
b.WriteRune(unicode.ToLower(r))
|
||||
lastHyphen = false
|
||||
case r == ' ' || r == '-' || r == '_' || r == '.':
|
||||
if !lastHyphen && b.Len() > 0 {
|
||||
b.WriteRune('-')
|
||||
lastHyphen = true
|
||||
}
|
||||
}
|
||||
}
|
||||
result := strings.Trim(b.String(), "-")
|
||||
return result
|
||||
}
|
||||
|
||||
func toSlash(pathStr string) string {
|
||||
return strings.ReplaceAll(pathStr, "\\", "/")
|
||||
}
|
||||
|
||||
func firstDuplicate(values []string) string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, v := range values {
|
||||
lower := strings.ToLower(v)
|
||||
if _, exists := seen[lower]; exists {
|
||||
return v
|
||||
}
|
||||
seen[lower] = struct{}{}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
55
internal/ai/preview.go
Normal file
55
internal/ai/preview.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/rogeecn/renamer/internal/ai/flow"
|
||||
"github.com/rogeecn/renamer/internal/output"
|
||||
)
|
||||
|
||||
// PrintPreview renders suggestions in a tabular format alongside validation results.
|
||||
func PrintPreview(w io.Writer, suggestions []flow.Suggestion, validation ValidationResult) error {
|
||||
table := output.NewAIPlanTable()
|
||||
if err := table.Begin(w); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for idx, suggestion := range suggestions {
|
||||
if err := table.WriteRow(output.AIPlanRow{
|
||||
Sequence: fmt.Sprintf("%02d", idx+1),
|
||||
Original: suggestion.Original,
|
||||
Proposed: suggestion.Suggested,
|
||||
Sanitized: flowToKey(suggestion.Suggested),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := table.End(w); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, warn := range validation.Warnings {
|
||||
if _, err := fmt.Fprintf(w, "Warning: %s\n", warn); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(validation.Conflicts) > 0 {
|
||||
if _, err := fmt.Fprintln(w, "Conflicts detected:"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, conflict := range validation.Conflicts {
|
||||
if _, err := fmt.Fprintf(w, " - %s -> %s (%s)\n", conflict.Original, conflict.Suggested, conflict.Reason); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(w, "Previewed %d suggestion(s)\n", len(suggestions)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
54
internal/ai/runtime.go
Normal file
54
internal/ai/runtime.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/firebase/genkit/go/core"
|
||||
"github.com/firebase/genkit/go/genkit"
|
||||
"github.com/firebase/genkit/go/plugins/googlegenai"
|
||||
"google.golang.org/genai"
|
||||
|
||||
"github.com/rogeecn/renamer/internal/ai/flow"
|
||||
)
|
||||
|
||||
var (
|
||||
runtimeOnce sync.Once
|
||||
runtimeErr error
|
||||
runtimeFlow *core.Flow[*flow.RenameFlowInput, *flow.Output, struct{}]
|
||||
)
|
||||
|
||||
func ensureRuntime(creds Credentials) error {
|
||||
runtimeOnce.Do(func() {
|
||||
ctx := context.Background()
|
||||
geminiBase := os.Getenv("GOOGLE_GEMINI_BASE_URL")
|
||||
vertexBase := os.Getenv("GOOGLE_VERTEX_BASE_URL")
|
||||
if geminiBase != "" || vertexBase != "" {
|
||||
genai.SetDefaultBaseURLs(genai.BaseURLParameters{
|
||||
GeminiURL: geminiBase,
|
||||
VertexURL: vertexBase,
|
||||
})
|
||||
}
|
||||
|
||||
plugin := &googlegenai.GoogleAI{APIKey: creds.APIKey}
|
||||
|
||||
g := genkit.Init(ctx,
|
||||
genkit.WithPlugins(plugin),
|
||||
genkit.WithDefaultModel(defaultModelID),
|
||||
)
|
||||
|
||||
runtimeFlow = flow.Define(g)
|
||||
})
|
||||
return runtimeErr
|
||||
}
|
||||
|
||||
func runRenameFlow(ctx context.Context, input *flow.RenameFlowInput, creds Credentials) (*flow.Output, error) {
|
||||
if err := ensureRuntime(creds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if runtimeFlow == nil {
|
||||
return nil, runtimeErr
|
||||
}
|
||||
return runtimeFlow.Run(ctx, input)
|
||||
}
|
||||
138
internal/ai/session.go
Normal file
138
internal/ai/session.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
flowpkg "github.com/rogeecn/renamer/internal/ai/flow"
|
||||
)
|
||||
|
||||
const defaultModelID = "googleai/gemini-1.5-flash"
|
||||
|
||||
// Session tracks prompt history and guidance notes for a single AI preview loop.
|
||||
type Session struct {
|
||||
files []string
|
||||
client *Client
|
||||
prompts []string
|
||||
notes []string
|
||||
model string
|
||||
sequenceSeparator string
|
||||
|
||||
lastOutput *flowpkg.Output
|
||||
lastValidation ValidationResult
|
||||
}
|
||||
|
||||
// NewSession builds a session with the provided scope, initial prompt, and client.
|
||||
func NewSession(files []string, initialPrompt string, sequenceSeparator string, client *Client) *Session {
|
||||
prompts := []string{strings.TrimSpace(initialPrompt)}
|
||||
if prompts[0] == "" {
|
||||
prompts[0] = ""
|
||||
}
|
||||
|
||||
if client == nil {
|
||||
client = NewClient()
|
||||
}
|
||||
|
||||
sep := strings.TrimSpace(sequenceSeparator)
|
||||
if sep == "" {
|
||||
sep = "."
|
||||
}
|
||||
|
||||
return &Session{
|
||||
files: append([]string(nil), files...),
|
||||
client: client,
|
||||
prompts: prompts,
|
||||
notes: make([]string, 0),
|
||||
model: defaultModelID,
|
||||
sequenceSeparator: sep,
|
||||
}
|
||||
}
|
||||
|
||||
// Generate executes the flow and returns structured suggestions with validation.
|
||||
func (s *Session) Generate(ctx context.Context) (*flowpkg.Output, ValidationResult, error) {
|
||||
prompt := s.CurrentPrompt()
|
||||
input := &flowpkg.RenameFlowInput{
|
||||
FileNames: append([]string(nil), s.files...),
|
||||
UserPrompt: prompt,
|
||||
SequenceSeparator: s.sequenceSeparator,
|
||||
}
|
||||
|
||||
output, err := s.client.Suggest(ctx, input)
|
||||
if err != nil {
|
||||
return nil, ValidationResult{}, err
|
||||
}
|
||||
|
||||
validation := ValidateSuggestions(s.files, output.Suggestions)
|
||||
s.lastOutput = output
|
||||
s.lastValidation = validation
|
||||
return output, validation, nil
|
||||
}
|
||||
|
||||
// CurrentPrompt returns the most recent prompt in the session.
|
||||
func (s *Session) CurrentPrompt() string {
|
||||
if len(s.prompts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return s.prompts[len(s.prompts)-1]
|
||||
}
|
||||
|
||||
// UpdatePrompt records a new prompt and adds a note for auditing.
|
||||
func (s *Session) UpdatePrompt(prompt string) {
|
||||
trimmed := strings.TrimSpace(prompt)
|
||||
s.prompts = append(s.prompts, trimmed)
|
||||
s.notes = append(s.notes, "prompt updated")
|
||||
}
|
||||
|
||||
// RecordRegeneration appends an audit note for regenerations.
|
||||
func (s *Session) RecordRegeneration() {
|
||||
s.notes = append(s.notes, "regenerated suggestions")
|
||||
}
|
||||
|
||||
// RecordAcceptance stores an audit note for accepted previews.
|
||||
func (s *Session) RecordAcceptance() {
|
||||
s.notes = append(s.notes, "accepted preview")
|
||||
}
|
||||
|
||||
// PromptHistory returns a copy of the recorded prompts.
|
||||
func (s *Session) PromptHistory() []string {
|
||||
history := make([]string, len(s.prompts))
|
||||
copy(history, s.prompts)
|
||||
return history
|
||||
}
|
||||
|
||||
// Notes returns audit notes collected during the session.
|
||||
func (s *Session) Notes() []string {
|
||||
copied := make([]string, len(s.notes))
|
||||
copy(copied, s.notes)
|
||||
return copied
|
||||
}
|
||||
|
||||
// Files returns the original scoped filenames.
|
||||
func (s *Session) Files() []string {
|
||||
copied := make([]string, len(s.files))
|
||||
copy(copied, s.files)
|
||||
return copied
|
||||
}
|
||||
|
||||
// SequenceSeparator returns the configured sequence separator.
|
||||
func (s *Session) SequenceSeparator() string {
|
||||
return s.sequenceSeparator
|
||||
}
|
||||
|
||||
// LastOutput returns the most recent flow output.
|
||||
func (s *Session) LastOutput() *flowpkg.Output {
|
||||
return s.lastOutput
|
||||
}
|
||||
|
||||
// LastValidation returns the validation result for the most recent output.
|
||||
func (s *Session) LastValidation() ValidationResult {
|
||||
return s.lastValidation
|
||||
}
|
||||
|
||||
// Model returns the model identifier associated with the session.
|
||||
func (s *Session) Model() string {
|
||||
if s.model == "" {
|
||||
return defaultModelID
|
||||
}
|
||||
return s.model
|
||||
}
|
||||
169
internal/ai/validation.go
Normal file
169
internal/ai/validation.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/rogeecn/renamer/internal/ai/flow"
|
||||
)
|
||||
|
||||
var invalidCharacters = []rune{'/', '\\', ':', '*', '?', '"', '<', '>', '|'}
|
||||
|
||||
// Conflict captures a validation failure for a proposed rename.
|
||||
type Conflict struct {
|
||||
Original string
|
||||
Suggested string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ValidationResult aggregates conflicts and warnings.
|
||||
type ValidationResult struct {
|
||||
Conflicts []Conflict
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
// ValidateSuggestions enforces rename safety rules before applying suggestions.
|
||||
func ValidateSuggestions(expected []string, suggestions []flow.Suggestion) ValidationResult {
|
||||
result := ValidationResult{}
|
||||
|
||||
expectedSet := make(map[string]struct{}, len(expected))
|
||||
for _, name := range expected {
|
||||
expectedSet[strings.ToLower(flowToKey(name))] = struct{}{}
|
||||
}
|
||||
|
||||
seenTargets := make(map[string]string)
|
||||
|
||||
for _, suggestion := range suggestions {
|
||||
key := strings.ToLower(flowToKey(suggestion.Original))
|
||||
if _, ok := expectedSet[key]; !ok {
|
||||
result.Conflicts = append(result.Conflicts, Conflict{
|
||||
Original: suggestion.Original,
|
||||
Suggested: suggestion.Suggested,
|
||||
Reason: "original file not present in scope",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
cleaned := strings.TrimSpace(suggestion.Suggested)
|
||||
if cleaned == "" {
|
||||
result.Conflicts = append(result.Conflicts, Conflict{
|
||||
Original: suggestion.Original,
|
||||
Suggested: suggestion.Suggested,
|
||||
Reason: "suggested name is empty",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
normalizedOriginal := flowToKey(suggestion.Original)
|
||||
normalizedSuggested := flowToKey(cleaned)
|
||||
|
||||
if strings.HasPrefix(normalizedSuggested, "/") {
|
||||
result.Conflicts = append(result.Conflicts, Conflict{
|
||||
Original: suggestion.Original,
|
||||
Suggested: suggestion.Suggested,
|
||||
Reason: "suggested name must be relative",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if containsParentSegment(normalizedSuggested) {
|
||||
result.Conflicts = append(result.Conflicts, Conflict{
|
||||
Original: suggestion.Original,
|
||||
Suggested: suggestion.Suggested,
|
||||
Reason: "suggested name cannot traverse directories",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
base := path.Base(cleaned)
|
||||
if containsInvalidCharacter(base) {
|
||||
result.Conflicts = append(result.Conflicts, Conflict{
|
||||
Original: suggestion.Original,
|
||||
Suggested: suggestion.Suggested,
|
||||
Reason: "suggested name contains invalid characters",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if !extensionsMatch(suggestion.Original, cleaned) {
|
||||
result.Conflicts = append(result.Conflicts, Conflict{
|
||||
Original: suggestion.Original,
|
||||
Suggested: suggestion.Suggested,
|
||||
Reason: "file extension changed",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if path.Dir(normalizedOriginal) != path.Dir(normalizedSuggested) {
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("suggestion for %q moves file to a different directory", suggestion.Original))
|
||||
}
|
||||
|
||||
targetKey := strings.ToLower(normalizedSuggested)
|
||||
if existing, ok := seenTargets[targetKey]; ok && existing != suggestion.Original {
|
||||
result.Conflicts = append(result.Conflicts, Conflict{
|
||||
Original: suggestion.Original,
|
||||
Suggested: suggestion.Suggested,
|
||||
Reason: "duplicate target generated",
|
||||
})
|
||||
continue
|
||||
}
|
||||
seenTargets[targetKey] = suggestion.Original
|
||||
|
||||
if normalizedOriginal == normalizedSuggested {
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("suggestion for %q does not change the filename", suggestion.Original))
|
||||
}
|
||||
}
|
||||
|
||||
if len(suggestions) != len(expected) {
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("expected %d suggestions but received %d", len(expected), len(suggestions)))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func flowToKey(value string) string {
|
||||
return strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
|
||||
}
|
||||
|
||||
func containsInvalidCharacter(value string) bool {
|
||||
for _, ch := range invalidCharacters {
|
||||
if strings.ContainsRune(value, ch) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func extensionsMatch(original, proposed string) bool {
|
||||
origExt := strings.ToLower(path.Ext(original))
|
||||
propExt := strings.ToLower(path.Ext(proposed))
|
||||
return origExt == propExt
|
||||
}
|
||||
|
||||
// SummarizeConflicts renders a human-readable summary of conflicts.
|
||||
func SummarizeConflicts(conflicts []Conflict) string {
|
||||
if len(conflicts) == 0 {
|
||||
return ""
|
||||
}
|
||||
builder := strings.Builder{}
|
||||
for _, c := range conflicts {
|
||||
builder.WriteString(fmt.Sprintf("%s -> %s (%s); ", c.Original, c.Suggested, c.Reason))
|
||||
}
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
|
||||
// SummarizeWarnings renders warnings as a delimited string.
|
||||
func SummarizeWarnings(warnings []string) string {
|
||||
return strings.Join(warnings, "; ")
|
||||
}
|
||||
|
||||
func containsParentSegment(value string) bool {
|
||||
parts := strings.Split(value, "/")
|
||||
for _, part := range parts {
|
||||
if part == ".." {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
21
internal/history/ai_entry.go
Normal file
21
internal/history/ai_entry.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package history
|
||||
|
||||
// BuildAIMetadata constructs ledger metadata for AI-driven rename batches.
|
||||
func BuildAIMetadata(prompt string, promptHistory []string, notes []string, model string, warnings []string) map[string]any {
|
||||
data := map[string]any{
|
||||
"prompt": prompt,
|
||||
"model": model,
|
||||
"flow": "renameFlow",
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
if len(promptHistory) > 0 {
|
||||
data["promptHistory"] = append([]string(nil), promptHistory...)
|
||||
}
|
||||
|
||||
if len(notes) > 0 {
|
||||
data["notes"] = append([]string(nil), notes...)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
40
internal/output/progress.go
Normal file
40
internal/output/progress.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// ProgressReporter prints textual progress for rename operations.
|
||||
type ProgressReporter struct {
|
||||
writer io.Writer
|
||||
total int
|
||||
count int
|
||||
}
|
||||
|
||||
// NewProgressReporter constructs a reporter bound to the supplied writer.
|
||||
func NewProgressReporter(w io.Writer, total int) *ProgressReporter {
|
||||
if w == nil {
|
||||
w = io.Discard
|
||||
}
|
||||
return &ProgressReporter{writer: w, total: total}
|
||||
}
|
||||
|
||||
// Step registers a completed operation and prints the progress.
|
||||
func (r *ProgressReporter) Step(from, to string) error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
r.count++
|
||||
_, err := fmt.Fprintf(r.writer, "[%d/%d] %s -> %s\n", r.count, r.total, from, to)
|
||||
return err
|
||||
}
|
||||
|
||||
// Complete emits a summary line after all operations finish.
|
||||
func (r *ProgressReporter) Complete() error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := fmt.Fprintf(r.writer, "Completed %d rename(s).\n", r.count)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user