feat: add versioning, account export, and dynamic models list

This commit is contained in:
Quorinex
2026-02-08 01:31:27 +08:00
parent 9aad3dec7e
commit 3e7cca04ba
6 changed files with 587 additions and 54 deletions

View File

@@ -18,6 +18,10 @@ Convert Kiro accounts to OpenAI / Anthropic compatible API service.
- 🎛️ **Web Admin Panel** - Easy account management
- 🔑 **Multiple Auth Methods** - AWS Builder ID, IAM Identity Center (Enterprise SSO), SSO Token, Local Cache, Credentials
- 📊 **Usage Tracking** - Monitor requests, tokens, and credits
- 📦 **Account Export/Import** - Compatible with Kiro Account Manager format
- 🔄 **Dynamic Model List** - Auto-synced from Kiro API with caching
- 🔔 **Version Update Check** - Automatic new version notification
- 🌐 **i18n** - Chinese / English admin panel
## Quick Start
@@ -215,6 +219,7 @@ Configure thinking mode in the Admin Panel under **Settings > Thinking Mode Sett
```
Kiro-Go/
├── main.go # Entry point
├── version.json # Version info for update check
├── config/ # Configuration management
├── pool/ # Account pool & load balancing
├── proxy/ # API handlers & Kiro client

View File

@@ -18,6 +18,10 @@
- 🎛️ **Web 管理面板** - 便捷的账号管理
- 🔑 **多种认证方式** - AWS Builder ID、IAM Identity Center (企业 SSO)、SSO Token、本地缓存、凭证 JSON
- 📊 **用量追踪** - 监控请求数、Token、Credits
- 📦 **账号导入导出** - 兼容 Kiro Account Manager 格式
- 🔄 **动态模型列表** - 自动从 Kiro API 同步并缓存
- 🔔 **版本更新检测** - 自动提醒新版本
- 🌐 **中英双语** - 管理面板支持中文 / 英文
## 快速开始
@@ -215,6 +219,7 @@ curl http://localhost:8080/v1/messages \
```
Kiro-Go/
├── main.go # 入口
├── version.json # 版本信息(用于更新检测)
├── config/ # 配置管理
├── pool/ # 账号池 & 负载均衡
├── proxy/ # API 处理 & Kiro 客户端

View File

