mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-11 02:46:58 +08:00
* feat(i18n): translate web console messages in the frontend and AI-client errors in the backend
Chinese text was hard-coded in API messages, stored log content, emails and
server logs, so it reached users of every interface language.
Text is now translated where its reader is:
- Web console APIs return the English source text as message_key with
message_params, and the web console translates it in all seven languages.
message keeps the English text for other API callers.
- Errors returned to AI clients, emails and notifications are translated by
the backend (en, zh-CN, zh-TW) in the language saved on the account, then
the language of the request's Accept-Language header.
- A reader who states no language still gets the original Chinese text of the
messages that were Chinese before, word for word. Gateways that use another
new-api as their upstream match that text, for example to disable a channel
whose account ran out of quota.
- Usage, top-up and system log content is stored as content_parts (key and
params) and rendered in the viewer's language. Task fail reasons and refund
reasons written by the gateway are English keys. Rows written earlier are
shown as before.
- Server logs are English.
Also:
- The saved interface language zhTW, and zh-HK, zh-MO and zh-Hant, now select
Traditional Chinese in the backend instead of Simplified Chinese.
- The built-in group descriptions are translated; descriptions an
administrator saved are shown as written.
- .agents/rules/i18n.md records the conventions. Tests in i18n/ fail on a
non-English string literal, on a web console message without translations
and on a locale key without a constant.
* feat(i18n): print server logs in Chinese when LOG_LANGUAGE asks for it
Server log lines are English by default. With LOG_LANGUAGE=zh-CN, the lines
that were Chinese before print their original Chinese text.
common.LogText formats a log line from its English format, which is also the
key of its Chinese text in common/log_text.zh-CN.json. A line without an entry
prints in English. go vet checks the arguments of every call, and
TestServerLogTexts checks that each entry still matches a call and has the
same verbs as its key.
* docs: list LOG_LANGUAGE in the docker compose example
* feat(i18n): translate every server log line and add DEFAULT_LANGUAGE
Every line the gateway prints goes through common.LogText. With
DEFAULT_LANGUAGE=zh-CN, the lines that have a Chinese text in
common/log_text.zh-CN.json print it and the others print English.
DEFAULT_LANGUAGE replaces LOG_LANGUAGE. It also sets the language of
backend messages, emails and notifications for a reader who states no
language: en, zh-CN or zh-TW, English when unset. The list of messages
that stayed Chinese for such readers is removed.
Review fixes:
- Channel test logs store the token name "Model test"; the console shows
it in the interface language.
- Task fail reasons written by the gateway are fixed sentences the console
translates. The HTTP status and the upstream detail go to the server log
when a polled task is failed.
- The auto-disable reason of a slow channel is written in DEFAULT_LANGUAGE.
Also:
- Drop the repository-scanning lint tests, the i18n rules file and its
read gate. AGENTS.md keeps the conventions in four lines.
- Payment error toasts keep their previous text and only add the
translated message key.
- Remove guards and helpers that no caller needs.
* fix(i18n): address review on language lookup, notices and error responses
- Relay requests no longer load the user a second time to find the
language: the settings TokenAuth put into the request are used as they
are, also when they hold no language.
- Upstream model update notices are rendered in each recipient's saved
language, DEFAULT_LANGUAGE when none.
- /api/usage/token and /api/log/token answer with backend-translated
messages again, with the keys and status codes used before this PR.
Their callers are scripts, not the web console.
- Webhook notifications fill {{value}} placeholders like the other
senders. Running the rendered text through Sprintf appended
%!(EXTRA ...) and broke %2F in links.
- RootUserLanguage reads the setting of the root user it already loaded.
- zh_TW and zh_HK are read as Traditional Chinese.
- Add common.ApiErrorStatus for error responses under another HTTP status
and use it where those responses were built by hand.
- Remove middleware.GetLanguage, which had no callers.
411 lines
11 KiB
Go
411 lines
11 KiB
Go
package model
|
|
|
|
import (
|
|
"errors"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/dto"
|
|
|
|
"github.com/samber/lo"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
type Ability struct {
|
|
Group string `json:"group" gorm:"type:varchar(64);primaryKey;autoIncrement:false"`
|
|
Model string `json:"model" gorm:"type:varchar(255);primaryKey;autoIncrement:false"`
|
|
ChannelId int `json:"channel_id" gorm:"primaryKey;autoIncrement:false;index"`
|
|
Enabled bool `json:"enabled"`
|
|
Priority *int64 `json:"priority" gorm:"bigint;default:0;index"`
|
|
Weight uint `json:"weight" gorm:"default:0;index"`
|
|
Tag *string `json:"tag" gorm:"index"`
|
|
}
|
|
|
|
type AbilityWithChannel struct {
|
|
Ability
|
|
ChannelType int `json:"channel_type"`
|
|
}
|
|
|
|
func GetAllEnableAbilityWithChannels() ([]AbilityWithChannel, error) {
|
|
var abilities []AbilityWithChannel
|
|
err := DB.Table("abilities").
|
|
Select("abilities.*, channels.type as channel_type").
|
|
Joins("left join channels on abilities.channel_id = channels.id").
|
|
Where("abilities.enabled = ?", true).
|
|
Scan(&abilities).Error
|
|
return abilities, err
|
|
}
|
|
|
|
func GetGroupEnabledModels(group string) []string {
|
|
var models []string
|
|
// Find distinct models
|
|
DB.Table("abilities").Where(commonGroupCol+" = ? and enabled = ?", group, true).Distinct("model").Pluck("model", &models)
|
|
return models
|
|
}
|
|
|
|
func GetEnabledModels() []string {
|
|
var models []string
|
|
// Find distinct models
|
|
DB.Table("abilities").Where("enabled = ?", true).Distinct("model").Pluck("model", &models)
|
|
return models
|
|
}
|
|
|
|
func GetAllEnableAbilities() []Ability {
|
|
var abilities []Ability
|
|
DB.Find(&abilities, "enabled = ?", true)
|
|
return abilities
|
|
}
|
|
|
|
func getPriority(group string, model string, retry int) (int, error) {
|
|
|
|
var priorities []int
|
|
err := DB.Model(&Ability{}).
|
|
Select("DISTINCT(priority)").
|
|
Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true).
|
|
Order("priority DESC"). // 按优先级降序排序
|
|
Pluck("priority", &priorities).Error // Pluck用于将查询的结果直接扫描到一个切片中
|
|
|
|
if err != nil {
|
|
// 处理错误
|
|
return 0, err
|
|
}
|
|
|
|
if len(priorities) == 0 {
|
|
// 如果没有查询到优先级,则返回错误
|
|
return 0, errors.New("database consistency is broken")
|
|
}
|
|
|
|
// 确定要使用的优先级
|
|
var priorityToUse int
|
|
if retry >= len(priorities) {
|
|
// 如果重试次数大于优先级数,则使用最小的优先级
|
|
priorityToUse = priorities[len(priorities)-1]
|
|
} else {
|
|
priorityToUse = priorities[retry]
|
|
}
|
|
return priorityToUse, nil
|
|
}
|
|
|
|
func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) {
|
|
maxPrioritySubQuery := DB.Model(&Ability{}).Select("MAX(priority)").Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true)
|
|
channelQuery := DB.Where(commonGroupCol+" = ? and model = ? and enabled = ? and priority = (?)", group, model, true, maxPrioritySubQuery)
|
|
if retry != 0 {
|
|
priority, err := getPriority(group, model, retry)
|
|
if err != nil {
|
|
return nil, err
|
|
} else {
|
|
channelQuery = DB.Where(commonGroupCol+" = ? and model = ? and enabled = ? and priority = ?", group, model, true, priority)
|
|
}
|
|
}
|
|
|
|
return channelQuery, nil
|
|
}
|
|
|
|
func GetChannel(
|
|
group string,
|
|
model string,
|
|
retry int,
|
|
filters []dto.ChannelFilter,
|
|
) (*Channel, error) {
|
|
var abilities []Ability
|
|
err := DB.Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true).Order("priority DESC, weight DESC").Find(&abilities).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
abilities = filterAbilitiesByConstraints(abilities, model, filters)
|
|
if len(abilities) > 0 {
|
|
priorities := make([]int64, 0)
|
|
seen := make(map[int64]bool)
|
|
for _, ability := range abilities {
|
|
priority := int64(0)
|
|
if ability.Priority != nil {
|
|
priority = *ability.Priority
|
|
}
|
|
if !seen[priority] {
|
|
seen[priority] = true
|
|
priorities = append(priorities, priority)
|
|
}
|
|
}
|
|
sort.Slice(priorities, func(i, j int) bool { return priorities[i] > priorities[j] })
|
|
if retry >= len(priorities) {
|
|
retry = len(priorities) - 1
|
|
}
|
|
targetPriority := priorities[retry]
|
|
abilities = lo.Filter(abilities, func(ability Ability, _ int) bool {
|
|
return ability.Priority == nil && targetPriority == 0 || ability.Priority != nil && *ability.Priority == targetPriority
|
|
})
|
|
}
|
|
channel := Channel{}
|
|
if len(abilities) > 0 {
|
|
// Randomly choose one
|
|
weightSum := uint(0)
|
|
for _, ability_ := range abilities {
|
|
weightSum += ability_.Weight + 10
|
|
}
|
|
// Randomly choose one
|
|
weight := common.GetRandomInt(int(weightSum))
|
|
for _, ability_ := range abilities {
|
|
weight -= int(ability_.Weight) + 10
|
|
//log.Printf("weight: %d, ability weight: %d", weight, *ability_.Weight)
|
|
if weight <= 0 {
|
|
channel.Id = ability_.ChannelId
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
return nil, nil
|
|
}
|
|
err = DB.First(&channel, "id = ?", channel.Id).Error
|
|
return &channel, err
|
|
}
|
|
|
|
// filterAbilitiesByConstraints applies the same ChannelSatisfiesFilters
|
|
// predicate used by the memory-cache path. A failed channel lookup fails
|
|
// closed when a task-plugin identity is required and fails open otherwise.
|
|
func filterAbilitiesByConstraints(abilities []Ability, modelName string, filters []dto.ChannelFilter) []Ability {
|
|
if len(abilities) == 0 {
|
|
return nil
|
|
}
|
|
|
|
channelIds := make([]int, 0, len(abilities))
|
|
seen := make(map[int]struct{}, len(abilities))
|
|
for _, ability := range abilities {
|
|
if _, ok := seen[ability.ChannelId]; ok {
|
|
continue
|
|
}
|
|
seen[ability.ChannelId] = struct{}{}
|
|
channelIds = append(channelIds, ability.ChannelId)
|
|
}
|
|
|
|
var channels []*Channel
|
|
if err := DB.Where("id IN ?", channelIds).Find(&channels).Error; err != nil {
|
|
if identityFilterRequiresKey(filters) {
|
|
return nil
|
|
}
|
|
return abilities
|
|
}
|
|
|
|
channelsByID := make(map[int]*Channel, len(channels))
|
|
for _, channel := range channels {
|
|
channelsByID[channel.Id] = channel
|
|
}
|
|
|
|
filtered := make([]Ability, 0, len(abilities))
|
|
for _, ability := range abilities {
|
|
channel := channelsByID[ability.ChannelId]
|
|
if ok, _ := ChannelSatisfiesFilters(channel, modelName, filters); ok {
|
|
filtered = append(filtered, ability)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func identityFilterRequiresKey(filters []dto.ChannelFilter) bool {
|
|
for _, filter := range filters {
|
|
if filter.Kind == dto.FilterTaskPluginIdentity && filter.TaskPluginKey != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (channel *Channel) AddAbilities(tx *gorm.DB) error {
|
|
models_ := strings.Split(channel.Models, ",")
|
|
groups_ := strings.Split(channel.Group, ",")
|
|
abilitySet := make(map[string]struct{})
|
|
abilities := make([]Ability, 0, len(models_))
|
|
for _, model := range models_ {
|
|
for _, group := range groups_ {
|
|
key := group + "|" + model
|
|
if _, exists := abilitySet[key]; exists {
|
|
continue
|
|
}
|
|
abilitySet[key] = struct{}{}
|
|
ability := Ability{
|
|
Group: group,
|
|
Model: model,
|
|
ChannelId: channel.Id,
|
|
Enabled: channel.Status == common.ChannelStatusEnabled,
|
|
Priority: channel.Priority,
|
|
Weight: uint(channel.GetWeight()),
|
|
Tag: channel.Tag,
|
|
}
|
|
abilities = append(abilities, ability)
|
|
}
|
|
}
|
|
if len(abilities) == 0 {
|
|
return nil
|
|
}
|
|
// choose DB or provided tx
|
|
useDB := DB
|
|
if tx != nil {
|
|
useDB = tx
|
|
}
|
|
for _, chunk := range lo.Chunk(abilities, 50) {
|
|
err := useDB.Clauses(clause.OnConflict{DoNothing: true}).Create(&chunk).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (channel *Channel) DeleteAbilities() error {
|
|
return DB.Where("channel_id = ?", channel.Id).Delete(&Ability{}).Error
|
|
}
|
|
|
|
// UpdateAbilities updates abilities of this channel.
|
|
// Make sure the channel is completed before calling this function.
|
|
func (channel *Channel) UpdateAbilities(tx *gorm.DB) error {
|
|
isNewTx := false
|
|
// 如果没有传入事务,创建新的事务
|
|
if tx == nil {
|
|
tx = DB.Begin()
|
|
if tx.Error != nil {
|
|
return tx.Error
|
|
}
|
|
isNewTx = true
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
tx.Rollback()
|
|
}
|
|
}()
|
|
}
|
|
|
|
// First delete all abilities of this channel
|
|
err := tx.Where("channel_id = ?", channel.Id).Delete(&Ability{}).Error
|
|
if err != nil {
|
|
if isNewTx {
|
|
tx.Rollback()
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Then add new abilities
|
|
models_ := channel.GetModels()
|
|
groups_ := strings.Split(channel.Group, ",")
|
|
abilitySet := make(map[string]struct{})
|
|
abilities := make([]Ability, 0, len(models_))
|
|
for _, model := range models_ {
|
|
for _, group := range groups_ {
|
|
key := group + "|" + model
|
|
if _, exists := abilitySet[key]; exists {
|
|
continue
|
|
}
|
|
abilitySet[key] = struct{}{}
|
|
ability := Ability{
|
|
Group: group,
|
|
Model: model,
|
|
ChannelId: channel.Id,
|
|
Enabled: channel.Status == common.ChannelStatusEnabled,
|
|
Priority: channel.Priority,
|
|
Weight: uint(channel.GetWeight()),
|
|
Tag: channel.Tag,
|
|
}
|
|
abilities = append(abilities, ability)
|
|
}
|
|
}
|
|
|
|
if len(abilities) > 0 {
|
|
for _, chunk := range lo.Chunk(abilities, 50) {
|
|
err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&chunk).Error
|
|
if err != nil {
|
|
if isNewTx {
|
|
tx.Rollback()
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// 如果是新创建的事务,需要提交
|
|
if isNewTx {
|
|
return tx.Commit().Error
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func UpdateAbilityStatus(channelId int, status bool) error {
|
|
return DB.Model(&Ability{}).Where("channel_id = ?", channelId).Select("enabled").Update("enabled", status).Error
|
|
}
|
|
|
|
func UpdateAbilityStatusByTag(tag string, status bool) error {
|
|
return DB.Model(&Ability{}).Where("tag = ?", tag).Select("enabled").Update("enabled", status).Error
|
|
}
|
|
|
|
func UpdateAbilityByTag(tag string, newTag *string, priority *int64, weight *uint) error {
|
|
ability := Ability{}
|
|
if newTag != nil {
|
|
ability.Tag = newTag
|
|
}
|
|
if priority != nil {
|
|
ability.Priority = priority
|
|
}
|
|
if weight != nil {
|
|
ability.Weight = *weight
|
|
}
|
|
return DB.Model(&Ability{}).Where("tag = ?", tag).Updates(ability).Error
|
|
}
|
|
|
|
var fixLock = sync.Mutex{}
|
|
|
|
func FixAbility() (int, int, error) {
|
|
lock := fixLock.TryLock()
|
|
if !lock {
|
|
return 0, 0, common.NewMessage("A repair task is already running. Please try again later")
|
|
}
|
|
defer fixLock.Unlock()
|
|
|
|
// truncate abilities table
|
|
if common.UsingMainDatabase(common.DatabaseTypeSQLite) {
|
|
err := DB.Exec("DELETE FROM abilities").Error
|
|
if err != nil {
|
|
common.SysLog(common.LogText("Delete abilities failed: %s", err.Error()))
|
|
return 0, 0, err
|
|
}
|
|
} else {
|
|
err := DB.Exec("TRUNCATE TABLE abilities").Error
|
|
if err != nil {
|
|
common.SysLog(common.LogText("Truncate abilities failed: %s", err.Error()))
|
|
return 0, 0, err
|
|
}
|
|
}
|
|
var channels []*Channel
|
|
// Find all channels
|
|
err := DB.Model(&Channel{}).Find(&channels).Error
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
if len(channels) == 0 {
|
|
return 0, 0, nil
|
|
}
|
|
successCount := 0
|
|
failCount := 0
|
|
for _, chunk := range lo.Chunk(channels, 50) {
|
|
ids := lo.Map(chunk, func(c *Channel, _ int) int { return c.Id })
|
|
// Delete all abilities of this channel
|
|
err = DB.Where("channel_id IN ?", ids).Delete(&Ability{}).Error
|
|
if err != nil {
|
|
common.SysLog(common.LogText("Delete abilities failed: %s", err.Error()))
|
|
failCount += len(chunk)
|
|
continue
|
|
}
|
|
// Then add new abilities
|
|
for _, channel := range chunk {
|
|
err = channel.AddAbilities(nil)
|
|
if err != nil {
|
|
common.SysLog(common.LogText("Add abilities for channel %d failed: %s", channel.Id, err.Error()))
|
|
failCount++
|
|
} else {
|
|
successCount++
|
|
}
|
|
}
|
|
}
|
|
InitChannelCache()
|
|
return successCount, failCount, nil
|
|
}
|