mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-19 21:00:52 +08:00
406 lines
9.3 KiB
Go
406 lines
9.3 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/atotto/clipboard"
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
// keysTabModel displays and manages API keys.
|
|
type keysTabModel struct {
|
|
client *Client
|
|
viewport viewport.Model
|
|
keys []string
|
|
gemini []map[string]any
|
|
claude []map[string]any
|
|
codex []map[string]any
|
|
vertex []map[string]any
|
|
openai []map[string]any
|
|
err error
|
|
width int
|
|
height int
|
|
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 {
|
|
apiKeys []string
|
|
gemini []map[string]any
|
|
claude []map[string]any
|
|
codex []map[string]any
|
|
vertex []map[string]any
|
|
openai []map[string]any
|
|
err error
|
|
}
|
|
|
|
type keyActionMsg struct {
|
|
action string
|
|
err error
|
|
}
|
|
|
|
func newKeysTabModel(client *Client) keysTabModel {
|
|
ti := textinput.New()
|
|
ti.CharLimit = 512
|
|
ti.Prompt = " Key: "
|
|
return keysTabModel{
|
|
client: client,
|
|
confirm: -1,
|
|
editInput: ti,
|
|
}
|
|
}
|
|
|
|
func (m keysTabModel) Init() tea.Cmd {
|
|
return m.fetchKeys
|
|
}
|
|
|
|
func (m keysTabModel) fetchKeys() tea.Msg {
|
|
result := keysDataMsg{}
|
|
apiKeys, err := m.client.GetAPIKeys()
|
|
if err != nil {
|
|
result.err = err
|
|
return result
|
|
}
|
|
result.apiKeys = apiKeys
|
|
result.gemini, _ = m.client.GetGeminiKeys()
|
|
result.claude, _ = m.client.GetClaudeKeys()
|
|
result.codex, _ = m.client.GetCodexKeys()
|
|
result.vertex, _ = m.client.GetVertexKeys()
|
|
result.openai, _ = m.client.GetOpenAICompat()
|
|
return result
|
|
}
|
|
|
|
func (m keysTabModel) Update(msg tea.Msg) (keysTabModel, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case localeChangedMsg:
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
case keysDataMsg:
|
|
if msg.err != nil {
|
|
m.err = msg.err
|
|
} else {
|
|
m.err = nil
|
|
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.renderContent())
|
|
return m, nil
|
|
|
|
case keyActionMsg:
|
|
if msg.err != nil {
|
|
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
|
|
}
|
|
|
|
func (m *keysTabModel) SetSize(w, h int) {
|
|
m.width = w
|
|
m.height = h
|
|
m.editInput.Width = w - 16
|
|
if !m.ready {
|
|
m.viewport = viewport.New(w, h)
|
|
m.viewport.SetContent(m.renderContent())
|
|
m.ready = true
|
|
} else {
|
|
m.viewport.Width = w
|
|
m.viewport.Height = h
|
|
}
|
|
}
|
|
|
|
func (m keysTabModel) View() string {
|
|
if !m.ready {
|
|
return T("loading")
|
|
}
|
|
return m.viewport.View()
|
|
}
|
|
|
|
func (m keysTabModel) renderContent() string {
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString(titleStyle.Render(T("keys_title")))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(helpStyle.Render(T("keys_help")))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(strings.Repeat("─", m.width))
|
|
sb.WriteString("\n")
|
|
|
|
if m.err != nil {
|
|
sb.WriteString(errorStyle.Render(T("error_prefix") + m.err.Error()))
|
|
sb.WriteString("\n")
|
|
return sb.String()
|
|
}
|
|
|
|
// ━━━ Access API Keys (interactive) ━━━
|
|
sb.WriteString(tableHeaderStyle.Render(fmt.Sprintf(" %s (%d)", T("access_keys"), len(m.keys))))
|
|
sb.WriteString("\n")
|
|
|
|
if len(m.keys) == 0 {
|
|
sb.WriteString(subtitleStyle.Render(T("no_keys")))
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
for i, key := range m.keys {
|
|
cursor := " "
|
|
rowStyle := lipgloss.NewStyle()
|
|
if i == m.cursor {
|
|
cursor = "▸ "
|
|
rowStyle = lipgloss.NewStyle().Bold(true)
|
|
}
|
|
|
|
row := fmt.Sprintf("%s%d. %s", cursor, i+1, maskKey(key))
|
|
sb.WriteString(rowStyle.Render(row))
|
|
sb.WriteString("\n")
|
|
|
|
// 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")
|
|
baseURL := getString(entry, "base-url")
|
|
prefix := getString(entry, "prefix")
|
|
info := name
|
|
if prefix != "" {
|
|
info += " (prefix: " + prefix + ")"
|
|
}
|
|
if baseURL != "" {
|
|
info += " → " + baseURL
|
|
}
|
|
sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, info))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
if m.status != "" {
|
|
sb.WriteString(m.status)
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func renderSection(sb *strings.Builder, title string, count int) {
|
|
header := fmt.Sprintf("%s (%d)", title, count)
|
|
sb.WriteString(tableHeaderStyle.Render(" " + header))
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
func renderProviderKeys(sb *strings.Builder, title string, keys []map[string]any) {
|
|
if len(keys) == 0 {
|
|
return
|
|
}
|
|
renderSection(sb, title, len(keys))
|
|
for i, key := range keys {
|
|
apiKey := getString(key, "api-key")
|
|
prefix := getString(key, "prefix")
|
|
baseURL := getString(key, "base-url")
|
|
info := maskKey(apiKey)
|
|
if prefix != "" {
|
|
info += " (prefix: " + prefix + ")"
|
|
}
|
|
if baseURL != "" {
|
|
info += " → " + baseURL
|
|
}
|
|
sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, info))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
func maskKey(key string) string {
|
|
if len(key) <= 8 {
|
|
return strings.Repeat("*", len(key))
|
|
}
|
|
return key[:4] + strings.Repeat("*", len(key)-8) + key[len(key)-4:]
|
|
}
|