@@ -126,6 +126,9 @@ type AccountInfo struct {
TrialExpiresAt int64
}
// Version 当前版本号
const Version = "1.0.0"
var (
cfg *Config
cfgLock sync.RWMutex

View File

@@ -29,6 +29,10 @@ type Handler struct {
startTime int64
stopRefresh chan struct{}
stopStatsSaver chan struct{}
// 模型缓存
cachedModels []ModelInfo
modelsCacheMu sync.RWMutex
modelsCacheTime int64
}
func NewHandler() *Handler {
@@ -58,11 +62,13 @@ func (h *Handler) backgroundRefresh() {
// 启动时延迟 10 秒后执行一次
time.Sleep(10 * time.Second)
h.refreshModelsCache()
h.refreshAllAccounts()
for {
select {
case <-ticker.C:
h.refreshModelsCache()
h.refreshAllAccounts()
case <-h.stopRefresh:
return
@@ -211,19 +217,44 @@ func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
// handleModels 模型列表
func (h *Handler) handleModels(w http.ResponseWriter, r *http.Request) {
models := []map[string]interface{}{
{"id": "claude-sonnet-4.5", "object": "model", "owned_by": "anthropic"},
{"id": "claude-sonnet-4.5-thinking", "object": "model", "owned_by": "anthropic"},
{"id": "claude-sonnet-4", "object": "model", "owned_by": "anthropic"},
{"id": "claude-sonnet-4-thinking", "object": "model", "owned_by": "anthropic"},
{"id": "claude-haiku-4.5", "object": "model", "owned_by": "anthropic"},
{"id": "claude-haiku-4.5-thinking", "object": "model", "owned_by": "anthropic"},
{"id": "claude-opus-4.5", "object": "model", "owned_by": "anthropic"},
{"id": "claude-opus-4.5-thinking", "object": "model", "owned_by": "anthropic"},
{"id": "auto", "object": "model", "owned_by": "kiro-api"},
{"id": "gpt-4o", "object": "model", "owned_by": "kiro-proxy"},
{"id": "gpt-4", "object": "model", "owned_by": "kiro-proxy"},
// 尝试用缓存的真实模型列表
h.modelsCacheMu.RLock()
cached := h.cachedModels
h.modelsCacheMu.RUnlock()
thinkingSuffix := config.GetThinkingConfig().Suffix
var models []map[string]interface{}
if len(cached) > 0 {
for _, m := range cached {
models = append(models, map[string]interface{}{
"id": m.ModelId, "object": "model", "owned_by": "anthropic",
})
// 自动生成 thinking 变体
models = append(models, map[string]interface{}{
"id": m.ModelId + thinkingSuffix, "object": "model", "owned_by": "anthropic",
})
}
} else {
// fallback 静态列表
models = []map[string]interface{}{
{"id": "claude-sonnet-4.5", "object": "model", "owned_by": "anthropic"},
{"id": "claude-sonnet-4.5" + thinkingSuffix, "object": "model", "owned_by": "anthropic"},
{"id": "claude-sonnet-4", "object": "model", "owned_by": "anthropic"},
{"id": "claude-sonnet-4" + thinkingSuffix, "object": "model", "owned_by": "anthropic"},
{"id": "claude-haiku-4.5", "object": "model", "owned_by": "anthropic"},
{"id": "claude-haiku-4.5" + thinkingSuffix, "object": "model", "owned_by": "anthropic"},
{"id": "claude-opus-4.5", "object": "model", "owned_by": "anthropic"},
{"id": "claude-opus-4.5" + thinkingSuffix, "object": "model", "owned_by": "anthropic"},
}
}
// 添加别名模型
models = append(models,
map[string]interface{}{"id": "auto", "object": "model", "owned_by": "kiro-proxy"},
map[string]interface{}{"id": "gpt-4o", "object": "model", "owned_by": "kiro-proxy"},
map[string]interface{}{"id": "gpt-4", "object": "model", "owned_by": "kiro-proxy"},
)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(map[string]interface{}{
"object": "list",
@@ -231,6 +262,33 @@ func (h *Handler) handleModels(w http.ResponseWriter, r *http.Request) {
})
}
// refreshModelsCache 从 Kiro API 拉取模型列表并缓存
func (h *Handler) refreshModelsCache() {
account := h.pool.GetNext()
if account == nil {
return
}
// 确保 token 有效
if err := h.ensureValidToken(account); err != nil {
return
}
models, err := ListAvailableModels(account)
if err != nil {
fmt.Printf("[ModelsCache] Failed to refresh: %v\n", err)
return
}
if len(models) > 0 {
h.modelsCacheMu.Lock()
h.cachedModels = models
h.modelsCacheTime = time.Now().Unix()
h.modelsCacheMu.Unlock()
fmt.Printf("[ModelsCache] Cached %d models\n", len(models))
}
}
// handleCountTokens Token 计数Claude Code 会调用)
func (h *Handler) handleCountTokens(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
@@ -1282,6 +1340,10 @@ func (h *Handler) handleAdminAPI(w http.ResponseWriter, r *http.Request) {
h.apiGetEndpointConfig(w, r)
case path == "/endpoint" && r.Method == "POST":
h.apiUpdateEndpointConfig(w, r)
case path == "/version" && r.Method == "GET":
h.apiGetVersion(w, r)
case path == "/export" && r.Method == "POST":
h.apiExportAccounts(w, r)
default:
w.WriteHeader(404)
json.NewEncoder(w).Encode(map[string]string{"error": "Not Found"})
@@ -1707,31 +1769,47 @@ func (h *Handler) apiImportCredentials(w http.ResponseWriter, r *http.Request) {
req.AuthMethod = "social"
}
}
// 如果没有 accessToken尝试刷新获取
accessToken := req.AccessToken
var expiresAt int64
if accessToken == "" {
tempAccount := &config.Account{
RefreshToken: req.RefreshToken,
ClientID: req.ClientID,
ClientSecret: req.ClientSecret,
AuthMethod: req.AuthMethod,
Region: req.Region,
// 标准化 authMethod
switch strings.ToLower(req.AuthMethod) {
case "idc", "builderid", "enterprise":
req.AuthMethod = "idc"
case "social", "google", "github":
req.AuthMethod = "social"
default:
if req.ClientID != "" && req.ClientSecret != "" {
req.AuthMethod = "idc"
} else {
req.AuthMethod = "social"
}
newAccessToken, newRefreshToken, newExpiresAt, err := auth.RefreshToken(tempAccount)
if err != nil {
}
// 始终尝试用 refreshToken 刷新获取新的 accessToken
var accessToken string
var expiresAt int64
tempAccount := &config.Account{
RefreshToken: req.RefreshToken,
ClientID: req.ClientID,
ClientSecret: req.ClientSecret,
AuthMethod: req.AuthMethod,
Region: req.Region,
}
newAccessToken, newRefreshToken, newExpiresAt, err := auth.RefreshToken(tempAccount)
if err != nil {
// 刷新失败,如果有传入的 accessToken 则尝试使用
if req.AccessToken != "" {
accessToken = req.AccessToken
expiresAt = time.Now().Unix() + 300 // 可能已过期,设短一点
} else {
w.WriteHeader(400)
json.NewEncoder(w).Encode(map[string]string{"error": "Token refresh failed: " + err.Error()})
return
}
} else {
accessToken = newAccessToken
if newRefreshToken != "" {
req.RefreshToken = newRefreshToken
}
expiresAt = newExpiresAt
} else {
expiresAt = time.Now().Unix() + 3600 // 默认 1 小时
}
// 获取用户信息
@@ -1858,13 +1936,14 @@ func (h *Handler) apiRefreshAccount(w http.ResponseWriter, r *http.Request, id s
return
}
// 检查 token 是否过期,需要刷新
if account.ExpiresAt > 0 && time.Now().Unix() > account.ExpiresAt-60 {
// 先尝试刷新 token(不管是否过期,确保 token 有效)
refreshTokenIfNeeded := func() error {
if account.RefreshToken == "" {
return nil
}
newAccessToken, newRefreshToken, newExpiresAt, err := auth.RefreshToken(account)
if err != nil {
w.WriteHeader(500)
json.NewEncoder(w).Encode(map[string]string{"error": "Token refresh failed: " + err.Error()})
return
return err
}
account.AccessToken = newAccessToken
if newRefreshToken != "" {
@@ -1873,14 +1952,34 @@ func (h *Handler) apiRefreshAccount(w http.ResponseWriter, r *http.Request, id s
account.ExpiresAt = newExpiresAt
config.UpdateAccountToken(id, newAccessToken, newRefreshToken, newExpiresAt)
h.pool.UpdateToken(id, newAccessToken, newRefreshToken, newExpiresAt)
return nil
}
// 检查 token 是否快过期,先刷新
if account.ExpiresAt > 0 && time.Now().Unix() > account.ExpiresAt-300 {
if err := refreshTokenIfNeeded(); err != nil {
w.WriteHeader(500)
json.NewEncoder(w).Encode(map[string]string{"error": "Token refresh failed: " + err.Error()})
return
}
}
// 获取账户信息
info, err := RefreshAccountInfo(account)
if err != nil {
w.WriteHeader(500)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
// 如果是 403/401说明 token 无效,尝试刷新后重试
errMsg := err.Error()
if strings.Contains(errMsg, "403") || strings.Contains(errMsg, "401") || strings.Contains(errMsg, "invalid") || strings.Contains(errMsg, "expired") {
if refreshErr := refreshTokenIfNeeded(); refreshErr == nil {
// 重试
info, err = RefreshAccountInfo(account)
}
}
if err != nil {
w.WriteHeader(500)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
}
// 保存到配置
@@ -2015,3 +2114,161 @@ func (h *Handler) apiUpdateEndpointConfig(w http.ResponseWriter, r *http.Request
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
// apiGetVersion 获取版本信息
func (h *Handler) apiGetVersion(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{
"version": config.Version,
})
}
// apiExportAccounts 导出账号凭证
func (h *Handler) apiExportAccounts(w http.ResponseWriter, r *http.Request) {
var req struct {
IDs []string `json:"ids"` // 为空则导出全部
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// 如果 body 为空或解析失败,导出全部
req.IDs = nil
}
accounts := config.GetAccounts()
// 如果指定了 ID只导出指定的
if len(req.IDs) > 0 {
idSet := make(map[string]bool)
for _, id := range req.IDs {
idSet[id] = true
}
var filtered []config.Account
for _, a := range accounts {
if idSet[a.ID] {
filtered = append(filtered, a)
}
}
accounts = filtered
}
// 构建兼容 Kiro Account Manager 的导出格式
type ExportCredentials struct {
AccessToken string `json:"accessToken"`
CsrfToken string `json:"csrfToken"`
RefreshToken string `json:"refreshToken,omitempty"`
ClientID string `json:"clientId,omitempty"`
ClientSecret string `json:"clientSecret,omitempty"`
Region string `json:"region,omitempty"`
ExpiresAt int64 `json:"expiresAt"`
AuthMethod string `json:"authMethod,omitempty"`
Provider string `json:"provider,omitempty"`
}
type ExportSubscription struct {
Type string `json:"type"`
Title string `json:"title,omitempty"`
}
type ExportUsage struct {
Current float64 `json:"current"`
Limit float64 `json:"limit"`
PercentUsed float64 `json:"percentUsed"`
LastUpdated int64 `json:"lastUpdated"`
}
type ExportAccount struct {
ID string `json:"id"`
Email string `json:"email"`
Nickname string `json:"nickname,omitempty"`
Idp string `json:"idp"`
UserId string `json:"userId,omitempty"`
MachineId string `json:"machineId,omitempty"`
Credentials ExportCredentials `json:"credentials"`
Subscription ExportSubscription `json:"subscription"`
Usage ExportUsage `json:"usage"`
Tags []string `json:"tags"`
Status string `json:"status"`
CreatedAt int64 `json:"createdAt"`
LastUsedAt int64 `json:"lastUsedAt"`
}
type ExportData struct {
Version string `json:"version"`
ExportedAt int64 `json:"exportedAt"`
Accounts []ExportAccount `json:"accounts"`
Groups []interface{} `json:"groups"`
Tags []interface{} `json:"tags"`
}
exportAccounts := make([]ExportAccount, 0, len(accounts))
for _, a := range accounts {
// 映射 provider 到 idp
idp := a.Provider
if idp == "" {
if a.AuthMethod == "social" {
idp = "Google"
} else {
idp = "BuilderId"
}
}
// 映射 authMethod
authMethod := a.AuthMethod
if authMethod == "idc" {
authMethod = "IdC"
}
// 映射订阅类型
subType := "Free"
rawType := strings.ToUpper(a.SubscriptionType)
if strings.Contains(rawType, "PRO_PLUS") || strings.Contains(rawType, "PROPLUS") {
subType = "Pro_Plus"
} else if strings.Contains(rawType, "PRO") {
subType = "Pro"
} else if strings.Contains(rawType, "POWER") {
subType = "Pro_Plus"
}
exportAccounts = append(exportAccounts, ExportAccount{
ID: a.ID,
Email: a.Email,
Nickname: a.Nickname,
Idp: idp,
UserId: a.UserId,
MachineId: a.MachineId,
Credentials: ExportCredentials{
AccessToken: a.AccessToken,
CsrfToken: "",
RefreshToken: a.RefreshToken,
ClientID: a.ClientID,
ClientSecret: a.ClientSecret,
Region: a.Region,
ExpiresAt: a.ExpiresAt * 1000, // 转为毫秒时间戳
AuthMethod: authMethod,
Provider: a.Provider,
},
Subscription: ExportSubscription{
Type: subType,
Title: a.SubscriptionTitle,
},
Usage: ExportUsage{
Current: a.UsageCurrent,
Limit: a.UsageLimit,
PercentUsed: a.UsagePercent,
LastUpdated: time.Now().UnixMilli(),
},
Tags: []string{},
Status: "active",
CreatedAt: time.Now().UnixMilli(),
LastUsedAt: time.Now().UnixMilli(),
})
}
data := ExportData{
Version: config.Version,
ExportedAt: time.Now().UnixMilli(),
Accounts: exportAccounts,
Groups: []interface{}{},
Tags: []interface{}{},
}
json.NewEncoder(w).Encode(data)
}

5
version.json Normal file
View File

@@ -0,0 +1,5 @@
{
"version": "1.0.0",
"changelog": "🎉 v1.0.0 首个正式版",
"download": "https://github.com/Quorinex/Kiro-Go"
}

View File

@@ -834,6 +834,7 @@
<button class="lang-btn" data-lang="en" onclick="setLang('en')">EN</button>
</div>
<span class="badge badge-success" id="statusBadge" data-i18n="status.running"></span>
<span class="badge badge-info" id="versionBadge" style="cursor:pointer" onclick="checkUpdate(true)"></span>
</div>
</div>
<div class="stats-grid">
@@ -871,7 +872,7 @@
<div class="card">
<div class="card-header">
<span class="card-title" data-i18n="accounts.title"></span>
<div class="card-actions"><button class="btn btn-primary btn-sm" onclick="showModal('add')"
<div class="card-actions"><button class="btn btn-secondary btn-sm" onclick="showExportModal()" data-i18n="accounts.export"></button><button class="btn btn-primary btn-sm" onclick="showModal('add')"
data-i18n="accounts.add"></button></div>
</div>
<div id="accountsList"></div>
@@ -887,8 +888,8 @@
<span data-i18n="settings.enableApiKey"></span>
</label>
</div>
<div class="form-group"><label>API Key</label><input type="text" id="apiKeyInput"
data-i18n-placeholder="settings.apiKeyPlaceholder"></div>
<div class="form-group"><label>API Key</label><div style="display:flex;gap:8px;align-items:stretch"><input type="text" id="apiKeyInput" style="flex:1"
data-i18n-placeholder="settings.apiKeyPlaceholder"><button class="btn btn-sm btn-secondary" onclick="generateApiKey()" data-i18n="settings.generateApiKey"></button></div></div>
<button class="btn btn-primary" onclick="saveSettings()" data-i18n="common.save"></button>
</div>
<div class="card">
@@ -973,6 +974,20 @@
<div id="detailBody"></div>
</div>
</div>
<div id="exportModal" class="modal">
<div class="modal-content">
<div class="modal-header"><span class="modal-title" data-i18n="export.title"></span><button
class="modal-close" onclick="closeExportModal()">&times;</button></div>
<div id="exportBody"></div>
</div>
</div>
<div id="updateModal" class="modal">
<div class="modal-content">
<div class="modal-header"><span class="modal-title" data-i18n="update.title"></span><button
class="modal-close" onclick="closeUpdateModal()">&times;</button></div>
<div id="updateBody"></div>
</div>
</div>
<script>
const i18n = {
zh: {
@@ -1021,6 +1036,7 @@
'settings.apiSettings': 'API 设置',
'settings.enableApiKey': '启用 API Key 验证',
'settings.apiKeyPlaceholder': '留空则不验证',
'settings.generateApiKey': '随机生成',
'settings.thinkingSettings': 'Thinking 模式设置',
'settings.thinkingSuffix': '触发后缀',
'settings.thinkingSuffixHint': '模型名称加此后缀即启用思考模式,如 claude-sonnet-4.5-thinking',
@@ -1129,7 +1145,7 @@
'credentials.social': 'Social (AWS Builder ID / Google / GitHub)',
'credentials.idc': 'IAM Identity Center (企业 SSO)',
'credentials.jsonError': 'JSON 格式错误',
'credentials.batchHint': '支持单个对象JSON 数组格式批量导入。必填: refreshToken。可选: provider (BuilderId/Enterprise/Github/Google), clientId, clientSecret',
'credentials.batchHint': '支持单个对象JSON 数组或 Kiro Account Manager 导出格式批量导入。必填: refreshToken。可选: provider (BuilderId/Enterprise/Github/Google), clientId, clientSecret',
'common.save': '保存设置',
'common.saved': '保存',
'common.copy': '复制',
@@ -1138,7 +1154,25 @@
'common.back': '返回',
'common.add': '添加',
'common.failed': '失败',
'common.saveFailed': '保存失败'
'common.saveFailed': '保存失败',
'accounts.export': '导出',
'export.title': '导出账号',
'export.selectAll': '全选',
'export.deselectAll': '取消全选',
'export.selected': '已选 {0} 个',
'export.downloadJson': '下载 JSON',
'export.showJson': '显示 JSON',
'export.copyJson': '复制 JSON',
'export.copied': '已复制到剪贴板',
'export.noSelection': '请至少选择一个账号',
'update.title': '版本更新',
'update.current': '当前版本',
'update.latest': '最新版本',
'update.newVersion': '发现新版本',
'update.upToDate': '已是最新版本',
'update.checkFailed': '检查更新失败',
'update.goDownload': '前往下载',
'update.changelog': '更新内容'
},
en: {
'login.subtitle': 'Enter admin password to login',
@@ -1180,6 +1214,7 @@
'settings.apiSettings': 'API Settings',
'settings.enableApiKey': 'Enable API Key Verification',
'settings.apiKeyPlaceholder': 'Leave empty to disable',
'settings.generateApiKey': 'Generate',
'settings.thinkingSettings': 'Thinking Mode Settings',
'settings.thinkingSuffix': 'Trigger Suffix',
'settings.thinkingSuffixHint': 'Add this suffix to model name to enable thinking mode, e.g. claude-sonnet-4.5-thinking',
@@ -1284,7 +1319,7 @@
'credentials.social': 'Social (AWS Builder ID / Google / GitHub)',
'credentials.idc': 'IAM Identity Center (Enterprise SSO)',
'credentials.jsonError': 'Invalid JSON format',
'credentials.batchHint': 'Supports single object or JSON array for batch import. Required: refreshToken. Optional: provider (BuilderId/Enterprise/Github/Google), clientId, clientSecret',
'credentials.batchHint': 'Supports single object, JSON array, or Kiro Account Manager export format. Required: refreshToken. Optional: provider (BuilderId/Enterprise/Github/Google), clientId, clientSecret',
'common.save': 'Save Settings',
'common.saved': 'Saved',
'common.copy': 'Copy',
@@ -1293,7 +1328,25 @@
'common.back': 'Back',
'common.add': 'Add',
'common.failed': 'Failed',
'common.saveFailed': 'Save failed'
'common.saveFailed': 'Save failed',
'accounts.export': 'Export',
'export.title': 'Export Accounts',
'export.selectAll': 'Select All',
'export.deselectAll': 'Deselect All',
'export.selected': '{0} selected',
'export.downloadJson': 'Download JSON',
'export.showJson': 'Show JSON',
'export.copyJson': 'Copy JSON',
'export.copied': 'Copied to clipboard',
'export.noSelection': 'Please select at least one account',
'update.title': 'Version Update',
'update.current': 'Current',
'update.latest': 'Latest',
'update.newVersion': 'New version available',
'update.upToDate': 'Up to date',
'update.checkFailed': 'Update check failed',
'update.goDownload': 'Download',
'update.changelog': 'Changelog'
}
};
let currentLang = localStorage.getItem('kiro_lang') || 'zh';
@@ -1361,10 +1414,12 @@
document.getElementById('mainPage').classList.remove('hidden');
}
async function loadData() {
await Promise.all([loadStats(), loadAccounts(), loadSettings()]);
await Promise.all([loadStats(), loadAccounts(), loadSettings(), loadVersion()]);
document.getElementById('claudeEndpoint').textContent = baseUrl + '/v1/messages';
document.getElementById('openaiEndpoint').textContent = baseUrl + '/v1/chat/completions';
document.getElementById('modelsEndpoint').textContent = baseUrl + '/v1/models';
// 自动检查更新
setTimeout(() => checkUpdate(false), 2000);
}
async function loadStats() {
const res = await fetch('/admin/api/status', { headers: { 'X-Admin-Password': password } });
@@ -1596,12 +1651,23 @@
const d = await res.json();
if (d.success) { alert(t('settings.endpointSaved')); } else { alert(t('common.saveFailed') + ': ' + d.error); }
}
function generateApiKey() {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let key = 'sk-';
for (let i = 0; i < 32; i++) key += chars.charAt(Math.floor(Math.random() * chars.length));
document.getElementById('apiKeyInput').value = key;
}
async function saveSettings() {
const requireApiKey = document.getElementById('requireApiKey').checked;
const apiKeyInput = document.getElementById('apiKeyInput');
if (requireApiKey && !apiKeyInput.value.trim()) {
generateApiKey();
}
await fetch('/admin/api/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ requireApiKey: document.getElementById('requireApiKey').checked, apiKey: document.getElementById('apiKeyInput').value })
body: JSON.stringify({ requireApiKey, apiKey: apiKeyInput.value })
});
alert(t('common.saved'));
alert(t('detail.saved'));
}
async function changePassword() {
const newPwd = document.getElementById('newPassword').value;
@@ -1746,30 +1812,59 @@
const payload = { refreshToken: tokenData.refreshToken, accessToken: tokenData.accessToken || '', clientId: clientData?.clientId || '', clientSecret: clientData?.clientSecret || '', authMethod: authMethod, provider: provider };
const res = await fetch('/admin/api/auth/credentials', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password }, body: JSON.stringify(payload) });
const d = await res.json();
if (d.success) { closeModal(); loadAccounts(); loadStats(); alert(t('local.importSuccess') + ': ' + (d.account?.email || d.account?.id)); }
if (d.success) { closeModal(); loadAccounts(); loadStats(); alert(t('local.importSuccess') + ': ' + (d.account?.email || d.account?.id)); autoRefreshNewAccount(d.account?.id); }
else alert(t('common.failed') + ': ' + d.error);
}
async function importCredentials() {
try {
const json = JSON.parse(document.getElementById('credJson').value.trim());
const items = Array.isArray(json) ? json : [json];
let success = 0, failed = 0, errors = [];
// 兼容 Kiro Account Manager 导出格式 {version, accounts: [...]}
let items;
if (json.accounts && Array.isArray(json.accounts)) {
// AccountExportData 格式,从 accounts[].credentials 提取
items = json.accounts.map(a => {
const c = a.credentials || {};
return {
refreshToken: c.refreshToken || a.refreshToken,
clientId: c.clientId || a.clientId,
clientSecret: c.clientSecret || a.clientSecret,
region: c.region || a.region,
// 不传 accessToken强制后端用 refreshToken 刷新获取新 token
authMethod: c.authMethod || a.authMethod,
provider: c.provider || a.provider || a.idp
};
});
} else {
items = Array.isArray(json) ? json : [json];
}
let success = 0, failed = 0, errors = [], newIds = [];
for (const item of items) {
if (!item.refreshToken) { failed++; errors.push('missing refreshToken'); continue; }
// 根据是否包含 clientId/clientSecret 判断认证方式
const hasClientCredentials = !!(item.clientId && item.clientSecret);
const authMethod = hasClientCredentials ? 'idc' : 'social';
const payload = { refreshToken: item.refreshToken, clientId: item.clientId || '', clientSecret: item.clientSecret || '', authMethod: authMethod, provider: item.provider || 'BuilderId' };
// 映射 authMethod: IdC/idc -> idc, social -> social
let authMethod = item.authMethod || '';
if (item.clientId && item.clientSecret) {
authMethod = 'idc';
} else if (!authMethod || authMethod === 'social') {
authMethod = 'social';
} else {
authMethod = authMethod.toLowerCase() === 'idc' ? 'idc' : 'social';
}
// 映射 provider
let provider = item.provider || '';
if (!provider && authMethod === 'social') provider = 'Google';
if (!provider && authMethod === 'idc') provider = 'BuilderId';
const payload = { refreshToken: item.refreshToken, accessToken: item.accessToken || '', clientId: item.clientId || '', clientSecret: item.clientSecret || '', authMethod: authMethod, provider: provider, region: item.region || 'us-east-1' };
try {
const res = await fetch('/admin/api/auth/credentials', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password }, body: JSON.stringify(payload) });
const d = await res.json();
if (d.success) success++; else { failed++; errors.push(d.error || 'unknown'); }
if (d.success) { success++; if (d.account?.id) newIds.push(d.account.id); } else { failed++; errors.push(d.error || 'unknown'); }
} catch { failed++; errors.push('request failed'); }
}
closeModal(); loadAccounts(); loadStats();
let msg = t('sso.importSuccess', success);
if (failed > 0) msg += t('sso.importPartial', failed);
alert(msg);
newIds.forEach(id => autoRefreshNewAccount(id));
} catch (e) { alert(t('credentials.jsonError')); }
}
async function importSsoToken() {
@@ -1782,6 +1877,7 @@
let msg = t('sso.importSuccess', count);
if (errCount > 0) msg += t('sso.importPartial', errCount);
alert(msg);
if (d.accounts) d.accounts.forEach(a => autoRefreshNewAccount(a.id));
} else alert(t('common.failed') + ': ' + d.error);
}
let builderIdSession = '';
@@ -1806,6 +1902,7 @@
if (d.completed) {
closeModal(); loadAccounts(); loadStats();
alert(t('builderid.success') + ': ' + (d.account?.email || d.account?.id));
autoRefreshNewAccount(d.account?.id);
} else if (d.success && !d.completed) {
document.getElementById('builderIdStatus').textContent = t('builderid.waiting');
pollBuilderIdAuth(d.interval || interval);
@@ -1825,7 +1922,7 @@
if (iamSession) {
const res = await fetch('/admin/api/auth/iam-sso/complete', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password }, body: JSON.stringify({ sessionId: iamSession, callbackUrl: document.getElementById('iamCallback').value }) });
const d = await res.json();
if (d.success) { closeModal(); loadAccounts(); loadStats(); alert(t('builderid.success') + ': ' + (d.account?.email || d.account?.id)); }
if (d.success) { closeModal(); loadAccounts(); loadStats(); alert(t('builderid.success') + ': ' + (d.account?.email || d.account?.id)); autoRefreshNewAccount(d.account?.id); }
else alert(t('common.failed') + ': ' + d.error);
} else {
const res = await fetch('/admin/api/auth/iam-sso/start', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password }, body: JSON.stringify({ startUrl: document.getElementById('iamStartUrl').value, region: document.getElementById('iamRegion').value }) });
@@ -1839,6 +1936,167 @@
}
}
setInterval(() => { if (!document.getElementById('mainPage').classList.contains('hidden')) loadStats(); }, 10000);
// ==================== 版本检查 ====================
let currentVersion = '';
async function loadVersion() {
try {
const res = await fetch('/admin/api/version', { headers: { 'X-Admin-Password': password } });
const d = await res.json();
currentVersion = d.version || '';
document.getElementById('versionBadge').textContent = 'v' + currentVersion;
} catch (e) { }
}
async function checkUpdate(manual) {
try {
const res = await fetch('https://raw.githubusercontent.com/Quorinex/Kiro-Go/main/version.json?t=' + Date.now());
if (!res.ok) throw new Error('Fetch failed');
const d = await res.json();
const latestVersion = (d.version || '').replace(/^v/, '');
if (latestVersion && latestVersion !== currentVersion && compareVersions(latestVersion, currentVersion) > 0) {
showUpdateModal(latestVersion, d.download || 'https://github.com/Quorinex/Kiro-Go', d.changelog || '');
} else if (manual) {
alert(t('update.upToDate') + ' (v' + currentVersion + ')');
}
} catch (e) {
if (manual) alert(t('update.checkFailed'));
}
}
function compareVersions(a, b) {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const na = pa[i] || 0, nb = pb[i] || 0;
if (na > nb) return 1;
if (na < nb) return -1;
}
return 0;
}
function showUpdateModal(version, url, changelog) {
const body = document.getElementById('updateBody');
body.innerHTML =
'<div style="text-align:center;margin-bottom:20px">' +
'<div style="font-size:48px;margin-bottom:12px">🎉</div>' +
'<p style="font-size:16px;font-weight:600;color:#7c3aed">' + t('update.newVersion') + '</p>' +
'</div>' +
'<div class="detail-grid" style="margin-bottom:16px">' +
'<div class="detail-item"><div class="detail-label">' + t('update.current') + '</div><div class="detail-value">v' + currentVersion + '</div></div>' +
'<div class="detail-item"><div class="detail-label">' + t('update.latest') + '</div><div class="detail-value" style="color:#16a34a">v' + version + '</div></div>' +
'</div>' +
(changelog ? '<div style="margin-bottom:16px"><div style="font-size:13px;font-weight:600;margin-bottom:8px">' + t('update.changelog') + '</div><div style="background:#f8fafc;padding:12px;border-radius:8px;font-size:12px;max-height:200px;overflow-y:auto;white-space:pre-wrap;line-height:1.6">' + escapeHtml(changelog) + '</div></div>' : '') +
'<div style="text-align:center"><a href="' + url + '" target="_blank" class="btn btn-primary" style="text-decoration:none;display:inline-block">' + t('update.goDownload') + '</a></div>';
document.getElementById('updateModal').classList.add('active');
}
function closeUpdateModal() { document.getElementById('updateModal').classList.remove('active'); }
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ==================== 导出功能 ====================
let exportSelectedIds = new Set();
function showExportModal() {
if (accountsData.length === 0) { alert(t('accounts.empty')); return; }
exportSelectedIds = new Set(accountsData.map(a => a.id));
renderExportModal();
document.getElementById('exportModal').classList.add('active');
}
function closeExportModal() { document.getElementById('exportModal').classList.remove('active'); }
function renderExportModal() {
const body = document.getElementById('exportBody');
const allSelected = exportSelectedIds.size === accountsData.length;
body.innerHTML =
'<div style="margin-bottom:12px;display:flex;justify-content:space-between;align-items:center">' +
'<span style="font-size:13px;color:#64748b">' + t('export.selected', exportSelectedIds.size) + '</span>' +
'<button class="btn btn-sm btn-secondary" onclick="toggleExportSelectAll()">' + (allSelected ? t('export.deselectAll') : t('export.selectAll')) + '</button>' +
'</div>' +
'<div style="max-height:300px;overflow-y:auto;margin-bottom:16px">' +
accountsData.map(a => {
const checked = exportSelectedIds.has(a.id) ? 'checked' : '';
return '<label style="display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:6px;cursor:pointer;margin-bottom:4px;background:' + (exportSelectedIds.has(a.id) ? '#f0f4ff' : '#f8fafc') + '">' +
'<input type="checkbox" ' + checked + ' onchange="toggleExportAccount(\'' + a.id + '\')" style="width:16px;height:16px">' +
'<div style="flex:1;min-width:0"><div style="font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + (a.email || a.id.substring(0, 12) + '...') + '</div>' +
'<div style="font-size:11px;color:#64748b">' + formatAuthMethod(a.provider || a.authMethod) + ' · ' + (a.subscriptionType || 'FREE') + '</div></div>' +
'</label>';
}).join('') +
'</div>' +
'<div id="exportJsonPreview" class="hidden" style="margin-bottom:12px"><textarea id="exportJsonText" readonly style="width:100%;min-height:150px;max-height:300px;font-family:monospace;font-size:11px;background:#f8fafc;resize:vertical"></textarea></div>' +
'<div class="modal-footer" style="flex-wrap:wrap">' +
'<button class="btn btn-secondary" onclick="closeExportModal()">' + t('common.cancel') + '</button>' +
'<button class="btn btn-secondary" onclick="exportShowJson()">' + t('export.showJson') + '</button>' +
'<button class="btn btn-secondary" onclick="exportCopyJson()">' + t('export.copyJson') + '</button>' +
'<button class="btn btn-primary" onclick="exportDownloadJson()">' + t('export.downloadJson') + '</button>' +
'</div>';
}
function toggleExportAccount(id) {
if (exportSelectedIds.has(id)) exportSelectedIds.delete(id);
else exportSelectedIds.add(id);
renderExportModal();
}
function toggleExportSelectAll() {
if (exportSelectedIds.size === accountsData.length) exportSelectedIds.clear();
else exportSelectedIds = new Set(accountsData.map(a => a.id));
renderExportModal();
}
async function getExportData() {
if (exportSelectedIds.size === 0) { alert(t('export.noSelection')); return null; }
const res = await fetch('/admin/api/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ ids: Array.from(exportSelectedIds) })
});
return await res.json();
}
async function exportShowJson() {
const data = await getExportData();
if (!data) return;
const preview = document.getElementById('exportJsonPreview');
const textarea = document.getElementById('exportJsonText');
preview.classList.remove('hidden');
textarea.value = JSON.stringify(data, null, 2);
}
async function exportCopyJson() {
const data = await getExportData();
if (!data) return;
await navigator.clipboard.writeText(JSON.stringify(data, null, 2));
alert(t('export.copied'));
}
async function exportDownloadJson() {
const data = await getExportData();
if (!data) return;
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'kiro-accounts-' + new Date().toISOString().slice(0, 10) + '.json';
a.click();
URL.revokeObjectURL(url);
}
// ==================== 添加账号后自动刷新 ====================
async function autoRefreshNewAccount(accountId) {
try {
await fetch('/admin/api/accounts/' + accountId + '/refresh', {
method: 'POST', headers: { 'X-Admin-Password': password }
});
} catch (e) { }
loadAccounts();
}
</script>
</body>