add ai feature
This commit is contained in:
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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user