mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-19 21:00:52 +08:00
feat(tui): add i18n
This commit is contained in:
2
go.mod
2
go.mod
@@ -4,6 +4,7 @@ go 1.24.2
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.0.6
|
github.com/andybalholm/brotli v1.0.6
|
||||||
|
github.com/atotto/clipboard v0.1.4
|
||||||
github.com/charmbracelet/bubbles v1.0.0
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
@@ -34,7 +35,6 @@ require (
|
|||||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/bytedance/sonic v1.11.6 // indirect
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ const (
|
|||||||
tabLogs
|
tabLogs
|
||||||
)
|
)
|
||||||
|
|
||||||
var tabNames = []string{"Dashboard", "Config", "Auth Files", "API Keys", "OAuth", "Usage", "Logs"}
|
|
||||||
|
|
||||||
// App is the root bubbletea model that contains all tab sub-models.
|
// App is the root bubbletea model that contains all tab sub-models.
|
||||||
type App struct {
|
type App struct {
|
||||||
activeTab int
|
activeTab int
|
||||||
@@ -50,7 +48,7 @@ func NewApp(port int, secretKey string, hook *LogHook) App {
|
|||||||
client := NewClient(port, secretKey)
|
client := NewClient(port, secretKey)
|
||||||
return App{
|
return App{
|
||||||
activeTab: tabDashboard,
|
activeTab: tabDashboard,
|
||||||
tabs: tabNames,
|
tabs: TabNames(),
|
||||||
dashboard: newDashboardModel(client),
|
dashboard: newDashboardModel(client),
|
||||||
config: newConfigTabModel(client),
|
config: newConfigTabModel(client),
|
||||||
auth: newAuthTabModel(client),
|
auth: newAuthTabModel(client),
|
||||||
@@ -102,13 +100,50 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if a.activeTab != tabLogs {
|
if a.activeTab != tabLogs {
|
||||||
return a, tea.Quit
|
return a, tea.Quit
|
||||||
}
|
}
|
||||||
|
case "L":
|
||||||
|
ToggleLocale()
|
||||||
|
a.tabs = TabNames()
|
||||||
|
// Broadcast locale change to ALL tabs so each re-renders
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
var cmd tea.Cmd
|
||||||
|
a.dashboard, cmd = a.dashboard.Update(localeChangedMsg{})
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
a.config, cmd = a.config.Update(localeChangedMsg{})
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
a.auth, cmd = a.auth.Update(localeChangedMsg{})
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
a.keys, cmd = a.keys.Update(localeChangedMsg{})
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
a.oauth, cmd = a.oauth.Update(localeChangedMsg{})
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
a.usage, cmd = a.usage.Update(localeChangedMsg{})
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
a.logs, cmd = a.logs.Update(localeChangedMsg{})
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
return a, tea.Batch(cmds...)
|
||||||
case "tab":
|
case "tab":
|
||||||
prevTab := a.activeTab
|
prevTab := a.activeTab
|
||||||
a.activeTab = (a.activeTab + 1) % len(a.tabs)
|
a.activeTab = (a.activeTab + 1) % len(a.tabs)
|
||||||
|
a.tabs = TabNames()
|
||||||
return a, a.initTabIfNeeded(prevTab)
|
return a, a.initTabIfNeeded(prevTab)
|
||||||
case "shift+tab":
|
case "shift+tab":
|
||||||
prevTab := a.activeTab
|
prevTab := a.activeTab
|
||||||
a.activeTab = (a.activeTab - 1 + len(a.tabs)) % len(a.tabs)
|
a.activeTab = (a.activeTab - 1 + len(a.tabs)) % len(a.tabs)
|
||||||
|
a.tabs = TabNames()
|
||||||
return a, a.initTabIfNeeded(prevTab)
|
return a, a.initTabIfNeeded(prevTab)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,6 +180,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return a, cmd
|
return a, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// localeChangedMsg is broadcast to all tabs when the user toggles locale.
|
||||||
|
type localeChangedMsg struct{}
|
||||||
|
|
||||||
func (a *App) initTabIfNeeded(_ int) tea.Cmd {
|
func (a *App) initTabIfNeeded(_ int) tea.Cmd {
|
||||||
if a.initialized[a.activeTab] {
|
if a.initialized[a.activeTab] {
|
||||||
return nil
|
return nil
|
||||||
@@ -171,7 +209,7 @@ func (a *App) initTabIfNeeded(_ int) tea.Cmd {
|
|||||||
|
|
||||||
func (a App) View() string {
|
func (a App) View() string {
|
||||||
if !a.ready {
|
if !a.ready {
|
||||||
return "Initializing TUI..."
|
return T("initializing_tui")
|
||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
@@ -219,8 +257,8 @@ func (a App) renderTabBar() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a App) renderStatusBar() string {
|
func (a App) renderStatusBar() string {
|
||||||
left := " CLIProxyAPI Management TUI"
|
left := T("status_left")
|
||||||
right := "Tab/Shift+Tab: switch • q/Ctrl+C: quit "
|
right := T("status_right")
|
||||||
gap := a.width - lipgloss.Width(left) - lipgloss.Width(right)
|
gap := a.width - lipgloss.Width(left) - lipgloss.Width(right)
|
||||||
if gap < 0 {
|
if gap < 0 {
|
||||||
gap = 0
|
gap = 0
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ func (m authTabModel) fetchFiles() tea.Msg {
|
|||||||
|
|
||||||
func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) {
|
func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
case localeChangedMsg:
|
||||||
|
m.viewport.SetContent(m.renderContent())
|
||||||
|
return m, nil
|
||||||
case authFilesMsg:
|
case authFilesMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.err = msg.err
|
m.err = msg.err
|
||||||
@@ -122,7 +125,7 @@ func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return authActionMsg{err: err}
|
return authActionMsg{err: err}
|
||||||
}
|
}
|
||||||
return authActionMsg{action: fmt.Sprintf("Updated %s on %s", fieldKey, fileName)}
|
return authActionMsg{action: fmt.Sprintf(T("updated_field"), fieldKey, fileName)}
|
||||||
}
|
}
|
||||||
case "esc":
|
case "esc":
|
||||||
m.editing = false
|
m.editing = false
|
||||||
@@ -150,7 +153,7 @@ func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return authActionMsg{err: err}
|
return authActionMsg{err: err}
|
||||||
}
|
}
|
||||||
return authActionMsg{action: fmt.Sprintf("Deleted %s", name)}
|
return authActionMsg{action: fmt.Sprintf(T("deleted"), name)}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.viewport.SetContent(m.renderContent())
|
m.viewport.SetContent(m.renderContent())
|
||||||
@@ -202,9 +205,9 @@ func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return authActionMsg{err: err}
|
return authActionMsg{err: err}
|
||||||
}
|
}
|
||||||
action := "Enabled"
|
action := T("enabled")
|
||||||
if newDisabled {
|
if newDisabled {
|
||||||
action = "Disabled"
|
action = T("disabled")
|
||||||
}
|
}
|
||||||
return authActionMsg{action: fmt.Sprintf("%s %s", action, name)}
|
return authActionMsg{action: fmt.Sprintf("%s %s", action, name)}
|
||||||
}
|
}
|
||||||
@@ -267,7 +270,7 @@ func (m *authTabModel) SetSize(w, h int) {
|
|||||||
|
|
||||||
func (m authTabModel) View() string {
|
func (m authTabModel) View() string {
|
||||||
if !m.ready {
|
if !m.ready {
|
||||||
return "Loading..."
|
return T("loading")
|
||||||
}
|
}
|
||||||
return m.viewport.View()
|
return m.viewport.View()
|
||||||
}
|
}
|
||||||
@@ -275,11 +278,11 @@ func (m authTabModel) View() string {
|
|||||||
func (m authTabModel) renderContent() string {
|
func (m authTabModel) renderContent() string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
sb.WriteString(titleStyle.Render("🔑 Auth Files"))
|
sb.WriteString(titleStyle.Render(T("auth_title")))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(helpStyle.Render(" [↑↓/jk] navigate • [Enter] expand • [e] enable/disable • [d] delete • [r] refresh"))
|
sb.WriteString(helpStyle.Render(T("auth_help1")))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(helpStyle.Render(" [1] edit prefix • [2] edit proxy_url • [3] edit priority"))
|
sb.WriteString(helpStyle.Render(T("auth_help2")))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(strings.Repeat("─", m.width))
|
sb.WriteString(strings.Repeat("─", m.width))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
@@ -291,7 +294,7 @@ func (m authTabModel) renderContent() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(m.files) == 0 {
|
if len(m.files) == 0 {
|
||||||
sb.WriteString(subtitleStyle.Render("\n No auth files found"))
|
sb.WriteString(subtitleStyle.Render(T("no_auth_files")))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
@@ -303,10 +306,10 @@ func (m authTabModel) renderContent() string {
|
|||||||
disabled := getBool(f, "disabled")
|
disabled := getBool(f, "disabled")
|
||||||
|
|
||||||
statusIcon := successStyle.Render("●")
|
statusIcon := successStyle.Render("●")
|
||||||
statusText := "active"
|
statusText := T("status_active")
|
||||||
if disabled {
|
if disabled {
|
||||||
statusIcon = lipgloss.NewStyle().Foreground(colorMuted).Render("○")
|
statusIcon = lipgloss.NewStyle().Foreground(colorMuted).Render("○")
|
||||||
statusText = "disabled"
|
statusText = T("status_disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
cursor := " "
|
cursor := " "
|
||||||
@@ -332,7 +335,7 @@ func (m authTabModel) renderContent() string {
|
|||||||
|
|
||||||
// Delete confirmation
|
// Delete confirmation
|
||||||
if m.confirm == i {
|
if m.confirm == i {
|
||||||
sb.WriteString(warningStyle.Render(fmt.Sprintf(" ⚠ Delete %s? [y/n] ", name)))
|
sb.WriteString(warningStyle.Render(fmt.Sprintf(" "+T("confirm_delete"), name)))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +343,7 @@ func (m authTabModel) renderContent() string {
|
|||||||
if m.editing && i == m.cursor {
|
if m.editing && i == m.cursor {
|
||||||
sb.WriteString(m.editInput.View())
|
sb.WriteString(m.editInput.View())
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(helpStyle.Render(" Enter: save • Esc: cancel"))
|
sb.WriteString(helpStyle.Render(" " + T("enter_save") + " • " + T("esc_cancel")))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,7 +401,7 @@ func (m authTabModel) renderDetail(f map[string]any) string {
|
|||||||
val := getAnyString(f, field.key)
|
val := getAnyString(f, field.key)
|
||||||
if val == "" || val == "<nil>" {
|
if val == "" || val == "<nil>" {
|
||||||
if field.editable {
|
if field.editable {
|
||||||
val = "(not set)"
|
val = T("not_set")
|
||||||
} else {
|
} else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,6 +206,34 @@ func (c *Client) GetAPIKeys() ([]string, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddAPIKey adds a new API key by sending old=nil, new=key which appends.
|
||||||
|
func (c *Client) AddAPIKey(key string) error {
|
||||||
|
body := map[string]any{"old": nil, "new": key}
|
||||||
|
jsonBody, _ := json.Marshal(body)
|
||||||
|
_, err := c.patch("/v0/management/api-keys", strings.NewReader(string(jsonBody)))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditAPIKey replaces an API key at the given index.
|
||||||
|
func (c *Client) EditAPIKey(index int, newValue string) error {
|
||||||
|
body := map[string]any{"index": index, "value": newValue}
|
||||||
|
jsonBody, _ := json.Marshal(body)
|
||||||
|
_, err := c.patch("/v0/management/api-keys", strings.NewReader(string(jsonBody)))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAPIKey deletes an API key by index.
|
||||||
|
func (c *Client) DeleteAPIKey(index int) error {
|
||||||
|
_, code, err := c.doRequest("DELETE", fmt.Sprintf("/v0/management/api-keys?index=%d", index), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if code >= 400 {
|
||||||
|
return fmt.Errorf("delete failed (HTTP %d)", code)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetGeminiKeys fetches Gemini API keys.
|
// GetGeminiKeys fetches Gemini API keys.
|
||||||
// API returns {"gemini-api-key": [...]}.
|
// API returns {"gemini-api-key": [...]}.
|
||||||
func (c *Client) GetGeminiKeys() ([]map[string]any, error) {
|
func (c *Client) GetGeminiKeys() ([]map[string]any, error) {
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ func (m configTabModel) fetchConfig() tea.Msg {
|
|||||||
|
|
||||||
func (m configTabModel) Update(msg tea.Msg) (configTabModel, tea.Cmd) {
|
func (m configTabModel) Update(msg tea.Msg) (configTabModel, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
case localeChangedMsg:
|
||||||
|
m.viewport.SetContent(m.renderContent())
|
||||||
|
return m, nil
|
||||||
case configDataMsg:
|
case configDataMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.err = msg.err
|
m.err = msg.err
|
||||||
@@ -79,7 +82,7 @@ func (m configTabModel) Update(msg tea.Msg) (configTabModel, tea.Cmd) {
|
|||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.message = errorStyle.Render("✗ " + msg.err.Error())
|
m.message = errorStyle.Render("✗ " + msg.err.Error())
|
||||||
} else {
|
} else {
|
||||||
m.message = successStyle.Render("✓ Updated successfully")
|
m.message = successStyle.Render(T("updated_ok"))
|
||||||
}
|
}
|
||||||
m.viewport.SetContent(m.renderContent())
|
m.viewport.SetContent(m.renderContent())
|
||||||
// Refresh config from server
|
// Refresh config from server
|
||||||
@@ -178,7 +181,7 @@ func (m configTabModel) submitEdit(idx int, newValue string) tea.Cmd {
|
|||||||
case "int":
|
case "int":
|
||||||
v, parseErr := strconv.Atoi(newValue)
|
v, parseErr := strconv.Atoi(newValue)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
return configUpdateMsg{err: fmt.Errorf("invalid integer: %s", newValue)}
|
return configUpdateMsg{err: fmt.Errorf("%s: %s", T("invalid_int"), newValue)}
|
||||||
}
|
}
|
||||||
err = m.client.PutIntField(f.apiPath, v)
|
err = m.client.PutIntField(f.apiPath, v)
|
||||||
case "string":
|
case "string":
|
||||||
@@ -214,7 +217,7 @@ func (m *configTabModel) ensureCursorVisible() {
|
|||||||
|
|
||||||
func (m configTabModel) View() string {
|
func (m configTabModel) View() string {
|
||||||
if !m.ready {
|
if !m.ready {
|
||||||
return "Loading..."
|
return T("loading")
|
||||||
}
|
}
|
||||||
return m.viewport.View()
|
return m.viewport.View()
|
||||||
}
|
}
|
||||||
@@ -222,7 +225,7 @@ func (m configTabModel) View() string {
|
|||||||
func (m configTabModel) renderContent() string {
|
func (m configTabModel) renderContent() string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
sb.WriteString(titleStyle.Render("⚙ Configuration"))
|
sb.WriteString(titleStyle.Render(T("config_title")))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
if m.message != "" {
|
if m.message != "" {
|
||||||
@@ -230,9 +233,9 @@ func (m configTabModel) renderContent() string {
|
|||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.WriteString(helpStyle.Render(" [↑↓/jk] navigate • [Enter/Space] edit • [r] refresh"))
|
sb.WriteString(helpStyle.Render(T("config_help1")))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(helpStyle.Render(" Bool fields: Enter to toggle • String/Int: Enter to type, Enter to confirm, Esc to cancel"))
|
sb.WriteString(helpStyle.Render(T("config_help2")))
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
@@ -241,7 +244,7 @@ func (m configTabModel) renderContent() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(m.fields) == 0 {
|
if len(m.fields) == 0 {
|
||||||
sb.WriteString(subtitleStyle.Render(" No configuration loaded"))
|
sb.WriteString(subtitleStyle.Render(T("no_config")))
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,23 +344,23 @@ func (m configTabModel) parseConfig(cfg map[string]any) []configField {
|
|||||||
|
|
||||||
func fieldSection(apiPath string) string {
|
func fieldSection(apiPath string) string {
|
||||||
if strings.HasPrefix(apiPath, "ampcode/") {
|
if strings.HasPrefix(apiPath, "ampcode/") {
|
||||||
return "AMP Code"
|
return T("section_ampcode")
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(apiPath, "quota-exceeded/") {
|
if strings.HasPrefix(apiPath, "quota-exceeded/") {
|
||||||
return "Quota Exceeded Handling"
|
return T("section_quota")
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(apiPath, "routing/") {
|
if strings.HasPrefix(apiPath, "routing/") {
|
||||||
return "Routing"
|
return T("section_routing")
|
||||||
}
|
}
|
||||||
switch apiPath {
|
switch apiPath {
|
||||||
case "port", "host", "debug", "proxy-url", "request-retry", "max-retry-interval", "force-model-prefix":
|
case "port", "host", "debug", "proxy-url", "request-retry", "max-retry-interval", "force-model-prefix":
|
||||||
return "Server"
|
return T("section_server")
|
||||||
case "logging-to-file", "logs-max-total-size-mb", "error-logs-max-files", "usage-statistics-enabled", "request-log":
|
case "logging-to-file", "logs-max-total-size-mb", "error-logs-max-files", "usage-statistics-enabled", "request-log":
|
||||||
return "Logging & Stats"
|
return T("section_logging")
|
||||||
case "ws-auth":
|
case "ws-auth":
|
||||||
return "WebSocket"
|
return T("section_websocket")
|
||||||
default:
|
default:
|
||||||
return "Other"
|
return T("section_other")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,7 +381,7 @@ func getBoolNested(m map[string]any, keys ...string) bool {
|
|||||||
|
|
||||||
func maskIfNotEmpty(s string) string {
|
func maskIfNotEmpty(s string) string {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return "(not set)"
|
return T("not_set")
|
||||||
}
|
}
|
||||||
return maskKey(s)
|
return maskKey(s)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ func (m dashboardModel) fetchData() tea.Msg {
|
|||||||
|
|
||||||
func (m dashboardModel) Update(msg tea.Msg) (dashboardModel, tea.Cmd) {
|
func (m dashboardModel) Update(msg tea.Msg) (dashboardModel, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
case localeChangedMsg:
|
||||||
|
// Re-fetch data to re-render with new locale
|
||||||
|
return m, m.fetchData
|
||||||
case dashboardDataMsg:
|
case dashboardDataMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.err = msg.err
|
m.err = msg.err
|
||||||
@@ -97,7 +100,7 @@ func (m *dashboardModel) SetSize(w, h int) {
|
|||||||
|
|
||||||
func (m dashboardModel) View() string {
|
func (m dashboardModel) View() string {
|
||||||
if !m.ready {
|
if !m.ready {
|
||||||
return "Loading..."
|
return T("loading")
|
||||||
}
|
}
|
||||||
return m.viewport.View()
|
return m.viewport.View()
|
||||||
}
|
}
|
||||||
@@ -105,19 +108,15 @@ func (m dashboardModel) View() string {
|
|||||||
func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []map[string]any, apiKeys []string) string {
|
func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []map[string]any, apiKeys []string) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
sb.WriteString(titleStyle.Render("📊 Dashboard"))
|
sb.WriteString(titleStyle.Render(T("dashboard_title")))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(helpStyle.Render(" [r] refresh • [↑↓] scroll"))
|
sb.WriteString(helpStyle.Render(T("dashboard_help")))
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
// ━━━ Connection Status ━━━
|
// ━━━ Connection Status ━━━
|
||||||
port := 0.0
|
|
||||||
if cfg != nil {
|
|
||||||
port = getFloat(cfg, "port")
|
|
||||||
}
|
|
||||||
connStyle := lipgloss.NewStyle().Bold(true).Foreground(colorSuccess)
|
connStyle := lipgloss.NewStyle().Bold(true).Foreground(colorSuccess)
|
||||||
sb.WriteString(connStyle.Render("● 已连接"))
|
sb.WriteString(connStyle.Render(T("connected")))
|
||||||
sb.WriteString(fmt.Sprintf(" http://127.0.0.1:%.0f", port))
|
sb.WriteString(fmt.Sprintf(" %s", m.client.baseURL))
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
// ━━━ Stats Cards ━━━
|
// ━━━ Stats Cards ━━━
|
||||||
@@ -141,7 +140,7 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m
|
|||||||
card1 := cardStyle.Render(fmt.Sprintf(
|
card1 := cardStyle.Render(fmt.Sprintf(
|
||||||
"%s\n%s",
|
"%s\n%s",
|
||||||
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("111")).Render(fmt.Sprintf("🔑 %d", keyCount)),
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("111")).Render(fmt.Sprintf("🔑 %d", keyCount)),
|
||||||
lipgloss.NewStyle().Foreground(colorMuted).Render("管理密钥"),
|
lipgloss.NewStyle().Foreground(colorMuted).Render(T("mgmt_keys")),
|
||||||
))
|
))
|
||||||
|
|
||||||
// Card 2: Auth Files
|
// Card 2: Auth Files
|
||||||
@@ -155,7 +154,7 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m
|
|||||||
card2 := cardStyle.Render(fmt.Sprintf(
|
card2 := cardStyle.Render(fmt.Sprintf(
|
||||||
"%s\n%s",
|
"%s\n%s",
|
||||||
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("76")).Render(fmt.Sprintf("📄 %d", authCount)),
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("76")).Render(fmt.Sprintf("📄 %d", authCount)),
|
||||||
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("认证文件 (%d active)", activeAuth)),
|
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s (%d %s)", T("auth_files_label"), activeAuth, T("active_suffix"))),
|
||||||
))
|
))
|
||||||
|
|
||||||
// Card 3: Total Requests
|
// Card 3: Total Requests
|
||||||
@@ -174,7 +173,7 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m
|
|||||||
card3 := cardStyle.Render(fmt.Sprintf(
|
card3 := cardStyle.Render(fmt.Sprintf(
|
||||||
"%s\n%s",
|
"%s\n%s",
|
||||||
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(fmt.Sprintf("📈 %d", totalReqs)),
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(fmt.Sprintf("📈 %d", totalReqs)),
|
||||||
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("请求 (✓%d ✗%d)", successReqs, failedReqs)),
|
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s (✓%d ✗%d)", T("total_requests"), successReqs, failedReqs)),
|
||||||
))
|
))
|
||||||
|
|
||||||
// Card 4: Total Tokens
|
// Card 4: Total Tokens
|
||||||
@@ -182,14 +181,14 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m
|
|||||||
card4 := cardStyle.Render(fmt.Sprintf(
|
card4 := cardStyle.Render(fmt.Sprintf(
|
||||||
"%s\n%s",
|
"%s\n%s",
|
||||||
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Render(fmt.Sprintf("🔤 %s", tokenStr)),
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Render(fmt.Sprintf("🔤 %s", tokenStr)),
|
||||||
lipgloss.NewStyle().Foreground(colorMuted).Render("总 Tokens"),
|
lipgloss.NewStyle().Foreground(colorMuted).Render(T("total_tokens")),
|
||||||
))
|
))
|
||||||
|
|
||||||
sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, card1, " ", card2, " ", card3, " ", card4))
|
sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, card1, " ", card2, " ", card3, " ", card4))
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
// ━━━ Current Config ━━━
|
// ━━━ Current Config ━━━
|
||||||
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("当前配置"))
|
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("current_config")))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
|
sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
@@ -210,16 +209,16 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m
|
|||||||
label string
|
label string
|
||||||
value string
|
value string
|
||||||
}{
|
}{
|
||||||
{"启用调试模式", boolEmoji(debug)},
|
{T("debug_mode"), boolEmoji(debug)},
|
||||||
{"启用使用统计", boolEmoji(usageEnabled)},
|
{T("usage_stats"), boolEmoji(usageEnabled)},
|
||||||
{"启用日志记录到文件", boolEmoji(loggingToFile)},
|
{T("log_to_file"), boolEmoji(loggingToFile)},
|
||||||
{"重试次数", fmt.Sprintf("%.0f", retry)},
|
{T("retry_count"), fmt.Sprintf("%.0f", retry)},
|
||||||
}
|
}
|
||||||
if proxyURL != "" {
|
if proxyURL != "" {
|
||||||
configItems = append(configItems, struct {
|
configItems = append(configItems, struct {
|
||||||
label string
|
label string
|
||||||
value string
|
value string
|
||||||
}{"代理 URL", proxyURL})
|
}{T("proxy_url"), proxyURL})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render config items as a compact row
|
// Render config items as a compact row
|
||||||
@@ -237,7 +236,7 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf(" %s %s\n",
|
sb.WriteString(fmt.Sprintf(" %s %s\n",
|
||||||
labelStyle.Render("路由策略:"),
|
labelStyle.Render(T("routing_strategy")+":"),
|
||||||
valueStyle.Render(strategy)))
|
valueStyle.Render(strategy)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,12 +246,12 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m
|
|||||||
if usage != nil {
|
if usage != nil {
|
||||||
if usageMap, ok := usage["usage"].(map[string]any); ok {
|
if usageMap, ok := usage["usage"].(map[string]any); ok {
|
||||||
if apis, ok := usageMap["apis"].(map[string]any); ok && len(apis) > 0 {
|
if apis, ok := usageMap["apis"].(map[string]any); ok && len(apis) > 0 {
|
||||||
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("模型统计"))
|
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("model_stats")))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
|
sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
header := fmt.Sprintf(" %-40s %10s %12s", "Model", "Requests", "Tokens")
|
header := fmt.Sprintf(" %-40s %10s %12s", T("model"), T("requests"), T("tokens"))
|
||||||
sb.WriteString(tableHeaderStyle.Render(header))
|
sb.WriteString(tableHeaderStyle.Render(header))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
@@ -315,9 +314,9 @@ func getBool(m map[string]any, key string) bool {
|
|||||||
|
|
||||||
func boolEmoji(b bool) string {
|
func boolEmoji(b bool) string {
|
||||||
if b {
|
if b {
|
||||||
return "是 ✓"
|
return T("bool_yes")
|
||||||
}
|
}
|
||||||
return "否"
|
return T("bool_no")
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatLargeNumber(n int64) string {
|
func formatLargeNumber(n int64) string {
|
||||||
|
|||||||
350
internal/tui/i18n.go
Normal file
350
internal/tui/i18n.go
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
// i18n provides a simple internationalization system for the TUI.
|
||||||
|
// Supported locales: "zh" (Chinese, default), "en" (English).
|
||||||
|
|
||||||
|
var currentLocale = "zh"
|
||||||
|
|
||||||
|
// SetLocale changes the active locale.
|
||||||
|
func SetLocale(locale string) {
|
||||||
|
if _, ok := locales[locale]; ok {
|
||||||
|
currentLocale = locale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentLocale returns the active locale code.
|
||||||
|
func CurrentLocale() string {
|
||||||
|
return currentLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleLocale switches between zh and en.
|
||||||
|
func ToggleLocale() {
|
||||||
|
if currentLocale == "zh" {
|
||||||
|
currentLocale = "en"
|
||||||
|
} else {
|
||||||
|
currentLocale = "zh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// T returns the translated string for the given key.
|
||||||
|
func T(key string) string {
|
||||||
|
if m, ok := locales[currentLocale]; ok {
|
||||||
|
if v, ok := m[key]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to English
|
||||||
|
if m, ok := locales["en"]; ok {
|
||||||
|
if v, ok := m[key]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
var locales = map[string]map[string]string{
|
||||||
|
"zh": zhStrings,
|
||||||
|
"en": enStrings,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────
|
||||||
|
// Tab names
|
||||||
|
// ──────────────────────────────────────────
|
||||||
|
var zhTabNames = []string{"仪表盘", "配置", "认证文件", "API 密钥", "OAuth", "使用统计", "日志"}
|
||||||
|
var enTabNames = []string{"Dashboard", "Config", "Auth Files", "API Keys", "OAuth", "Usage", "Logs"}
|
||||||
|
|
||||||
|
// TabNames returns tab names in the current locale.
|
||||||
|
func TabNames() []string {
|
||||||
|
if currentLocale == "zh" {
|
||||||
|
return zhTabNames
|
||||||
|
}
|
||||||
|
return enTabNames
|
||||||
|
}
|
||||||
|
|
||||||
|
var zhStrings = map[string]string{
|
||||||
|
// ── Common ──
|
||||||
|
"loading": "加载中...",
|
||||||
|
"refresh": "刷新",
|
||||||
|
"save": "保存",
|
||||||
|
"cancel": "取消",
|
||||||
|
"confirm": "确认",
|
||||||
|
"yes": "是",
|
||||||
|
"no": "否",
|
||||||
|
"error": "错误",
|
||||||
|
"success": "成功",
|
||||||
|
"navigate": "导航",
|
||||||
|
"scroll": "滚动",
|
||||||
|
"enter_save": "Enter: 保存",
|
||||||
|
"esc_cancel": "Esc: 取消",
|
||||||
|
"enter_submit": "Enter: 提交",
|
||||||
|
"press_r": "[r] 刷新",
|
||||||
|
"press_scroll": "[↑↓] 滚动",
|
||||||
|
"not_set": "(未设置)",
|
||||||
|
"error_prefix": "⚠ 错误: ",
|
||||||
|
|
||||||
|
// ── Status bar ──
|
||||||
|
"status_left": " CLIProxyAPI 管理终端",
|
||||||
|
"status_right": "Tab/Shift+Tab: 切换 • L: 语言 • q/Ctrl+C: 退出 ",
|
||||||
|
"initializing_tui": "正在初始化...",
|
||||||
|
|
||||||
|
// ── Dashboard ──
|
||||||
|
"dashboard_title": "📊 仪表盘",
|
||||||
|
"dashboard_help": " [r] 刷新 • [↑↓] 滚动",
|
||||||
|
"connected": "● 已连接",
|
||||||
|
"mgmt_keys": "管理密钥",
|
||||||
|
"auth_files_label": "认证文件",
|
||||||
|
"active_suffix": "活跃",
|
||||||
|
"total_requests": "请求",
|
||||||
|
"success_label": "成功",
|
||||||
|
"failure_label": "失败",
|
||||||
|
"total_tokens": "总 Tokens",
|
||||||
|
"current_config": "当前配置",
|
||||||
|
"debug_mode": "启用调试模式",
|
||||||
|
"usage_stats": "启用使用统计",
|
||||||
|
"log_to_file": "启用日志记录到文件",
|
||||||
|
"retry_count": "重试次数",
|
||||||
|
"proxy_url": "代理 URL",
|
||||||
|
"routing_strategy": "路由策略",
|
||||||
|
"model_stats": "模型统计",
|
||||||
|
"model": "模型",
|
||||||
|
"requests": "请求数",
|
||||||
|
"tokens": "Tokens",
|
||||||
|
"bool_yes": "是 ✓",
|
||||||
|
"bool_no": "否",
|
||||||
|
|
||||||
|
// ── Config ──
|
||||||
|
"config_title": "⚙ 配置",
|
||||||
|
"config_help1": " [↑↓/jk] 导航 • [Enter/Space] 编辑 • [r] 刷新",
|
||||||
|
"config_help2": " 布尔: Enter 切换 • 文本/数字: Enter 输入, Enter 确认, Esc 取消",
|
||||||
|
"updated_ok": "✓ 更新成功",
|
||||||
|
"no_config": " 未加载配置",
|
||||||
|
"invalid_int": "无效整数",
|
||||||
|
"section_server": "服务器",
|
||||||
|
"section_logging": "日志与统计",
|
||||||
|
"section_quota": "配额超限处理",
|
||||||
|
"section_routing": "路由",
|
||||||
|
"section_websocket": "WebSocket",
|
||||||
|
"section_ampcode": "AMP Code",
|
||||||
|
"section_other": "其他",
|
||||||
|
|
||||||
|
// ── Auth Files ──
|
||||||
|
"auth_title": "🔑 认证文件",
|
||||||
|
"auth_help1": " [↑↓/jk] 导航 • [Enter] 展开 • [e] 启用/停用 • [d] 删除 • [r] 刷新",
|
||||||
|
"auth_help2": " [1] 编辑 prefix • [2] 编辑 proxy_url • [3] 编辑 priority",
|
||||||
|
"no_auth_files": " 无认证文件",
|
||||||
|
"confirm_delete": "⚠ 删除 %s? [y/n]",
|
||||||
|
"deleted": "已删除 %s",
|
||||||
|
"enabled": "已启用",
|
||||||
|
"disabled": "已停用",
|
||||||
|
"updated_field": "已更新 %s 的 %s",
|
||||||
|
"status_active": "活跃",
|
||||||
|
"status_disabled": "已停用",
|
||||||
|
|
||||||
|
// ── API Keys ──
|
||||||
|
"keys_title": "🔐 API 密钥",
|
||||||
|
"keys_help": " [↑↓/jk] 导航 • [a] 添加 • [e] 编辑 • [d] 删除 • [c] 复制 • [r] 刷新",
|
||||||
|
"no_keys": " 无 API Key,按 [a] 添加",
|
||||||
|
"access_keys": "Access API Keys",
|
||||||
|
"confirm_delete_key": "⚠ 确认删除 %s? [y/n]",
|
||||||
|
"key_added": "已添加 API Key",
|
||||||
|
"key_updated": "已更新 API Key",
|
||||||
|
"key_deleted": "已删除 API Key",
|
||||||
|
"copied": "✓ 已复制到剪贴板",
|
||||||
|
"copy_failed": "✗ 复制失败",
|
||||||
|
"new_key_prompt": " New Key: ",
|
||||||
|
"edit_key_prompt": " Edit Key: ",
|
||||||
|
"enter_add": " Enter: 添加 • Esc: 取消",
|
||||||
|
"enter_save_esc": " Enter: 保存 • Esc: 取消",
|
||||||
|
|
||||||
|
// ── OAuth ──
|
||||||
|
"oauth_title": "🔐 OAuth 登录",
|
||||||
|
"oauth_select": " 选择提供商并按 [Enter] 开始 OAuth 登录:",
|
||||||
|
"oauth_help": " [↑↓/jk] 导航 • [Enter] 登录 • [Esc] 清除状态",
|
||||||
|
"oauth_initiating": "⏳ 正在初始化 %s 登录...",
|
||||||
|
"oauth_success": "认证成功! 请刷新 Auth Files 标签查看新凭证。",
|
||||||
|
"oauth_completed": "认证流程已完成。",
|
||||||
|
"oauth_failed": "认证失败",
|
||||||
|
"oauth_timeout": "OAuth 流程超时 (5 分钟)",
|
||||||
|
"oauth_press_esc": " 按 [Esc] 取消",
|
||||||
|
"oauth_auth_url": " 授权链接:",
|
||||||
|
"oauth_remote_hint": " 远程浏览器模式:在浏览器中打开上述链接完成授权后,将回调 URL 粘贴到下方。",
|
||||||
|
"oauth_callback_url": " 回调 URL:",
|
||||||
|
"oauth_press_c": " 按 [c] 输入回调 URL • [Esc] 返回",
|
||||||
|
"oauth_submitting": "⏳ 提交回调中...",
|
||||||
|
"oauth_submit_ok": "✓ 回调已提交,等待处理...",
|
||||||
|
"oauth_submit_fail": "✗ 提交回调失败",
|
||||||
|
"oauth_waiting": " 等待认证中...",
|
||||||
|
|
||||||
|
// ── Usage ──
|
||||||
|
"usage_title": "📈 使用统计",
|
||||||
|
"usage_help": " [r] 刷新 • [↑↓] 滚动",
|
||||||
|
"usage_no_data": " 使用数据不可用",
|
||||||
|
"usage_total_reqs": "总请求数",
|
||||||
|
"usage_total_tokens": "总 Token 数",
|
||||||
|
"usage_success": "成功",
|
||||||
|
"usage_failure": "失败",
|
||||||
|
"usage_total_token_l": "总Token",
|
||||||
|
"usage_rpm": "RPM",
|
||||||
|
"usage_tpm": "TPM",
|
||||||
|
"usage_req_by_hour": "请求趋势 (按小时)",
|
||||||
|
"usage_tok_by_hour": "Token 使用趋势 (按小时)",
|
||||||
|
"usage_req_by_day": "请求趋势 (按天)",
|
||||||
|
"usage_api_detail": "API 详细统计",
|
||||||
|
"usage_input": "输入",
|
||||||
|
"usage_output": "输出",
|
||||||
|
"usage_cached": "缓存",
|
||||||
|
"usage_reasoning": "思考",
|
||||||
|
|
||||||
|
// ── Logs ──
|
||||||
|
"logs_title": "📋 日志",
|
||||||
|
"logs_auto_scroll": "● 自动滚动",
|
||||||
|
"logs_paused": "○ 已暂停",
|
||||||
|
"logs_filter": "过滤",
|
||||||
|
"logs_lines": "行数",
|
||||||
|
"logs_help": " [a] 自动滚动 • [c] 清除 • [1] 全部 [2] info+ [3] warn+ [4] error • [↑↓] 滚动",
|
||||||
|
"logs_waiting": " 等待日志输出...",
|
||||||
|
}
|
||||||
|
|
||||||
|
var enStrings = map[string]string{
|
||||||
|
// ── Common ──
|
||||||
|
"loading": "Loading...",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
"error": "Error",
|
||||||
|
"success": "Success",
|
||||||
|
"navigate": "Navigate",
|
||||||
|
"scroll": "Scroll",
|
||||||
|
"enter_save": "Enter: Save",
|
||||||
|
"esc_cancel": "Esc: Cancel",
|
||||||
|
"enter_submit": "Enter: Submit",
|
||||||
|
"press_r": "[r] Refresh",
|
||||||
|
"press_scroll": "[↑↓] Scroll",
|
||||||
|
"not_set": "(not set)",
|
||||||
|
"error_prefix": "⚠ Error: ",
|
||||||
|
|
||||||
|
// ── Status bar ──
|
||||||
|
"status_left": " CLIProxyAPI Management TUI",
|
||||||
|
"status_right": "Tab/Shift+Tab: switch • L: lang • q/Ctrl+C: quit ",
|
||||||
|
"initializing_tui": "Initializing...",
|
||||||
|
|
||||||
|
// ── Dashboard ──
|
||||||
|
"dashboard_title": "📊 Dashboard",
|
||||||
|
"dashboard_help": " [r] Refresh • [↑↓] Scroll",
|
||||||
|
"connected": "● Connected",
|
||||||
|
"mgmt_keys": "Mgmt Keys",
|
||||||
|
"auth_files_label": "Auth Files",
|
||||||
|
"active_suffix": "active",
|
||||||
|
"total_requests": "Requests",
|
||||||
|
"success_label": "Success",
|
||||||
|
"failure_label": "Failed",
|
||||||
|
"total_tokens": "Total Tokens",
|
||||||
|
"current_config": "Current Config",
|
||||||
|
"debug_mode": "Debug Mode",
|
||||||
|
"usage_stats": "Usage Statistics",
|
||||||
|
"log_to_file": "Log to File",
|
||||||
|
"retry_count": "Retry Count",
|
||||||
|
"proxy_url": "Proxy URL",
|
||||||
|
"routing_strategy": "Routing Strategy",
|
||||||
|
"model_stats": "Model Stats",
|
||||||
|
"model": "Model",
|
||||||
|
"requests": "Requests",
|
||||||
|
"tokens": "Tokens",
|
||||||
|
"bool_yes": "Yes ✓",
|
||||||
|
"bool_no": "No",
|
||||||
|
|
||||||
|
// ── Config ──
|
||||||
|
"config_title": "⚙ Configuration",
|
||||||
|
"config_help1": " [↑↓/jk] Navigate • [Enter/Space] Edit • [r] Refresh",
|
||||||
|
"config_help2": " Bool: Enter to toggle • String/Int: Enter to type, Enter to confirm, Esc to cancel",
|
||||||
|
"updated_ok": "✓ Updated successfully",
|
||||||
|
"no_config": " No configuration loaded",
|
||||||
|
"invalid_int": "invalid integer",
|
||||||
|
"section_server": "Server",
|
||||||
|
"section_logging": "Logging & Stats",
|
||||||
|
"section_quota": "Quota Exceeded Handling",
|
||||||
|
"section_routing": "Routing",
|
||||||
|
"section_websocket": "WebSocket",
|
||||||
|
"section_ampcode": "AMP Code",
|
||||||
|
"section_other": "Other",
|
||||||
|
|
||||||
|
// ── Auth Files ──
|
||||||
|
"auth_title": "🔑 Auth Files",
|
||||||
|
"auth_help1": " [↑↓/jk] Navigate • [Enter] Expand • [e] Enable/Disable • [d] Delete • [r] Refresh",
|
||||||
|
"auth_help2": " [1] Edit prefix • [2] Edit proxy_url • [3] Edit priority",
|
||||||
|
"no_auth_files": " No auth files found",
|
||||||
|
"confirm_delete": "⚠ Delete %s? [y/n]",
|
||||||
|
"deleted": "Deleted %s",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"updated_field": "Updated %s on %s",
|
||||||
|
"status_active": "active",
|
||||||
|
"status_disabled": "disabled",
|
||||||
|
|
||||||
|
// ── API Keys ──
|
||||||
|
"keys_title": "🔐 API Keys",
|
||||||
|
"keys_help": " [↑↓/jk] Navigate • [a] Add • [e] Edit • [d] Delete • [c] Copy • [r] Refresh",
|
||||||
|
"no_keys": " No API Keys. Press [a] to add",
|
||||||
|
"access_keys": "Access API Keys",
|
||||||
|
"confirm_delete_key": "⚠ Delete %s? [y/n]",
|
||||||
|
"key_added": "API Key added",
|
||||||
|
"key_updated": "API Key updated",
|
||||||
|
"key_deleted": "API Key deleted",
|
||||||
|
"copied": "✓ Copied to clipboard",
|
||||||
|
"copy_failed": "✗ Copy failed",
|
||||||
|
"new_key_prompt": " New Key: ",
|
||||||
|
"edit_key_prompt": " Edit Key: ",
|
||||||
|
"enter_add": " Enter: Add • Esc: Cancel",
|
||||||
|
"enter_save_esc": " Enter: Save • Esc: Cancel",
|
||||||
|
|
||||||
|
// ── OAuth ──
|
||||||
|
"oauth_title": "🔐 OAuth Login",
|
||||||
|
"oauth_select": " Select a provider and press [Enter] to start OAuth login:",
|
||||||
|
"oauth_help": " [↑↓/jk] Navigate • [Enter] Login • [Esc] Clear status",
|
||||||
|
"oauth_initiating": "⏳ Initiating %s login...",
|
||||||
|
"oauth_success": "Authentication successful! Refresh Auth Files tab to see the new credential.",
|
||||||
|
"oauth_completed": "Authentication flow completed.",
|
||||||
|
"oauth_failed": "Authentication failed",
|
||||||
|
"oauth_timeout": "OAuth flow timed out (5 minutes)",
|
||||||
|
"oauth_press_esc": " Press [Esc] to cancel",
|
||||||
|
"oauth_auth_url": " Authorization URL:",
|
||||||
|
"oauth_remote_hint": " Remote browser mode: Open the URL above in browser, paste the callback URL below after authorization.",
|
||||||
|
"oauth_callback_url": " Callback URL:",
|
||||||
|
"oauth_press_c": " Press [c] to enter callback URL • [Esc] to go back",
|
||||||
|
"oauth_submitting": "⏳ Submitting callback...",
|
||||||
|
"oauth_submit_ok": "✓ Callback submitted, waiting...",
|
||||||
|
"oauth_submit_fail": "✗ Callback submission failed",
|
||||||
|
"oauth_waiting": " Waiting for authentication...",
|
||||||
|
|
||||||
|
// ── Usage ──
|
||||||
|
"usage_title": "📈 Usage Statistics",
|
||||||
|
"usage_help": " [r] Refresh • [↑↓] Scroll",
|
||||||
|
"usage_no_data": " Usage data not available",
|
||||||
|
"usage_total_reqs": "Total Requests",
|
||||||
|
"usage_total_tokens": "Total Tokens",
|
||||||
|
"usage_success": "Success",
|
||||||
|
"usage_failure": "Failed",
|
||||||
|
"usage_total_token_l": "Total Tokens",
|
||||||
|
"usage_rpm": "RPM",
|
||||||
|
"usage_tpm": "TPM",
|
||||||
|
"usage_req_by_hour": "Requests by Hour",
|
||||||
|
"usage_tok_by_hour": "Token Usage by Hour",
|
||||||
|
"usage_req_by_day": "Requests by Day",
|
||||||
|
"usage_api_detail": "API Detail Statistics",
|
||||||
|
"usage_input": "Input",
|
||||||
|
"usage_output": "Output",
|
||||||
|
"usage_cached": "Cached",
|
||||||
|
"usage_reasoning": "Reasoning",
|
||||||
|
|
||||||
|
// ── Logs ──
|
||||||
|
"logs_title": "📋 Logs",
|
||||||
|
"logs_auto_scroll": "● AUTO-SCROLL",
|
||||||
|
"logs_paused": "○ PAUSED",
|
||||||
|
"logs_filter": "Filter",
|
||||||
|
"logs_lines": "Lines",
|
||||||
|
"logs_help": " [a] Auto-scroll • [c] Clear • [1] All [2] info+ [3] warn+ [4] error • [↑↓] Scroll",
|
||||||
|
"logs_waiting": " Waiting for log output...",
|
||||||
|
}
|
||||||
@@ -4,19 +4,36 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/atotto/clipboard"
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
// keysTabModel displays API keys from all providers.
|
// keysTabModel displays and manages API keys.
|
||||||
type keysTabModel struct {
|
type keysTabModel struct {
|
||||||
client *Client
|
client *Client
|
||||||
viewport viewport.Model
|
viewport viewport.Model
|
||||||
content string
|
keys []string
|
||||||
|
gemini []map[string]any
|
||||||
|
claude []map[string]any
|
||||||
|
codex []map[string]any
|
||||||
|
vertex []map[string]any
|
||||||
|
openai []map[string]any
|
||||||
err error
|
err error
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
ready bool
|
ready bool
|
||||||
|
cursor int
|
||||||
|
confirm int // -1 = no deletion pending
|
||||||
|
status string
|
||||||
|
|
||||||
|
// Editing / Adding
|
||||||
|
editing bool
|
||||||
|
adding bool
|
||||||
|
editIdx int
|
||||||
|
editInput textinput.Model
|
||||||
}
|
}
|
||||||
|
|
||||||
type keysDataMsg struct {
|
type keysDataMsg struct {
|
||||||
@@ -29,9 +46,19 @@ type keysDataMsg struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type keyActionMsg struct {
|
||||||
|
action string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
func newKeysTabModel(client *Client) keysTabModel {
|
func newKeysTabModel(client *Client) keysTabModel {
|
||||||
|
ti := textinput.New()
|
||||||
|
ti.CharLimit = 512
|
||||||
|
ti.Prompt = " Key: "
|
||||||
return keysTabModel{
|
return keysTabModel{
|
||||||
client: client,
|
client: client,
|
||||||
|
confirm: -1,
|
||||||
|
editInput: ti,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,44 +68,185 @@ func (m keysTabModel) Init() tea.Cmd {
|
|||||||
|
|
||||||
func (m keysTabModel) fetchKeys() tea.Msg {
|
func (m keysTabModel) fetchKeys() tea.Msg {
|
||||||
result := keysDataMsg{}
|
result := keysDataMsg{}
|
||||||
|
|
||||||
apiKeys, err := m.client.GetAPIKeys()
|
apiKeys, err := m.client.GetAPIKeys()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.err = err
|
result.err = err
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
result.apiKeys = apiKeys
|
result.apiKeys = apiKeys
|
||||||
|
|
||||||
// Fetch all key types, ignoring individual errors (they may not be configured)
|
|
||||||
result.gemini, _ = m.client.GetGeminiKeys()
|
result.gemini, _ = m.client.GetGeminiKeys()
|
||||||
result.claude, _ = m.client.GetClaudeKeys()
|
result.claude, _ = m.client.GetClaudeKeys()
|
||||||
result.codex, _ = m.client.GetCodexKeys()
|
result.codex, _ = m.client.GetCodexKeys()
|
||||||
result.vertex, _ = m.client.GetVertexKeys()
|
result.vertex, _ = m.client.GetVertexKeys()
|
||||||
result.openai, _ = m.client.GetOpenAICompat()
|
result.openai, _ = m.client.GetOpenAICompat()
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m keysTabModel) Update(msg tea.Msg) (keysTabModel, tea.Cmd) {
|
func (m keysTabModel) Update(msg tea.Msg) (keysTabModel, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
case localeChangedMsg:
|
||||||
|
m.viewport.SetContent(m.renderContent())
|
||||||
|
return m, nil
|
||||||
case keysDataMsg:
|
case keysDataMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.err = msg.err
|
m.err = msg.err
|
||||||
m.content = errorStyle.Render("⚠ Error: " + msg.err.Error())
|
|
||||||
} else {
|
} else {
|
||||||
m.err = nil
|
m.err = nil
|
||||||
m.content = m.renderKeys(msg)
|
m.keys = msg.apiKeys
|
||||||
|
m.gemini = msg.gemini
|
||||||
|
m.claude = msg.claude
|
||||||
|
m.codex = msg.codex
|
||||||
|
m.vertex = msg.vertex
|
||||||
|
m.openai = msg.openai
|
||||||
|
if m.cursor >= len(m.keys) {
|
||||||
|
m.cursor = max(0, len(m.keys)-1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
m.viewport.SetContent(m.content)
|
m.viewport.SetContent(m.renderContent())
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case keyActionMsg:
|
||||||
if msg.String() == "r" {
|
if msg.err != nil {
|
||||||
return m, m.fetchKeys
|
m.status = errorStyle.Render("✗ " + msg.err.Error())
|
||||||
|
} else {
|
||||||
|
m.status = successStyle.Render("✓ " + msg.action)
|
||||||
|
}
|
||||||
|
m.confirm = -1
|
||||||
|
m.viewport.SetContent(m.renderContent())
|
||||||
|
return m, m.fetchKeys
|
||||||
|
|
||||||
|
case tea.KeyMsg:
|
||||||
|
// ---- Editing / Adding mode ----
|
||||||
|
if m.editing || m.adding {
|
||||||
|
switch msg.String() {
|
||||||
|
case "enter":
|
||||||
|
value := strings.TrimSpace(m.editInput.Value())
|
||||||
|
if value == "" {
|
||||||
|
m.editing = false
|
||||||
|
m.adding = false
|
||||||
|
m.editInput.Blur()
|
||||||
|
m.viewport.SetContent(m.renderContent())
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
isAdding := m.adding
|
||||||
|
editIdx := m.editIdx
|
||||||
|
m.editing = false
|
||||||
|
m.adding = false
|
||||||
|
m.editInput.Blur()
|
||||||
|
if isAdding {
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
err := m.client.AddAPIKey(value)
|
||||||
|
if err != nil {
|
||||||
|
return keyActionMsg{err: err}
|
||||||
|
}
|
||||||
|
return keyActionMsg{action: T("key_added")}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
err := m.client.EditAPIKey(editIdx, value)
|
||||||
|
if err != nil {
|
||||||
|
return keyActionMsg{err: err}
|
||||||
|
}
|
||||||
|
return keyActionMsg{action: T("key_updated")}
|
||||||
|
}
|
||||||
|
case "esc":
|
||||||
|
m.editing = false
|
||||||
|
m.adding = false
|
||||||
|
m.editInput.Blur()
|
||||||
|
m.viewport.SetContent(m.renderContent())
|
||||||
|
return m, nil
|
||||||
|
default:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.editInput, cmd = m.editInput.Update(msg)
|
||||||
|
m.viewport.SetContent(m.renderContent())
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Delete confirmation ----
|
||||||
|
if m.confirm >= 0 {
|
||||||
|
switch msg.String() {
|
||||||
|
case "y", "Y":
|
||||||
|
idx := m.confirm
|
||||||
|
m.confirm = -1
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
err := m.client.DeleteAPIKey(idx)
|
||||||
|
if err != nil {
|
||||||
|
return keyActionMsg{err: err}
|
||||||
|
}
|
||||||
|
return keyActionMsg{action: T("key_deleted")}
|
||||||
|
}
|
||||||
|
case "n", "N", "esc":
|
||||||
|
m.confirm = -1
|
||||||
|
m.viewport.SetContent(m.renderContent())
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Normal mode ----
|
||||||
|
switch msg.String() {
|
||||||
|
case "j", "down":
|
||||||
|
if len(m.keys) > 0 {
|
||||||
|
m.cursor = (m.cursor + 1) % len(m.keys)
|
||||||
|
m.viewport.SetContent(m.renderContent())
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case "k", "up":
|
||||||
|
if len(m.keys) > 0 {
|
||||||
|
m.cursor = (m.cursor - 1 + len(m.keys)) % len(m.keys)
|
||||||
|
m.viewport.SetContent(m.renderContent())
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case "a":
|
||||||
|
// Add new key
|
||||||
|
m.adding = true
|
||||||
|
m.editing = false
|
||||||
|
m.editInput.SetValue("")
|
||||||
|
m.editInput.Prompt = T("new_key_prompt")
|
||||||
|
m.editInput.Focus()
|
||||||
|
m.viewport.SetContent(m.renderContent())
|
||||||
|
return m, textinput.Blink
|
||||||
|
case "e":
|
||||||
|
// Edit selected key
|
||||||
|
if m.cursor < len(m.keys) {
|
||||||
|
m.editing = true
|
||||||
|
m.adding = false
|
||||||
|
m.editIdx = m.cursor
|
||||||
|
m.editInput.SetValue(m.keys[m.cursor])
|
||||||
|
m.editInput.Prompt = T("edit_key_prompt")
|
||||||
|
m.editInput.Focus()
|
||||||
|
m.viewport.SetContent(m.renderContent())
|
||||||
|
return m, textinput.Blink
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case "d":
|
||||||
|
// Delete selected key
|
||||||
|
if m.cursor < len(m.keys) {
|
||||||
|
m.confirm = m.cursor
|
||||||
|
m.viewport.SetContent(m.renderContent())
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case "c":
|
||||||
|
// Copy selected key to clipboard
|
||||||
|
if m.cursor < len(m.keys) {
|
||||||
|
key := m.keys[m.cursor]
|
||||||
|
if err := clipboard.WriteAll(key); err != nil {
|
||||||
|
m.status = errorStyle.Render(T("copy_failed") + ": " + err.Error())
|
||||||
|
} else {
|
||||||
|
m.status = successStyle.Render(T("copied"))
|
||||||
|
}
|
||||||
|
m.viewport.SetContent(m.renderContent())
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case "r":
|
||||||
|
m.status = ""
|
||||||
|
return m, m.fetchKeys
|
||||||
|
default:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.viewport, cmd = m.viewport.Update(msg)
|
||||||
|
return m, cmd
|
||||||
}
|
}
|
||||||
var cmd tea.Cmd
|
|
||||||
m.viewport, cmd = m.viewport.Update(msg)
|
|
||||||
return m, cmd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
@@ -89,9 +257,10 @@ func (m keysTabModel) Update(msg tea.Msg) (keysTabModel, tea.Cmd) {
|
|||||||
func (m *keysTabModel) SetSize(w, h int) {
|
func (m *keysTabModel) SetSize(w, h int) {
|
||||||
m.width = w
|
m.width = w
|
||||||
m.height = h
|
m.height = h
|
||||||
|
m.editInput.Width = w - 16
|
||||||
if !m.ready {
|
if !m.ready {
|
||||||
m.viewport = viewport.New(w, h)
|
m.viewport = viewport.New(w, h)
|
||||||
m.viewport.SetContent(m.content)
|
m.viewport.SetContent(m.renderContent())
|
||||||
m.ready = true
|
m.ready = true
|
||||||
} else {
|
} else {
|
||||||
m.viewport.Width = w
|
m.viewport.Width = w
|
||||||
@@ -101,40 +270,83 @@ func (m *keysTabModel) SetSize(w, h int) {
|
|||||||
|
|
||||||
func (m keysTabModel) View() string {
|
func (m keysTabModel) View() string {
|
||||||
if !m.ready {
|
if !m.ready {
|
||||||
return "Loading..."
|
return T("loading")
|
||||||
}
|
}
|
||||||
return m.viewport.View()
|
return m.viewport.View()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m keysTabModel) renderKeys(data keysDataMsg) string {
|
func (m keysTabModel) renderContent() string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
sb.WriteString(titleStyle.Render("🔐 API Keys"))
|
sb.WriteString(titleStyle.Render(T("keys_title")))
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(helpStyle.Render(T("keys_help")))
|
||||||
// API Keys (access keys)
|
sb.WriteString("\n")
|
||||||
renderSection(&sb, "Access API Keys", len(data.apiKeys))
|
sb.WriteString(strings.Repeat("─", m.width))
|
||||||
for i, key := range data.apiKeys {
|
|
||||||
sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, maskKey(key)))
|
|
||||||
}
|
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
// Gemini Keys
|
if m.err != nil {
|
||||||
renderProviderKeys(&sb, "Gemini API Keys", data.gemini)
|
sb.WriteString(errorStyle.Render(T("error_prefix") + m.err.Error()))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
// Claude Keys
|
// ━━━ Access API Keys (interactive) ━━━
|
||||||
renderProviderKeys(&sb, "Claude API Keys", data.claude)
|
sb.WriteString(tableHeaderStyle.Render(fmt.Sprintf(" %s (%d)", T("access_keys"), len(m.keys))))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
// Codex Keys
|
if len(m.keys) == 0 {
|
||||||
renderProviderKeys(&sb, "Codex API Keys", data.codex)
|
sb.WriteString(subtitleStyle.Render(T("no_keys")))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
// Vertex Keys
|
for i, key := range m.keys {
|
||||||
renderProviderKeys(&sb, "Vertex API Keys", data.vertex)
|
cursor := " "
|
||||||
|
rowStyle := lipgloss.NewStyle()
|
||||||
|
if i == m.cursor {
|
||||||
|
cursor = "▸ "
|
||||||
|
rowStyle = lipgloss.NewStyle().Bold(true)
|
||||||
|
}
|
||||||
|
|
||||||
// OpenAI Compatibility
|
row := fmt.Sprintf("%s%d. %s", cursor, i+1, maskKey(key))
|
||||||
if len(data.openai) > 0 {
|
sb.WriteString(rowStyle.Render(row))
|
||||||
renderSection(&sb, "OpenAI Compatibility", len(data.openai))
|
sb.WriteString("\n")
|
||||||
for i, entry := range data.openai {
|
|
||||||
|
// Delete confirmation
|
||||||
|
if m.confirm == i {
|
||||||
|
sb.WriteString(warningStyle.Render(fmt.Sprintf(" "+T("confirm_delete_key"), maskKey(key))))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit input
|
||||||
|
if m.editing && m.editIdx == i {
|
||||||
|
sb.WriteString(m.editInput.View())
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(helpStyle.Render(T("enter_save_esc")))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add input
|
||||||
|
if m.adding {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(m.editInput.View())
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(helpStyle.Render(T("enter_add")))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
// ━━━ Provider Keys (read-only display) ━━━
|
||||||
|
renderProviderKeys(&sb, "Gemini API Keys", m.gemini)
|
||||||
|
renderProviderKeys(&sb, "Claude API Keys", m.claude)
|
||||||
|
renderProviderKeys(&sb, "Codex API Keys", m.codex)
|
||||||
|
renderProviderKeys(&sb, "Vertex API Keys", m.vertex)
|
||||||
|
|
||||||
|
if len(m.openai) > 0 {
|
||||||
|
renderSection(&sb, "OpenAI Compatibility", len(m.openai))
|
||||||
|
for i, entry := range m.openai {
|
||||||
name := getString(entry, "name")
|
name := getString(entry, "name")
|
||||||
baseURL := getString(entry, "base-url")
|
baseURL := getString(entry, "base-url")
|
||||||
prefix := getString(entry, "prefix")
|
prefix := getString(entry, "prefix")
|
||||||
@@ -150,7 +362,10 @@ func (m keysTabModel) renderKeys(data keysDataMsg) string {
|
|||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.WriteString(helpStyle.Render("Press [r] to refresh • [↑↓] to scroll"))
|
if m.status != "" {
|
||||||
|
sb.WriteString(m.status)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ func (m logsTabModel) waitForLog() tea.Msg {
|
|||||||
|
|
||||||
func (m logsTabModel) Update(msg tea.Msg) (logsTabModel, tea.Cmd) {
|
func (m logsTabModel) Update(msg tea.Msg) (logsTabModel, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
case localeChangedMsg:
|
||||||
|
m.viewport.SetContent(m.renderLogs())
|
||||||
|
return m, nil
|
||||||
case logLineMsg:
|
case logLineMsg:
|
||||||
m.lines = append(m.lines, string(msg))
|
m.lines = append(m.lines, string(msg))
|
||||||
if len(m.lines) > m.maxLines {
|
if len(m.lines) > m.maxLines {
|
||||||
@@ -122,7 +125,7 @@ func (m *logsTabModel) SetSize(w, h int) {
|
|||||||
|
|
||||||
func (m logsTabModel) View() string {
|
func (m logsTabModel) View() string {
|
||||||
if !m.ready {
|
if !m.ready {
|
||||||
return "Loading logs..."
|
return T("loading")
|
||||||
}
|
}
|
||||||
return m.viewport.View()
|
return m.viewport.View()
|
||||||
}
|
}
|
||||||
@@ -130,26 +133,26 @@ func (m logsTabModel) View() string {
|
|||||||
func (m logsTabModel) renderLogs() string {
|
func (m logsTabModel) renderLogs() string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
scrollStatus := successStyle.Render("● AUTO-SCROLL")
|
scrollStatus := successStyle.Render(T("logs_auto_scroll"))
|
||||||
if !m.autoScroll {
|
if !m.autoScroll {
|
||||||
scrollStatus = warningStyle.Render("○ PAUSED")
|
scrollStatus = warningStyle.Render(T("logs_paused"))
|
||||||
}
|
}
|
||||||
filterLabel := "ALL"
|
filterLabel := "ALL"
|
||||||
if m.filter != "" {
|
if m.filter != "" {
|
||||||
filterLabel = strings.ToUpper(m.filter) + "+"
|
filterLabel = strings.ToUpper(m.filter) + "+"
|
||||||
}
|
}
|
||||||
|
|
||||||
header := fmt.Sprintf(" 📋 Logs %s Filter: %s Lines: %d",
|
header := fmt.Sprintf(" %s %s %s: %s %s: %d",
|
||||||
scrollStatus, filterLabel, len(m.lines))
|
T("logs_title"), scrollStatus, T("logs_filter"), filterLabel, T("logs_lines"), len(m.lines))
|
||||||
sb.WriteString(titleStyle.Render(header))
|
sb.WriteString(titleStyle.Render(header))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(helpStyle.Render(" [a]uto-scroll • [c]lear • [1]all [2]info+ [3]warn+ [4]error • [↑↓] scroll"))
|
sb.WriteString(helpStyle.Render(T("logs_help")))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(strings.Repeat("─", m.width))
|
sb.WriteString(strings.Repeat("─", m.width))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
if len(m.lines) == 0 {
|
if len(m.lines) == 0 {
|
||||||
sb.WriteString(subtitleStyle.Render("\n Waiting for log output..."))
|
sb.WriteString(subtitleStyle.Render(T("logs_waiting")))
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ func (m oauthTabModel) Init() tea.Cmd {
|
|||||||
|
|
||||||
func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) {
|
func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
case localeChangedMsg:
|
||||||
|
m.viewport.SetContent(m.renderContent())
|
||||||
|
return m, nil
|
||||||
case oauthStartMsg:
|
case oauthStartMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.state = oauthError
|
m.state = oauthError
|
||||||
@@ -133,9 +136,9 @@ func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) {
|
|||||||
|
|
||||||
case oauthCallbackSubmitMsg:
|
case oauthCallbackSubmitMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.message = errorStyle.Render("✗ 提交回调失败: " + msg.err.Error())
|
m.message = errorStyle.Render(T("oauth_submit_fail") + ": " + msg.err.Error())
|
||||||
} else {
|
} else {
|
||||||
m.message = successStyle.Render("✓ 回调已提交,等待处理...")
|
m.message = successStyle.Render(T("oauth_submit_ok"))
|
||||||
}
|
}
|
||||||
m.viewport.SetContent(m.renderContent())
|
m.viewport.SetContent(m.renderContent())
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -151,7 +154,7 @@ func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.inputActive = false
|
m.inputActive = false
|
||||||
m.callbackInput.Blur()
|
m.callbackInput.Blur()
|
||||||
m.message = warningStyle.Render("⏳ 提交回调中...")
|
m.message = warningStyle.Render(T("oauth_submitting"))
|
||||||
m.viewport.SetContent(m.renderContent())
|
m.viewport.SetContent(m.renderContent())
|
||||||
return m, m.submitCallback(callbackURL)
|
return m, m.submitCallback(callbackURL)
|
||||||
case "esc":
|
case "esc":
|
||||||
@@ -217,7 +220,7 @@ func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) {
|
|||||||
if m.cursor >= 0 && m.cursor < len(oauthProviders) {
|
if m.cursor >= 0 && m.cursor < len(oauthProviders) {
|
||||||
provider := oauthProviders[m.cursor]
|
provider := oauthProviders[m.cursor]
|
||||||
m.state = oauthPending
|
m.state = oauthPending
|
||||||
m.message = warningStyle.Render("⏳ 正在初始化 " + provider.name + " 登录...")
|
m.message = warningStyle.Render(fmt.Sprintf(T("oauth_initiating"), provider.name))
|
||||||
m.viewport.SetContent(m.renderContent())
|
m.viewport.SetContent(m.renderContent())
|
||||||
return m, m.startOAuth(provider)
|
return m, m.startOAuth(provider)
|
||||||
}
|
}
|
||||||
@@ -307,7 +310,7 @@ func (m oauthTabModel) pollOAuthStatus(state string) tea.Cmd {
|
|||||||
deadline := time.Now().Add(5 * time.Minute)
|
deadline := time.Now().Add(5 * time.Minute)
|
||||||
for {
|
for {
|
||||||
if time.Now().After(deadline) {
|
if time.Now().After(deadline) {
|
||||||
return oauthPollMsg{done: false, err: fmt.Errorf("OAuth flow timed out (5 minutes)")}
|
return oauthPollMsg{done: false, err: fmt.Errorf("%s", T("oauth_timeout"))}
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
@@ -321,19 +324,19 @@ func (m oauthTabModel) pollOAuthStatus(state string) tea.Cmd {
|
|||||||
case "ok":
|
case "ok":
|
||||||
return oauthPollMsg{
|
return oauthPollMsg{
|
||||||
done: true,
|
done: true,
|
||||||
message: "认证成功! 请刷新 Auth Files 标签查看新凭证。",
|
message: T("oauth_success"),
|
||||||
}
|
}
|
||||||
case "error":
|
case "error":
|
||||||
return oauthPollMsg{
|
return oauthPollMsg{
|
||||||
done: false,
|
done: false,
|
||||||
err: fmt.Errorf("认证失败: %s", errMsg),
|
err: fmt.Errorf("%s: %s", T("oauth_failed"), errMsg),
|
||||||
}
|
}
|
||||||
case "wait":
|
case "wait":
|
||||||
continue
|
continue
|
||||||
default:
|
default:
|
||||||
return oauthPollMsg{
|
return oauthPollMsg{
|
||||||
done: true,
|
done: true,
|
||||||
message: "认证流程已完成。",
|
message: T("oauth_completed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -356,7 +359,7 @@ func (m *oauthTabModel) SetSize(w, h int) {
|
|||||||
|
|
||||||
func (m oauthTabModel) View() string {
|
func (m oauthTabModel) View() string {
|
||||||
if !m.ready {
|
if !m.ready {
|
||||||
return "Loading..."
|
return T("loading")
|
||||||
}
|
}
|
||||||
return m.viewport.View()
|
return m.viewport.View()
|
||||||
}
|
}
|
||||||
@@ -364,7 +367,7 @@ func (m oauthTabModel) View() string {
|
|||||||
func (m oauthTabModel) renderContent() string {
|
func (m oauthTabModel) renderContent() string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
sb.WriteString(titleStyle.Render("🔐 OAuth 登录"))
|
sb.WriteString(titleStyle.Render(T("oauth_title")))
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
if m.message != "" {
|
if m.message != "" {
|
||||||
@@ -379,11 +382,11 @@ func (m oauthTabModel) renderContent() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if m.state == oauthPending {
|
if m.state == oauthPending {
|
||||||
sb.WriteString(helpStyle.Render(" Press [Esc] to cancel"))
|
sb.WriteString(helpStyle.Render(T("oauth_press_esc")))
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.WriteString(helpStyle.Render(" 选择提供商并按 [Enter] 开始 OAuth 登录:"))
|
sb.WriteString(helpStyle.Render(T("oauth_select")))
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
for i, p := range oauthProviders {
|
for i, p := range oauthProviders {
|
||||||
@@ -404,7 +407,7 @@ func (m oauthTabModel) renderContent() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(helpStyle.Render(" [↑↓/jk] 导航 • [Enter] 登录 • [Esc] 清除状态"))
|
sb.WriteString(helpStyle.Render(T("oauth_help")))
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
@@ -417,7 +420,7 @@ func (m oauthTabModel) renderRemoteMode() string {
|
|||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
// Auth URL section
|
// Auth URL section
|
||||||
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(" 授权链接:"))
|
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(T("oauth_auth_url")))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
// Wrap URL to fit terminal width
|
// Wrap URL to fit terminal width
|
||||||
@@ -432,23 +435,23 @@ func (m oauthTabModel) renderRemoteMode() string {
|
|||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
sb.WriteString(helpStyle.Render(" 远程浏览器模式:在浏览器中打开上述链接完成授权后,将回调 URL 粘贴到下方。"))
|
sb.WriteString(helpStyle.Render(T("oauth_remote_hint")))
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
// Callback URL input
|
// Callback URL input
|
||||||
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(" 回调 URL:"))
|
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(T("oauth_callback_url")))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
if m.inputActive {
|
if m.inputActive {
|
||||||
sb.WriteString(m.callbackInput.View())
|
sb.WriteString(m.callbackInput.View())
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(helpStyle.Render(" Enter: 提交 • Esc: 取消输入"))
|
sb.WriteString(helpStyle.Render(" " + T("enter_submit") + " • " + T("esc_cancel")))
|
||||||
} else {
|
} else {
|
||||||
sb.WriteString(helpStyle.Render(" 按 [c] 输入回调 URL • [Esc] 返回"))
|
sb.WriteString(helpStyle.Render(T("oauth_press_c")))
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
sb.WriteString(warningStyle.Render(" 等待认证中..."))
|
sb.WriteString(warningStyle.Render(T("oauth_waiting")))
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ func (m usageTabModel) fetchData() tea.Msg {
|
|||||||
|
|
||||||
func (m usageTabModel) Update(msg tea.Msg) (usageTabModel, tea.Cmd) {
|
func (m usageTabModel) Update(msg tea.Msg) (usageTabModel, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
case localeChangedMsg:
|
||||||
|
m.viewport.SetContent(m.renderContent())
|
||||||
|
return m, nil
|
||||||
case usageDataMsg:
|
case usageDataMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.err = msg.err
|
m.err = msg.err
|
||||||
@@ -82,7 +85,7 @@ func (m *usageTabModel) SetSize(w, h int) {
|
|||||||
|
|
||||||
func (m usageTabModel) View() string {
|
func (m usageTabModel) View() string {
|
||||||
if !m.ready {
|
if !m.ready {
|
||||||
return "Loading..."
|
return T("loading")
|
||||||
}
|
}
|
||||||
return m.viewport.View()
|
return m.viewport.View()
|
||||||
}
|
}
|
||||||
@@ -90,9 +93,9 @@ func (m usageTabModel) View() string {
|
|||||||
func (m usageTabModel) renderContent() string {
|
func (m usageTabModel) renderContent() string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
sb.WriteString(titleStyle.Render("📈 使用统计"))
|
sb.WriteString(titleStyle.Render(T("usage_title")))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(helpStyle.Render(" [r] refresh • [↑↓] scroll"))
|
sb.WriteString(helpStyle.Render(T("usage_help")))
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
@@ -102,14 +105,14 @@ func (m usageTabModel) renderContent() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if m.usage == nil {
|
if m.usage == nil {
|
||||||
sb.WriteString(subtitleStyle.Render(" Usage data not available"))
|
sb.WriteString(subtitleStyle.Render(T("usage_no_data")))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
usageMap, _ := m.usage["usage"].(map[string]any)
|
usageMap, _ := m.usage["usage"].(map[string]any)
|
||||||
if usageMap == nil {
|
if usageMap == nil {
|
||||||
sb.WriteString(subtitleStyle.Render(" No usage data"))
|
sb.WriteString(subtitleStyle.Render(T("usage_no_data")))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
@@ -137,17 +140,17 @@ func (m usageTabModel) renderContent() string {
|
|||||||
// Total Requests
|
// Total Requests
|
||||||
card1 := cardStyle.Copy().BorderForeground(lipgloss.Color("111")).Render(fmt.Sprintf(
|
card1 := cardStyle.Copy().BorderForeground(lipgloss.Color("111")).Render(fmt.Sprintf(
|
||||||
"%s\n%s\n%s",
|
"%s\n%s\n%s",
|
||||||
lipgloss.NewStyle().Foreground(colorMuted).Render("总请求数"),
|
lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_total_reqs")),
|
||||||
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("111")).Render(fmt.Sprintf("%d", totalReqs)),
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("111")).Render(fmt.Sprintf("%d", totalReqs)),
|
||||||
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("● 成功: %d ● 失败: %d", successCnt, failureCnt)),
|
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("● %s: %d ● %s: %d", T("usage_success"), successCnt, T("usage_failure"), failureCnt)),
|
||||||
))
|
))
|
||||||
|
|
||||||
// Total Tokens
|
// Total Tokens
|
||||||
card2 := cardStyle.Copy().BorderForeground(lipgloss.Color("214")).Render(fmt.Sprintf(
|
card2 := cardStyle.Copy().BorderForeground(lipgloss.Color("214")).Render(fmt.Sprintf(
|
||||||
"%s\n%s\n%s",
|
"%s\n%s\n%s",
|
||||||
lipgloss.NewStyle().Foreground(colorMuted).Render("总 Token 数"),
|
lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_total_tokens")),
|
||||||
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(formatLargeNumber(totalTokens)),
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(formatLargeNumber(totalTokens)),
|
||||||
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("总Token: %s", formatLargeNumber(totalTokens))),
|
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s: %s", T("usage_total_token_l"), formatLargeNumber(totalTokens))),
|
||||||
))
|
))
|
||||||
|
|
||||||
// RPM
|
// RPM
|
||||||
@@ -159,9 +162,9 @@ func (m usageTabModel) renderContent() string {
|
|||||||
}
|
}
|
||||||
card3 := cardStyle.Copy().BorderForeground(lipgloss.Color("76")).Render(fmt.Sprintf(
|
card3 := cardStyle.Copy().BorderForeground(lipgloss.Color("76")).Render(fmt.Sprintf(
|
||||||
"%s\n%s\n%s",
|
"%s\n%s\n%s",
|
||||||
lipgloss.NewStyle().Foreground(colorMuted).Render("RPM"),
|
lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_rpm")),
|
||||||
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("76")).Render(fmt.Sprintf("%.2f", rpm)),
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("76")).Render(fmt.Sprintf("%.2f", rpm)),
|
||||||
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("总请求数: %d", totalReqs)),
|
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s: %d", T("usage_total_reqs"), totalReqs)),
|
||||||
))
|
))
|
||||||
|
|
||||||
// TPM
|
// TPM
|
||||||
@@ -173,9 +176,9 @@ func (m usageTabModel) renderContent() string {
|
|||||||
}
|
}
|
||||||
card4 := cardStyle.Copy().BorderForeground(lipgloss.Color("170")).Render(fmt.Sprintf(
|
card4 := cardStyle.Copy().BorderForeground(lipgloss.Color("170")).Render(fmt.Sprintf(
|
||||||
"%s\n%s\n%s",
|
"%s\n%s\n%s",
|
||||||
lipgloss.NewStyle().Foreground(colorMuted).Render("TPM"),
|
lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_tpm")),
|
||||||
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Render(fmt.Sprintf("%.2f", tpm)),
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Render(fmt.Sprintf("%.2f", tpm)),
|
||||||
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("总Token数: %s", formatLargeNumber(totalTokens))),
|
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s: %s", T("usage_total_tokens"), formatLargeNumber(totalTokens))),
|
||||||
))
|
))
|
||||||
|
|
||||||
sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, card1, " ", card2, " ", card3, " ", card4))
|
sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, card1, " ", card2, " ", card3, " ", card4))
|
||||||
@@ -183,7 +186,7 @@ func (m usageTabModel) renderContent() string {
|
|||||||
|
|
||||||
// ━━━ Requests by Hour (ASCII bar chart) ━━━
|
// ━━━ Requests by Hour (ASCII bar chart) ━━━
|
||||||
if rByH, ok := usageMap["requests_by_hour"].(map[string]any); ok && len(rByH) > 0 {
|
if rByH, ok := usageMap["requests_by_hour"].(map[string]any); ok && len(rByH) > 0 {
|
||||||
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("请求趋势 (按小时)"))
|
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("usage_req_by_hour")))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
|
sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
@@ -193,7 +196,7 @@ func (m usageTabModel) renderContent() string {
|
|||||||
|
|
||||||
// ━━━ Tokens by Hour ━━━
|
// ━━━ Tokens by Hour ━━━
|
||||||
if tByH, ok := usageMap["tokens_by_hour"].(map[string]any); ok && len(tByH) > 0 {
|
if tByH, ok := usageMap["tokens_by_hour"].(map[string]any); ok && len(tByH) > 0 {
|
||||||
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("Token 使用趋势 (按小时)"))
|
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("usage_tok_by_hour")))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
|
sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
@@ -203,7 +206,7 @@ func (m usageTabModel) renderContent() string {
|
|||||||
|
|
||||||
// ━━━ Requests by Day ━━━
|
// ━━━ Requests by Day ━━━
|
||||||
if rByD, ok := usageMap["requests_by_day"].(map[string]any); ok && len(rByD) > 0 {
|
if rByD, ok := usageMap["requests_by_day"].(map[string]any); ok && len(rByD) > 0 {
|
||||||
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("请求趋势 (按天)"))
|
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("usage_req_by_day")))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
|
sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
@@ -213,12 +216,12 @@ func (m usageTabModel) renderContent() string {
|
|||||||
|
|
||||||
// ━━━ API Detail Stats ━━━
|
// ━━━ API Detail Stats ━━━
|
||||||
if apis, ok := usageMap["apis"].(map[string]any); ok && len(apis) > 0 {
|
if apis, ok := usageMap["apis"].(map[string]any); ok && len(apis) > 0 {
|
||||||
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("API 详细统计"))
|
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("usage_api_detail")))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(strings.Repeat("─", minInt(m.width, 80)))
|
sb.WriteString(strings.Repeat("─", minInt(m.width, 80)))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
header := fmt.Sprintf(" %-30s %10s %12s", "API", "Requests", "Tokens")
|
header := fmt.Sprintf(" %-30s %10s %12s", "API", T("requests"), T("tokens"))
|
||||||
sb.WriteString(tableHeaderStyle.Render(header))
|
sb.WriteString(tableHeaderStyle.Render(header))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
@@ -289,16 +292,16 @@ func (m usageTabModel) renderTokenBreakdown(modelStats map[string]any) string {
|
|||||||
|
|
||||||
parts := []string{}
|
parts := []string{}
|
||||||
if inputTotal > 0 {
|
if inputTotal > 0 {
|
||||||
parts = append(parts, fmt.Sprintf("输入:%s", formatLargeNumber(inputTotal)))
|
parts = append(parts, fmt.Sprintf("%s:%s", T("usage_input"), formatLargeNumber(inputTotal)))
|
||||||
}
|
}
|
||||||
if outputTotal > 0 {
|
if outputTotal > 0 {
|
||||||
parts = append(parts, fmt.Sprintf("输出:%s", formatLargeNumber(outputTotal)))
|
parts = append(parts, fmt.Sprintf("%s:%s", T("usage_output"), formatLargeNumber(outputTotal)))
|
||||||
}
|
}
|
||||||
if cachedTotal > 0 {
|
if cachedTotal > 0 {
|
||||||
parts = append(parts, fmt.Sprintf("缓存:%s", formatLargeNumber(cachedTotal)))
|
parts = append(parts, fmt.Sprintf("%s:%s", T("usage_cached"), formatLargeNumber(cachedTotal)))
|
||||||
}
|
}
|
||||||
if reasoningTotal > 0 {
|
if reasoningTotal > 0 {
|
||||||
parts = append(parts, fmt.Sprintf("思考:%s", formatLargeNumber(reasoningTotal)))
|
parts = append(parts, fmt.Sprintf("%s:%s", T("usage_reasoning"), formatLargeNumber(reasoningTotal)))
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf(" │ %s\n",
|
return fmt.Sprintf(" │ %s\n",
|
||||||
|
|||||||
Reference in New Issue
Block a user