mirror of
https://github.com/danielmiessler/fabric.git
synced 2026-09-10 07:36:44 -04:00
Merge pull request #2015 from ksylvan/kayvan/i18n-fixes-2026-02-19
Implement comprehensive i18n support across all plugins and tools
This commit is contained in:
commit
ab50a4a392
7
cmd/generate_changelog/incoming/2015.txt
Normal file
7
cmd/generate_changelog/incoming/2015.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
### PR [#2015](https://github.com/danielmiessler/Fabric/pull/2015) by [ksylvan](https://github.com/ksylvan): Implement comprehensive i18n support across all plugins and tools
|
||||
|
||||
- Implement comprehensive internationalization (i18n) support across all AI vendor plugins, tools, and template plugins.
|
||||
- Update localization files for multiple languages with new translation keys.
|
||||
- Replace hardcoded strings in Spotify and YouTube tools with localized equivalents.
|
||||
- Add unit tests for localized error handling in Ollama.
|
||||
- Standardize setup questions using the new i18n translation framework.
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -13,6 +13,7 @@ import (
|
|||
"github.com/anthropics/anthropic-sdk-go/option"
|
||||
"github.com/danielmiessler/fabric/internal/chat"
|
||||
"github.com/danielmiessler/fabric/internal/domain"
|
||||
"github.com/danielmiessler/fabric/internal/i18n"
|
||||
debuglog "github.com/danielmiessler/fabric/internal/log"
|
||||
"github.com/danielmiessler/fabric/internal/plugins"
|
||||
)
|
||||
|
|
@ -194,7 +195,7 @@ func (an *Client) SendStream(
|
|||
}
|
||||
|
||||
if stream.Err() != nil {
|
||||
fmt.Fprintf(os.Stderr, "Messages stream error: %v\n", stream.Err())
|
||||
fmt.Fprintf(os.Stderr, i18n.T("anthropic_stream_error"), stream.Err())
|
||||
}
|
||||
close(channel)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/danielmiessler/fabric/internal/i18n"
|
||||
"github.com/danielmiessler/fabric/internal/plugins"
|
||||
"github.com/danielmiessler/fabric/internal/plugins/ai/openai"
|
||||
openaiapi "github.com/openai/openai-go"
|
||||
|
|
@ -20,9 +21,9 @@ func NewClient() (ret *Client) {
|
|||
ret = &Client{}
|
||||
ret.Client = openai.NewClientCompatible("Azure", "", ret.configure)
|
||||
ret.ApiDeployments = ret.AddSetupQuestionCustom("deployments", true,
|
||||
"Enter your Azure deployments (comma separated)")
|
||||
i18n.T("azure_deployments_question"))
|
||||
ret.ApiVersion = ret.AddSetupQuestionCustom("API Version", false,
|
||||
"Enter the Azure API version (optional)")
|
||||
i18n.T("azure_api_version_question"))
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -42,12 +43,12 @@ func (oi *Client) configure() error {
|
|||
|
||||
apiKey := strings.TrimSpace(oi.ApiKey.Value)
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("Azure API key is required")
|
||||
return fmt.Errorf("%s", i18n.T("azure_api_key_required"))
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSpace(oi.ApiBaseURL.Value)
|
||||
if baseURL == "" {
|
||||
return fmt.Errorf("Azure API base URL is required")
|
||||
return fmt.Errorf("%s", i18n.T("azure_base_url_required"))
|
||||
}
|
||||
|
||||
apiVersion := strings.TrimSpace(oi.ApiVersion.Value)
|
||||
|
|
@ -101,7 +102,7 @@ func azureDeploymentMiddleware(req *http.Request, next option.MiddlewareNext) (*
|
|||
// Extract model/deployment name from request body
|
||||
deploymentName, err := extractDeploymentFromBody(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract deployment name: %w", err)
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("azure_failed_extract_deployment"), err)
|
||||
}
|
||||
|
||||
// Transform path: /chat/completions -> /deployments/{name}/chat/completions
|
||||
|
|
@ -117,7 +118,7 @@ func azureDeploymentMiddleware(req *http.Request, next option.MiddlewareNext) (*
|
|||
// and restores the body for subsequent use
|
||||
func extractDeploymentFromBody(req *http.Request) (string, error) {
|
||||
if req.Body == nil {
|
||||
return "", fmt.Errorf("request body is nil")
|
||||
return "", fmt.Errorf("%s", i18n.T("azure_request_body_nil"))
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(req.Body)
|
||||
|
|
@ -135,7 +136,7 @@ func extractDeploymentFromBody(req *http.Request) (string, error) {
|
|||
}
|
||||
|
||||
if payload.Model == "" {
|
||||
return "", fmt.Errorf("model field is empty or missing in request body")
|
||||
return "", fmt.Errorf("%s", i18n.T("azure_model_field_empty"))
|
||||
}
|
||||
|
||||
return payload.Model, nil
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
|
||||
"github.com/danielmiessler/fabric/internal/chat"
|
||||
"github.com/danielmiessler/fabric/internal/domain"
|
||||
"github.com/danielmiessler/fabric/internal/i18n"
|
||||
debuglog "github.com/danielmiessler/fabric/internal/log"
|
||||
"github.com/danielmiessler/fabric/internal/plugins"
|
||||
ollamaapi "github.com/ollama/ollama/api"
|
||||
|
|
@ -27,12 +28,12 @@ func NewClient() (ret *Client) {
|
|||
ret.PluginBase = plugins.NewVendorPluginBase(vendorName, ret.configure)
|
||||
|
||||
ret.ApiUrl = ret.AddSetupQuestionCustom("API URL", true,
|
||||
"Enter your Ollama URL (as a reminder, it is usually http://localhost:11434')")
|
||||
fmt.Sprintf(i18n.T("lmstudio_api_url_question"), vendorName, defaultBaseUrl))
|
||||
ret.ApiUrl.Value = defaultBaseUrl
|
||||
ret.ApiKey = ret.PluginBase.AddSetupQuestion("API key", false)
|
||||
ret.ApiKey.Value = ""
|
||||
ret.ApiHttpTimeout = ret.AddSetupQuestionCustom("HTTP Timeout", true,
|
||||
"Specify HTTP timeout duration for Ollama requests (e.g. 30s, 5m, 1h)")
|
||||
i18n.T("ollama_http_timeout_question"))
|
||||
ret.ApiHttpTimeout.Value = "20m"
|
||||
|
||||
return
|
||||
|
|
@ -67,7 +68,7 @@ func (o *Client) IsConfigured() bool {
|
|||
|
||||
func (o *Client) configure() (err error) {
|
||||
if o.apiUrl, err = url.Parse(o.ApiUrl.Value); err != nil {
|
||||
fmt.Printf("cannot parse URL: %s: %v\n", o.ApiUrl.Value, err)
|
||||
fmt.Printf("%s\n", fmt.Sprintf(i18n.T("ollama_cannot_parse_url"), o.ApiUrl.Value, err))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -78,7 +79,7 @@ func (o *Client) configure() (err error) {
|
|||
if err == nil && o.ApiHttpTimeout.Value != "" {
|
||||
timeout = parsed
|
||||
} else if o.ApiHttpTimeout.Value != "" {
|
||||
fmt.Printf("Invalid HTTP timeout format (%q), using default (20m): %v\n", o.ApiHttpTimeout.Value, err)
|
||||
fmt.Printf("%s\n", fmt.Sprintf(i18n.T("ollama_invalid_http_timeout_using_default"), o.ApiHttpTimeout.Value, err))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -152,7 +153,7 @@ func (o *Client) Send(ctx context.Context, msgs []*chat.ChatCompletionMessage, o
|
|||
}
|
||||
|
||||
if err = o.client.Chat(ctx, &req, respFunc); err != nil {
|
||||
debuglog.Debug(debuglog.Basic, "Ollama chat request failed: %v\n", err)
|
||||
debuglog.Debug(debuglog.Basic, "%s\n", fmt.Sprintf(i18n.T("ollama_chat_request_failed"), err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -225,11 +226,11 @@ func (o *Client) loadImageBytes(ctx context.Context, imageURL string) (ret []byt
|
|||
if strings.HasPrefix(imageURL, "data:") {
|
||||
parts := strings.SplitN(imageURL, ",", 2)
|
||||
if len(parts) != 2 {
|
||||
err = fmt.Errorf("invalid data URL format")
|
||||
err = fmt.Errorf("%s", i18n.T("ollama_invalid_data_url_format"))
|
||||
return
|
||||
}
|
||||
if ret, err = base64.StdEncoding.DecodeString(parts[1]); err != nil {
|
||||
err = fmt.Errorf("failed to decode data URL: %w", err)
|
||||
err = fmt.Errorf("%s", fmt.Sprintf(i18n.T("ollama_failed_decode_data_url"), err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -247,7 +248,7 @@ func (o *Client) loadImageBytes(ctx context.Context, imageURL string) (ret []byt
|
|||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
err = fmt.Errorf("failed to fetch image %s: %s", imageURL, resp.Status)
|
||||
err = fmt.Errorf("%s", fmt.Sprintf(i18n.T("ollama_failed_fetch_image"), imageURL, resp.Status))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
62
internal/plugins/ai/ollama/ollama_test.go
Normal file
62
internal/plugins/ai/ollama/ollama_test.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package ollama
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/danielmiessler/fabric/internal/i18n"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoadImageBytes_DataURLValidationErrorsAreLocalized(t *testing.T) {
|
||||
_, err := i18n.Init("en")
|
||||
require.NoError(t, err)
|
||||
|
||||
client := &Client{}
|
||||
|
||||
_, err = client.loadImageBytes(context.Background(), "data:image/png;base64")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, i18n.T("ollama_invalid_data_url_format"), err.Error())
|
||||
|
||||
_, err = client.loadImageBytes(context.Background(), "data:image/png;base64,%%%%")
|
||||
require.Error(t, err)
|
||||
assert.True(t, strings.HasPrefix(err.Error(), strings.Split(i18n.T("ollama_failed_decode_data_url"), "%v")[0]))
|
||||
}
|
||||
|
||||
func TestLoadImageBytes_HTTPFetchErrorIsLocalized(t *testing.T) {
|
||||
_, err := i18n.Init("en")
|
||||
require.NoError(t, err)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client := &Client{httpClient: server.Client()}
|
||||
|
||||
_, err = client.loadImageBytes(context.Background(), server.URL+"/image.png")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t,
|
||||
fmt.Sprintf(i18n.T("ollama_failed_fetch_image"), server.URL+"/image.png", "500 Internal Server Error"),
|
||||
err.Error(),
|
||||
)
|
||||
}
|
||||
|
||||
func TestLoadImageBytes_DataURLSuccess(t *testing.T) {
|
||||
_, err := i18n.Init("en")
|
||||
require.NoError(t, err)
|
||||
|
||||
client := &Client{}
|
||||
expected := []byte("hello world")
|
||||
dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(expected)
|
||||
|
||||
got, err := client.loadImageBytes(context.Background(), dataURL)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expected, got)
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/danielmiessler/fabric/internal/i18n"
|
||||
debuglog "github.com/danielmiessler/fabric/internal/log"
|
||||
|
||||
openai "github.com/openai/openai-go"
|
||||
|
|
@ -53,12 +54,12 @@ func (o *Client) TranscribeFile(ctx context.Context, filePath, model string, spl
|
|||
}
|
||||
|
||||
if !slices.Contains(AllowedTranscriptionModels, model) {
|
||||
return "", fmt.Errorf("model '%s' is not supported for transcription", model)
|
||||
return "", fmt.Errorf("%s", fmt.Sprintf(i18n.T("openai_audio_model_not_supported_for_transcription"), model))
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
if _, ok := allowedAudioExtensions[ext]; !ok {
|
||||
return "", fmt.Errorf("unsupported audio format '%s'", ext)
|
||||
return "", fmt.Errorf("%s", fmt.Sprintf(i18n.T("openai_audio_unsupported_audio_format"), ext))
|
||||
}
|
||||
|
||||
info, err := os.Stat(filePath)
|
||||
|
|
@ -70,9 +71,9 @@ func (o *Client) TranscribeFile(ctx context.Context, filePath, model string, spl
|
|||
var cleanup func()
|
||||
if info.Size() > MaxAudioFileSize {
|
||||
if !split {
|
||||
return "", fmt.Errorf("file %s exceeds 25MB limit; use --split-media-file to enable automatic splitting", filePath)
|
||||
return "", fmt.Errorf("%s", fmt.Sprintf(i18n.T("openai_audio_file_exceeds_limit_enable_split"), filePath))
|
||||
}
|
||||
debuglog.Log("File %s is larger than the size limit... breaking it up into chunks...\n", filePath)
|
||||
debuglog.Log("%s\n", fmt.Sprintf(i18n.T("openai_audio_file_exceeds_limit_splitting"), filePath))
|
||||
if files, cleanup, err = splitAudioFile(filePath, ext, MaxAudioFileSize); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -88,7 +89,7 @@ func (o *Client) TranscribeFile(ctx context.Context, filePath, model string, spl
|
|||
wg.Add(1)
|
||||
go func(index int, filePath string) {
|
||||
defer wg.Done()
|
||||
debuglog.Log("Using model %s to transcribe part %d (file name: %s)...\n", model, index+1, filePath)
|
||||
debuglog.Log("%s\n", fmt.Sprintf(i18n.T("openai_audio_using_model_to_transcribe_part"), model, index+1, filePath))
|
||||
|
||||
chunk, openErr := os.Open(filePath)
|
||||
if openErr != nil {
|
||||
|
|
@ -140,7 +141,7 @@ func (o *Client) TranscribeFile(ctx context.Context, filePath, model string, spl
|
|||
// It returns the list of chunk file paths and a cleanup function.
|
||||
func splitAudioFile(src, ext string, maxSize int64) (files []string, cleanup func(), err error) {
|
||||
if _, err = exec.LookPath("ffmpeg"); err != nil {
|
||||
return nil, nil, fmt.Errorf("ffmpeg not found: please install it")
|
||||
return nil, nil, fmt.Errorf("%s", i18n.T("openai_audio_ffmpeg_not_found_install"))
|
||||
}
|
||||
|
||||
var dir string
|
||||
|
|
@ -152,12 +153,12 @@ func splitAudioFile(src, ext string, maxSize int64) (files []string, cleanup fun
|
|||
segmentTime := 600 // start with 10 minutes
|
||||
for {
|
||||
pattern := filepath.Join(dir, "chunk-%03d"+ext)
|
||||
debuglog.Log("Running ffmpeg to split audio into %d-second chunks...\n", segmentTime)
|
||||
debuglog.Log("%s\n", fmt.Sprintf(i18n.T("openai_audio_running_ffmpeg_split_chunks"), segmentTime))
|
||||
cmd := exec.Command("ffmpeg", "-y", "-i", src, "-f", "segment", "-segment_time", fmt.Sprintf("%d", segmentTime), "-c", "copy", pattern)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err = cmd.Run(); err != nil {
|
||||
return nil, cleanup, fmt.Errorf("ffmpeg failed: %v: %s", err, stderr.String())
|
||||
return nil, cleanup, fmt.Errorf("%s", fmt.Sprintf(i18n.T("openai_audio_ffmpeg_failed"), err, stderr.String()))
|
||||
}
|
||||
|
||||
if files, err = filepath.Glob(filepath.Join(dir, "chunk-*"+ext)); err != nil {
|
||||
|
|
@ -183,7 +184,7 @@ func splitAudioFile(src, ext string, maxSize int64) (files []string, cleanup fun
|
|||
_ = os.Remove(f)
|
||||
}
|
||||
if segmentTime <= 1 {
|
||||
return nil, cleanup, fmt.Errorf("unable to split file into acceptable size chunks")
|
||||
return nil, cleanup, fmt.Errorf("%s", i18n.T("openai_audio_unable_to_split_acceptable_size_chunks"))
|
||||
}
|
||||
segmentTime /= 2
|
||||
}
|
||||
|
|
|
|||
64
internal/plugins/ai/openai/openai_audio_test.go
Normal file
64
internal/plugins/ai/openai/openai_audio_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/danielmiessler/fabric/internal/i18n"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTranscribeFile_ValidationErrorsAreLocalized(t *testing.T) {
|
||||
_, err := i18n.Init("en")
|
||||
require.NoError(t, err)
|
||||
|
||||
client := &Client{}
|
||||
|
||||
audioFile, err := os.CreateTemp("", "transcribe-valid-*.mp3")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, audioFile.Close())
|
||||
t.Cleanup(func() { _ = os.Remove(audioFile.Name()) })
|
||||
|
||||
_, err = client.TranscribeFile(context.Background(), audioFile.Name(), "not-a-model", false)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t,
|
||||
fmt.Sprintf(i18n.T("openai_audio_model_not_supported_for_transcription"), "not-a-model"),
|
||||
err.Error(),
|
||||
)
|
||||
|
||||
unsupportedFile, err := os.CreateTemp("", "transcribe-invalid-*.txt")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, unsupportedFile.Close())
|
||||
t.Cleanup(func() { _ = os.Remove(unsupportedFile.Name()) })
|
||||
|
||||
_, err = client.TranscribeFile(context.Background(), unsupportedFile.Name(), AllowedTranscriptionModels[0], false)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t,
|
||||
fmt.Sprintf(i18n.T("openai_audio_unsupported_audio_format"), filepath.Ext(unsupportedFile.Name())),
|
||||
err.Error(),
|
||||
)
|
||||
}
|
||||
|
||||
func TestTranscribeFile_FileSizeLimitErrorIsLocalized(t *testing.T) {
|
||||
_, err := i18n.Init("en")
|
||||
require.NoError(t, err)
|
||||
|
||||
client := &Client{}
|
||||
largeFile, err := os.CreateTemp("", "transcribe-large-*.mp3")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = os.Remove(largeFile.Name()) })
|
||||
|
||||
require.NoError(t, largeFile.Truncate(MaxAudioFileSize+1))
|
||||
require.NoError(t, largeFile.Close())
|
||||
|
||||
_, err = client.TranscribeFile(context.Background(), largeFile.Name(), AllowedTranscriptionModels[0], false)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t,
|
||||
fmt.Sprintf(i18n.T("openai_audio_file_exceeds_limit_enable_split"), largeFile.Name()),
|
||||
err.Error(),
|
||||
)
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/danielmiessler/fabric/internal/domain"
|
||||
"github.com/danielmiessler/fabric/internal/i18n"
|
||||
"github.com/openai/openai-go/packages/param"
|
||||
"github.com/openai/openai-go/responses"
|
||||
)
|
||||
|
|
@ -115,23 +116,23 @@ func (o *Client) extractAndSaveImages(resp *responses.Response, opts *domain.Cha
|
|||
// Decode base64 image data
|
||||
imageData, err := base64.StdEncoding.DecodeString(imageCall.Result)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode image data: %w", err)
|
||||
return fmt.Errorf("%s", fmt.Sprintf(i18n.T("openai_image_failed_to_decode_image_data"), err))
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(opts.ImageFile)
|
||||
if dir != "." {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
||||
return fmt.Errorf("%s", fmt.Sprintf(i18n.T("openai_image_failed_to_create_directory"), dir, err))
|
||||
}
|
||||
}
|
||||
|
||||
// Save image to file
|
||||
if err := os.WriteFile(opts.ImageFile, imageData, 0644); err != nil {
|
||||
return fmt.Errorf("failed to save image to %s: %w", opts.ImageFile, err)
|
||||
return fmt.Errorf("%s", fmt.Sprintf(i18n.T("openai_image_failed_to_save_image"), opts.ImageFile, err))
|
||||
}
|
||||
|
||||
fmt.Printf("Image saved to: %s\n", opts.ImageFile)
|
||||
fmt.Printf("%s\n", fmt.Sprintf(i18n.T("openai_image_saved_to"), opts.ImageFile))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ import (
|
|||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/danielmiessler/fabric/internal/chat"
|
||||
"github.com/danielmiessler/fabric/internal/domain"
|
||||
"github.com/danielmiessler/fabric/internal/i18n"
|
||||
debuglog "github.com/danielmiessler/fabric/internal/log"
|
||||
"github.com/danielmiessler/fabric/internal/plugins"
|
||||
perplexity "github.com/sgaunet/perplexity-go/v2"
|
||||
|
||||
"github.com/danielmiessler/fabric/internal/chat"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -46,7 +46,7 @@ func (c *Client) Configure() error {
|
|||
if apiKeyFromEnv != "" {
|
||||
c.APIKey.Value = apiKeyFromEnv
|
||||
} else {
|
||||
return fmt.Errorf("%s API key not configured. Please set the %s environment variable or run 'fabric --setup %s'", providerName, envKey, providerName)
|
||||
return fmt.Errorf(i18n.T("perplexity_api_key_not_configured"), providerName, envKey, providerName)
|
||||
}
|
||||
}
|
||||
c.client = perplexity.NewClient(c.APIKey.Value)
|
||||
|
|
@ -62,7 +62,7 @@ func (c *Client) ListModels() ([]string, error) {
|
|||
func (c *Client) Send(ctx context.Context, msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions) (string, error) {
|
||||
if c.client == nil {
|
||||
if err := c.Configure(); err != nil {
|
||||
return "", fmt.Errorf("failed to configure Perplexity client: %w", err)
|
||||
return "", fmt.Errorf(i18n.T("perplexity_failed_configure"), err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -101,7 +101,7 @@ func (c *Client) Send(ctx context.Context, msgs []*chat.ChatCompletionMessage, o
|
|||
// Corrected: Use SendCompletionRequest method from perplexity-go library
|
||||
resp, err := c.client.SendCompletionRequest(request) // Pass request directly
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("perplexity API request failed: %w", err) // Corrected capitalization
|
||||
return "", fmt.Errorf(i18n.T("perplexity_api_request_failed"), err)
|
||||
}
|
||||
|
||||
var content strings.Builder
|
||||
|
|
@ -110,7 +110,7 @@ func (c *Client) Send(ctx context.Context, msgs []*chat.ChatCompletionMessage, o
|
|||
// Append citations if available
|
||||
citations := resp.GetCitations()
|
||||
if len(citations) > 0 {
|
||||
content.WriteString("\n\n# CITATIONS\n\n")
|
||||
content.WriteString(i18n.T("perplexity_citations_header"))
|
||||
for i, citation := range citations {
|
||||
content.WriteString(fmt.Sprintf("- [%d] %s\n", i+1, citation))
|
||||
}
|
||||
|
|
@ -123,7 +123,7 @@ func (c *Client) SendStream(msgs []*chat.ChatCompletionMessage, opts *domain.Cha
|
|||
if c.client == nil {
|
||||
if err := c.Configure(); err != nil {
|
||||
close(channel) // Ensure channel is closed on error
|
||||
return fmt.Errorf("failed to configure Perplexity client: %w", err)
|
||||
return fmt.Errorf(i18n.T("perplexity_failed_configure"), err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -170,7 +170,7 @@ func (c *Client) SendStream(msgs []*chat.ChatCompletionMessage, opts *domain.Cha
|
|||
if err != nil {
|
||||
// Log error, can't send to string channel directly.
|
||||
// Consider a mechanism to propagate this error if needed.
|
||||
debuglog.Log("perplexity streaming error: %v\n", err)
|
||||
debuglog.Log(i18n.T("perplexity_streaming_error"), err)
|
||||
// If the error occurs during stream setup, the channel might not have been closed by the receiver loop.
|
||||
// However, closing it here might cause a panic if the receiver loop also tries to close it.
|
||||
// close(channel) // Caution: Uncommenting this may cause panic, as channel is closed in the receiver goroutine.
|
||||
|
|
@ -216,7 +216,7 @@ func (c *Client) SendStream(msgs []*chat.ChatCompletionMessage, opts *domain.Cha
|
|||
citations := lastResponse.GetCitations()
|
||||
if len(citations) > 0 {
|
||||
var citationsText strings.Builder
|
||||
citationsText.WriteString("\n\n# CITATIONS\n\n")
|
||||
citationsText.WriteString(i18n.T("perplexity_citations_header"))
|
||||
for i, citation := range citations {
|
||||
citationsText.WriteString(fmt.Sprintf("- [%d] %s\n", i+1, citation))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/danielmiessler/fabric/internal/i18n"
|
||||
"github.com/danielmiessler/fabric/internal/plugins"
|
||||
)
|
||||
|
||||
|
|
@ -74,8 +75,7 @@ func (o *VendorsManager) FindByName(name string) Vendor {
|
|||
|
||||
func (o *VendorsManager) readModels() (err error) {
|
||||
if len(o.Vendors) == 0 {
|
||||
|
||||
err = fmt.Errorf("no AI vendors configured to read models from. Please configure at least one AI vendor")
|
||||
err = fmt.Errorf("%s", i18n.T("vendors_no_ai_vendors_configured_read_models"))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -138,7 +138,7 @@ func (o *VendorsManager) Setup() (ret map[string]Vendor, err error) {
|
|||
func (o *VendorsManager) SetupVendor(vendorName string, configuredVendors map[string]Vendor) (err error) {
|
||||
vendor := o.FindByName(vendorName)
|
||||
if vendor == nil {
|
||||
err = fmt.Errorf("vendor %s not found", vendorName)
|
||||
err = fmt.Errorf("%s", fmt.Sprintf(i18n.T("vendor_not_found"), vendorName))
|
||||
return
|
||||
}
|
||||
o.setupVendorTo(vendor, configuredVendors)
|
||||
|
|
@ -147,11 +147,11 @@ func (o *VendorsManager) SetupVendor(vendorName string, configuredVendors map[st
|
|||
|
||||
func (o *VendorsManager) setupVendorTo(vendor Vendor, configuredVendors map[string]Vendor) {
|
||||
if vendorErr := vendor.Setup(); vendorErr == nil {
|
||||
fmt.Printf("[%v] configured\n", vendor.GetName())
|
||||
fmt.Printf("%s\n", fmt.Sprintf(i18n.T("plugin_setup_configured"), vendor.GetName()))
|
||||
configuredVendors[strings.ToLower(vendor.GetName())] = vendor
|
||||
} else {
|
||||
delete(configuredVendors, strings.ToLower(vendor.GetName()))
|
||||
fmt.Printf("[%v] skipped\n", vendor.GetName())
|
||||
fmt.Printf("%s", fmt.Sprintf(i18n.T("plugin_setup_skipped"), vendor.GetName()))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import (
|
|||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/danielmiessler/fabric/internal/i18n"
|
||||
)
|
||||
|
||||
// DateTimePlugin handles time and date operations
|
||||
|
|
@ -94,7 +96,7 @@ func (p *DateTimePlugin) Apply(operation string, value string) (string, error) {
|
|||
return p.handleRelative(now, value)
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("datetime: unknown operation %q (see plugin documentation for supported operations)", operation)
|
||||
return "", fmt.Errorf(i18n.T("template_datetime_error_unknown_operation"), operation)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -102,7 +104,7 @@ func (p *DateTimePlugin) handleRelative(now time.Time, value string) (string, er
|
|||
debugf("DateTime: handling relative time value=%q", value)
|
||||
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("datetime: relative time requires a value (e.g., -1h, -1d, -1w)")
|
||||
return "", fmt.Errorf("%s", i18n.T("template_datetime_error_relative_requires_value"))
|
||||
}
|
||||
|
||||
// Try standard duration first (hours, minutes)
|
||||
|
|
@ -114,7 +116,7 @@ func (p *DateTimePlugin) handleRelative(now time.Time, value string) (string, er
|
|||
|
||||
// Handle date units
|
||||
if len(value) < 2 {
|
||||
return "", fmt.Errorf("datetime: invalid relative format (use: -1h, 2d, -3w, 1m, -1y)")
|
||||
return "", fmt.Errorf("%s", i18n.T("template_datetime_error_invalid_relative_format"))
|
||||
}
|
||||
|
||||
unit := value[len(value)-1:]
|
||||
|
|
@ -122,7 +124,7 @@ func (p *DateTimePlugin) handleRelative(now time.Time, value string) (string, er
|
|||
|
||||
num, err := strconv.Atoi(numStr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("datetime: invalid number in relative time: %q", value)
|
||||
return "", fmt.Errorf(i18n.T("template_datetime_error_invalid_number"), value)
|
||||
}
|
||||
|
||||
var result string
|
||||
|
|
@ -136,7 +138,7 @@ func (p *DateTimePlugin) handleRelative(now time.Time, value string) (string, er
|
|||
case "y":
|
||||
result = now.AddDate(num, 0, 0).Format("2006-01-02")
|
||||
default:
|
||||
return "", fmt.Errorf("datetime: invalid unit %q (use: h,m for time or d,w,m,y for date)", unit)
|
||||
return "", fmt.Errorf(i18n.T("template_datetime_error_invalid_unit"), unit)
|
||||
}
|
||||
|
||||
debugf("DateTime: relative unit=%q num=%d result=%q", unit, num, result)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import (
|
|||
"os"
|
||||
"os/user"
|
||||
"runtime"
|
||||
|
||||
"github.com/danielmiessler/fabric/internal/i18n"
|
||||
)
|
||||
|
||||
// SysPlugin provides access to system-level information.
|
||||
|
|
@ -29,7 +31,7 @@ func (p *SysPlugin) Apply(operation string, value string) (string, error) {
|
|||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
debugf("Sys: hostname error: %v", err)
|
||||
return "", fmt.Errorf("sys: hostname error: %v", err)
|
||||
return "", fmt.Errorf(i18n.T("template_sys_error_hostname"), err)
|
||||
}
|
||||
debugf("Sys: hostname=%q", hostname)
|
||||
return hostname, nil
|
||||
|
|
@ -38,7 +40,7 @@ func (p *SysPlugin) Apply(operation string, value string) (string, error) {
|
|||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
debugf("Sys: user error: %v", err)
|
||||
return "", fmt.Errorf("sys: user error: %v", err)
|
||||
return "", fmt.Errorf(i18n.T("template_sys_error_user"), err)
|
||||
}
|
||||
debugf("Sys: user=%q", currentUser.Username)
|
||||
return currentUser.Username, nil
|
||||
|
|
@ -56,7 +58,7 @@ func (p *SysPlugin) Apply(operation string, value string) (string, error) {
|
|||
case "env":
|
||||
if value == "" {
|
||||
debugf("Sys: env error: missing variable name")
|
||||
return "", fmt.Errorf("sys: env operation requires a variable name")
|
||||
return "", fmt.Errorf("%s", i18n.T("template_sys_error_env_requires_var"))
|
||||
}
|
||||
result := os.Getenv(value)
|
||||
debugf("Sys: env %q=%q", value, result)
|
||||
|
|
@ -66,7 +68,7 @@ func (p *SysPlugin) Apply(operation string, value string) (string, error) {
|
|||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
debugf("Sys: pwd error: %v", err)
|
||||
return "", fmt.Errorf("sys: pwd error: %v", err)
|
||||
return "", fmt.Errorf(i18n.T("template_sys_error_pwd"), err)
|
||||
}
|
||||
debugf("Sys: pwd=%q", dir)
|
||||
return dir, nil
|
||||
|
|
@ -75,13 +77,13 @@ func (p *SysPlugin) Apply(operation string, value string) (string, error) {
|
|||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
debugf("Sys: home error: %v", err)
|
||||
return "", fmt.Errorf("sys: home error: %v", err)
|
||||
return "", fmt.Errorf(i18n.T("template_sys_error_home"), err)
|
||||
}
|
||||
debugf("Sys: home=%q", homeDir)
|
||||
return homeDir, nil
|
||||
|
||||
default:
|
||||
debugf("Sys: unknown operation %q", operation)
|
||||
return "", fmt.Errorf("sys: unknown operation %q (supported: hostname, user, os, arch, env, pwd, home)", operation)
|
||||
return "", fmt.Errorf(i18n.T("template_sys_error_unknown_operation"), operation)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ func (jc *Client) ScrapeQuestion(question string) (ret string, err error) {
|
|||
func (jc *Client) request(requestURL string) (ret string, err error) {
|
||||
var req *http.Request
|
||||
if req, err = http.NewRequest("GET", requestURL, nil); err != nil {
|
||||
err = fmt.Errorf("error creating request: %w", err)
|
||||
err = fmt.Errorf("%s", fmt.Sprintf(i18n.T("jina_error_creating_request"), err))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -57,14 +57,14 @@ func (jc *Client) request(requestURL string) (ret string, err error) {
|
|||
client := &http.Client{}
|
||||
var resp *http.Response
|
||||
if resp, err = client.Do(req); err != nil {
|
||||
err = fmt.Errorf("error sending request: %w", err)
|
||||
err = fmt.Errorf("%s", fmt.Sprintf(i18n.T("jina_error_sending_request"), err))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var body []byte
|
||||
if body, err = io.ReadAll(resp.Body); err != nil {
|
||||
err = fmt.Errorf("error reading response body: %w", err)
|
||||
err = fmt.Errorf("%s", fmt.Sprintf(i18n.T("jina_error_reading_response_body"), err))
|
||||
return
|
||||
}
|
||||
ret = string(body)
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ func NewSpotify() *Spotify {
|
|||
EnvNamePrefix: plugins.BuildEnvVariablePrefix(label),
|
||||
}
|
||||
|
||||
ret.ClientId = ret.AddSetupQuestion("Client ID", false)
|
||||
ret.ClientSecret = ret.AddSetupQuestion("Client Secret", false)
|
||||
ret.ClientId = ret.AddSetupQuestionWithEnvName("Client ID", false, i18n.T("spotify_client_id_question"))
|
||||
ret.ClientSecret = ret.AddSetupQuestionWithEnvName("Client Secret", false, i18n.T("spotify_client_secret_question"))
|
||||
|
||||
return ret
|
||||
}
|
||||
|
|
@ -475,48 +475,48 @@ func (s *Spotify) FormatMetadataAsText(metadata any) string {
|
|||
|
||||
switch m := metadata.(type) {
|
||||
case *ShowMetadata:
|
||||
sb.WriteString("# Spotify Podcast/Show\n\n")
|
||||
sb.WriteString(fmt.Sprintf("**Title**: %s\n", m.Name))
|
||||
sb.WriteString(fmt.Sprintf("**Publisher**: %s\n", m.Publisher))
|
||||
sb.WriteString(fmt.Sprintf("**Total Episodes**: %d\n", m.TotalEpisodes))
|
||||
sb.WriteString(i18n.T("spotify_show_header") + "\n\n")
|
||||
sb.WriteString(fmt.Sprintf(i18n.T("spotify_title_label")+"\n", m.Name))
|
||||
sb.WriteString(fmt.Sprintf(i18n.T("spotify_publisher_label")+"\n", m.Publisher))
|
||||
sb.WriteString(fmt.Sprintf(i18n.T("spotify_total_episodes_label")+"\n", m.TotalEpisodes))
|
||||
if len(m.Languages) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("**Languages**: %s\n", strings.Join(m.Languages, ", ")))
|
||||
sb.WriteString(fmt.Sprintf(i18n.T("spotify_languages_label")+"\n", strings.Join(m.Languages, ", ")))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("**Media Type**: %s\n", m.MediaType))
|
||||
sb.WriteString(fmt.Sprintf("**URL**: %s\n\n", m.ExternalURL))
|
||||
sb.WriteString("## Description\n\n")
|
||||
sb.WriteString(fmt.Sprintf(i18n.T("spotify_media_type_label")+"\n", m.MediaType))
|
||||
sb.WriteString(fmt.Sprintf(i18n.T("spotify_url_label")+"\n\n", m.ExternalURL))
|
||||
sb.WriteString(i18n.T("spotify_description_header") + "\n\n")
|
||||
sb.WriteString(m.Description)
|
||||
sb.WriteString("\n")
|
||||
|
||||
case *EpisodeMetadata:
|
||||
sb.WriteString("# Spotify Episode\n\n")
|
||||
sb.WriteString(fmt.Sprintf("**Title**: %s\n", m.Name))
|
||||
sb.WriteString(fmt.Sprintf("**Show**: %s\n", m.ShowName))
|
||||
sb.WriteString(fmt.Sprintf("**Release Date**: %s\n", m.ReleaseDate))
|
||||
sb.WriteString(fmt.Sprintf("**Duration**: %d minutes\n", m.DurationMinutes))
|
||||
sb.WriteString(fmt.Sprintf("**Language**: %s\n", m.Language))
|
||||
sb.WriteString(fmt.Sprintf("**Explicit**: %v\n", m.Explicit))
|
||||
sb.WriteString(fmt.Sprintf("**URL**: %s\n", m.ExternalURL))
|
||||
sb.WriteString(i18n.T("spotify_episode_header") + "\n\n")
|
||||
sb.WriteString(fmt.Sprintf(i18n.T("spotify_title_label")+"\n", m.Name))
|
||||
sb.WriteString(fmt.Sprintf(i18n.T("spotify_show_name_label")+"\n", m.ShowName))
|
||||
sb.WriteString(fmt.Sprintf(i18n.T("spotify_release_date_label")+"\n", m.ReleaseDate))
|
||||
sb.WriteString(fmt.Sprintf(i18n.T("spotify_duration_label")+"\n", m.DurationMinutes))
|
||||
sb.WriteString(fmt.Sprintf(i18n.T("spotify_language_field_label")+"\n", m.Language))
|
||||
sb.WriteString(fmt.Sprintf(i18n.T("spotify_explicit_label")+"\n", m.Explicit))
|
||||
sb.WriteString(fmt.Sprintf(i18n.T("spotify_url_label")+"\n", m.ExternalURL))
|
||||
if m.AudioPreviewURL != "" {
|
||||
sb.WriteString(fmt.Sprintf("**Audio Preview**: %s\n", m.AudioPreviewURL))
|
||||
sb.WriteString(fmt.Sprintf(i18n.T("spotify_audio_preview_label")+"\n", m.AudioPreviewURL))
|
||||
}
|
||||
sb.WriteString("\n## Description\n\n")
|
||||
sb.WriteString("\n" + i18n.T("spotify_description_header") + "\n\n")
|
||||
sb.WriteString(m.Description)
|
||||
sb.WriteString("\n")
|
||||
|
||||
case *SearchResult:
|
||||
sb.WriteString("# Spotify Search Results\n\n")
|
||||
sb.WriteString(i18n.T("spotify_search_results_header") + "\n\n")
|
||||
for i, show := range m.Shows {
|
||||
sb.WriteString(fmt.Sprintf("## %d. %s\n", i+1, show.Name))
|
||||
sb.WriteString(fmt.Sprintf("- **Publisher**: %s\n", show.Publisher))
|
||||
sb.WriteString(fmt.Sprintf("- **Episodes**: %d\n", show.TotalEpisodes))
|
||||
sb.WriteString(fmt.Sprintf("- **URL**: %s\n", show.ExternalURL))
|
||||
sb.WriteString(fmt.Sprintf(i18n.T("spotify_search_publisher_label")+"\n", show.Publisher))
|
||||
sb.WriteString(fmt.Sprintf(i18n.T("spotify_search_episodes_label")+"\n", show.TotalEpisodes))
|
||||
sb.WriteString(fmt.Sprintf(i18n.T("spotify_search_url_label")+"\n", show.ExternalURL))
|
||||
// Truncate description for search results
|
||||
desc := show.Description
|
||||
if len(desc) > 200 {
|
||||
desc = desc[:200] + "..."
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- **Description**: %s\n\n", desc))
|
||||
sb.WriteString(fmt.Sprintf(i18n.T("spotify_search_description_label")+"\n\n", desc))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -506,7 +506,7 @@ func (o *YouTube) GrabComments(videoId string) (ret []string, err error) {
|
|||
call := o.service.CommentThreads.List([]string{"snippet", "replies"}).VideoId(videoId).TextFormat("plainText").MaxResults(100)
|
||||
var response *youtube.CommentThreadListResponse
|
||||
if response, err = call.Do(); err != nil {
|
||||
log.Printf("Failed to fetch comments: %v", err)
|
||||
log.Printf(i18n.T("youtube_failed_fetch_comments"), err)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -677,7 +677,7 @@ func (o *YouTube) FetchAndSavePlaylist(playlistID, filename string) (err error)
|
|||
return
|
||||
}
|
||||
|
||||
fmt.Println("Playlist saved to", filename)
|
||||
fmt.Printf("%s\n", fmt.Sprintf(i18n.T("youtube_playlist_saved_to"), filename))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -688,8 +688,8 @@ func (o *YouTube) FetchAndPrintPlaylist(playlistID string) (err error) {
|
|||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Playlist: %s\n", playlistID)
|
||||
fmt.Printf("VideoId: Title\n")
|
||||
fmt.Printf("%s\n", fmt.Sprintf(i18n.T("youtube_playlist_header"), playlistID))
|
||||
fmt.Printf("%s\n", i18n.T("youtube_video_id_title_header"))
|
||||
for _, video := range videos {
|
||||
fmt.Printf("%s: %s\n", video.Id, video.Title)
|
||||
}
|
||||
|
|
@ -831,7 +831,7 @@ func (o *YouTube) GrabByFlags() (ret *VideoInfo, err error) {
|
|||
flag.Parse()
|
||||
|
||||
if flag.NArg() == 0 {
|
||||
log.Fatal("Error: No URL provided.")
|
||||
log.Fatalf("%s", i18n.T("youtube_no_url_provided"))
|
||||
}
|
||||
|
||||
url := flag.Arg(0)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/danielmiessler/fabric/internal/i18n"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
|
|
@ -98,7 +99,7 @@ func (o *GroupsItemsSelector[I]) GetGroupAndItemByItemNumber(number int) (group
|
|||
}
|
||||
|
||||
if !found {
|
||||
err = fmt.Errorf("number %d is out of range", number)
|
||||
err = fmt.Errorf("%s", fmt.Sprintf(i18n.T("groups_items_number_out_of_range"), number))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue