add ai feature

This commit is contained in:
2025-11-05 16:06:09 +08:00
parent 42bc9aff42
commit 13ca7ddbed
33 changed files with 2194 additions and 30 deletions

3
internal/ai/flow/doc.go Normal file
View File

@@ -0,0 +1,3 @@
package flow
// Package flow hosts the Genkit rename flow implementation.

View 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
View 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
}

View 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"
}
]
}

View 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)
}
}

View 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 ""
}