修改插件和日志提示

This commit is contained in:
zhangchen
2026-04-07 13:25:56 +08:00
parent 8a06db7f05
commit cc0123486b
8 changed files with 440 additions and 23 deletions

View File

@@ -405,6 +405,9 @@ CAMOUFOX_VERSION=135.0.1 CAMOUFOX_RELEASE=beta.24 docker compose build app
| grok2api | Grok token 管理、回填、聊天/API 服务 | `https://github.com/chenyme/grok2api.git` |
| kiro-account-manager | Kiro 账号管理相关插件 | `https://github.com/hj01857655/kiro-account-manager.git` |
插件页中的 **“安装最新版 / 更新到最新版”** 会同步仓库最新代码,且已支持 **卸载**(会先停止服务,再删除本地插件目录)。
默认按 **最新 semver tag** 更新;你也可以在“设置 → 插件 → 安装/更新策略”切回 **分支 HEAD** 模式。
如果你后续要改成 `ghproxy``gitclone`、企业 Git 镜像或其他代理地址,需要同步修改:
```text

View File

@@ -96,6 +96,7 @@ CONFIG_KEYS = [
"grok2api_quota",
"kiro_manager_path",
"kiro_manager_exe",
"external_apps_update_mode",
"contribution_enabled",
"contribution_server_url",
"contribution_key",
@@ -136,6 +137,8 @@ def get_config():
all_cfg["contribution_enabled"] = "0"
if not all_cfg.get("contribution_server_url"):
all_cfg["contribution_server_url"] = "http://new.xem8k5.top:7317/"
if not all_cfg.get("external_apps_update_mode"):
all_cfg["external_apps_update_mode"] = "tag"
# 只返回已知 key未设置的返回空字符串
return {k: all_cfg.get(k, "") for k in CONFIG_KEYS}

View File

@@ -8,7 +8,7 @@ from sqlmodel import Session, select
from core.base_platform import Account, AccountStatus
from core.db import AccountModel, engine
from services.external_apps import install, list_status, start, start_all, stop, stop_all
from services.external_apps import install, list_status, start, start_all, stop, stop_all, uninstall
from services.chatgpt_sync import backfill_chatgpt_account_to_cpa, get_cliproxy_sync_state
router = APIRouter(prefix="/integrations", tags=["integrations"])
@@ -60,6 +60,11 @@ def install_service(name: str):
return install(name)
@router.post("/services/{name}/uninstall")
def uninstall_service(name: str):
return uninstall(name)
@router.post("/services/{name}/stop")
def stop_service(name: str):
return stop(name)

View File

@@ -169,9 +169,7 @@ def _run_register(task_id: str, req: RegisterTaskRequest):
success = 0
skipped = 0
errors = []
workspace_success = 0
start_gate_lock = threading.Lock()
workspace_progress_lock = threading.Lock()
next_start_time = time.time()
def _sleep_with_control(
@@ -203,7 +201,7 @@ def _run_register(task_id: str, req: RegisterTaskRequest):
)
def _do_one(i: int):
nonlocal next_start_time, workspace_success
nonlocal next_start_time
proxy_pool = None
_proxy = None
current_email = req.email or ""
@@ -295,13 +293,6 @@ def _run_register(task_id: str, req: RegisterTaskRequest):
if _proxy:
proxy_pool.report_success(_proxy)
_log(task_id, f"[OK] 注册成功: {account.email}")
workspace_id = ""
if isinstance(account.extra, dict):
workspace_id = str(account.extra.get("workspace_id") or "").strip()
if workspace_id:
with workspace_progress_lock:
workspace_success += 1
_log(task_id, f"[ChatGPT] workspace进度: {workspace_success}/{req.count}")
_save_task_log(req.platform, account.email, "success")
_auto_upload_integrations(task_id, saved_account or account)
cashier_url = (account.extra or {}).get("cashier_url", "")
@@ -358,6 +349,11 @@ def _run_register(task_id: str, req: RegisterTaskRequest):
stopped = True
else:
errors.append(result.message)
_task_store.update_counters(
task_id,
success=success,
registered=success + skipped + len(errors),
)
if stopped or control.is_stop_requested():
stopped = True
for pending in futures:
@@ -369,6 +365,7 @@ def _run_register(task_id: str, req: RegisterTaskRequest):
task_id,
status="failed",
success=success,
registered=success + skipped + len(errors),
skipped=skipped,
errors=errors,
error=str(e),
@@ -388,6 +385,7 @@ def _run_register(task_id: str, req: RegisterTaskRequest):
task_id,
status=final_status,
success=success,
registered=success + skipped + len(errors),
skipped=skipped,
errors=errors,
)
@@ -473,11 +471,17 @@ async def stream_logs(task_id: str, since: int = 0):
sent = since
while True:
logs, status = _task_store.log_state(task_id)
snapshot = _task_store.snapshot(task_id)
counters = {
"success": int(snapshot.get("success") or 0),
"registered": int(snapshot.get("registered") or 0),
"total": int(snapshot.get("total") or 0),
}
while sent < len(logs):
yield f"data: {json.dumps({'line': logs[sent]})}\n\n"
yield f"data: {json.dumps({'line': logs[sent], **counters})}\n\n"
sent += 1
if status in ("done", "failed", "stopped"):
yield f"data: {json.dumps({'done': True, 'status': status})}\n\n"
yield f"data: {json.dumps({'done': True, 'status': status, **counters})}\n\n"
break
await asyncio.sleep(0.5)

View File

@@ -137,6 +137,7 @@ class RegisterTaskRecord:
progress: str = "0/0"
logs: list[str] = field(default_factory=list)
success: int = 0
registered: int = 0
skipped: int = 0
errors: list[str] = field(default_factory=list)
cashier_urls: list[str] = field(default_factory=list)
@@ -155,9 +156,11 @@ class RegisterTaskRecord:
"platform": self.platform,
"source": self.source,
"meta": dict(self.meta),
"total": self.total,
"progress": self.progress,
"logs": list(self.logs),
"success": self.success,
"registered": self.registered,
"skipped": self.skipped,
"errors": list(self.errors),
"control": self.control.snapshot(),
@@ -265,12 +268,28 @@ class RegisterTaskStore:
record.cashier_urls.append(cashier_url)
record.updated_at = time.time()
def update_counters(
self,
task_id: str,
*,
success: int | None = None,
registered: int | None = None,
) -> None:
with self._lock:
record = self._records[task_id]
if success is not None:
record.success = max(0, int(success))
if registered is not None:
record.registered = max(0, int(registered))
record.updated_at = time.time()
def finish(
self,
task_id: str,
*,
status: str,
success: int,
registered: int | None = None,
skipped: int,
errors: list[str],
error: str = "",
@@ -279,6 +298,10 @@ class RegisterTaskStore:
record = self._records[task_id]
record.status = status
record.success = success
if registered is None:
record.registered = max(success + skipped + len(errors), 0)
else:
record.registered = max(0, int(registered))
record.skipped = skipped
record.errors = list(errors)
record.error = error

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { Button, message, Space } from 'antd'
import { Button, message, Space, Tag } from 'antd'
import { CopyOutlined, FastForwardOutlined, StopOutlined } from '@ant-design/icons'
import { API_BASE, apiFetch, getToken } from '@/lib/utils'
@@ -11,8 +11,36 @@ interface TaskLogPanelProps {
type TaskTerminalStatus = 'idle' | 'done' | 'failed' | 'stopped'
interface RegisterSummary {
success: number
registered: number
total: number
}
function parseCounter(value: unknown): number {
const n = Number(value || 0)
if (!Number.isFinite(n) || n < 0) return 0
return Math.floor(n)
}
function normalizeSummary(next: RegisterSummary): RegisterSummary {
const success = parseCounter(next.success)
const registered = Math.max(parseCounter(next.registered), success)
const total = Math.max(parseCounter(next.total), registered)
return { success, registered, total }
}
function mergeSummary(previous: RegisterSummary, incoming: Partial<RegisterSummary>): RegisterSummary {
return normalizeSummary({
success: incoming.success ?? previous.success,
registered: incoming.registered ?? previous.registered,
total: incoming.total ?? previous.total,
})
}
export function TaskLogPanel({ taskId, onDone }: TaskLogPanelProps) {
const [lines, setLines] = useState<string[]>([])
const [summary, setSummary] = useState<RegisterSummary>({ success: 0, registered: 0, total: 0 })
const [error, setError] = useState('')
const [terminalStatus, setTerminalStatus] = useState<TaskTerminalStatus>('idle')
const [skipLoading, setSkipLoading] = useState(false)
@@ -81,6 +109,7 @@ export function TaskLogPanel({ taskId, onDone }: TaskLogPanelProps) {
const maxRetryMs = 8000
nextSinceRef.current = 0
setLines([])
setSummary({ success: 0, registered: 0, total: 0 })
setError('')
setTerminalStatus('idle')
setStopRequested(false)
@@ -93,12 +122,22 @@ export function TaskLogPanel({ taskId, onDone }: TaskLogPanelProps) {
const snapshot = await apiFetch(`/tasks/${taskId}`) as {
logs?: string[]
status?: TaskTerminalStatus | string
success?: number
registered?: number
total?: number
control?: { stop_requested?: boolean }
}
if (cancelled) return true
const snapshotLines = Array.isArray(snapshot.logs) ? snapshot.logs : []
setLines(snapshotLines)
setSummary((previous) =>
mergeSummary(previous, {
success: snapshot.success,
registered: snapshot.registered,
total: snapshot.total,
}),
)
nextSinceRef.current = snapshotLines.length
setStopRequested(Boolean(snapshot.control?.stop_requested))
@@ -159,7 +198,17 @@ export function TaskLogPanel({ taskId, onDone }: TaskLogPanelProps) {
line?: string
done?: boolean
status?: TaskTerminalStatus
success?: number
registered?: number
total?: number
}
setSummary((previous) =>
mergeSummary(previous, {
success: payload.success,
registered: payload.registered,
total: payload.total,
}),
)
if (payload.line) {
nextSinceRef.current += 1
setLines((previous) => [...previous, payload.line!])
@@ -224,6 +273,12 @@ export function TaskLogPanel({ taskId, onDone }: TaskLogPanelProps) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Space wrap style={{ marginBottom: 8 }}>
<Tag color="green">{summary.success}</Tag>
<Tag color="blue">{summary.registered}</Tag>
<Tag color="default">{summary.total}</Tag>
</Space>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<Space>
<Button

View File

@@ -71,6 +71,10 @@ const SELECT_FIELDS: Record<string, { label: string; value: string }[]> = {
{ label: 'ATAccess Token推荐', value: 'at' },
{ label: 'RTRefresh Token', value: 'rt' },
],
external_apps_update_mode: [
{ label: 'latest semver tag推荐', value: 'tag' },
{ label: '分支 HEAD', value: 'branch' },
],
}
const TAB_ITEMS = [
@@ -793,6 +797,7 @@ function IntegrationsPanel() {
const [items, setItems] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const [busy, setBusy] = useState('')
const [updateMode, setUpdateMode] = useState<'tag' | 'branch'>('tag')
const saved = false
const [resultModal, setResultModal] = useState({
open: false,
@@ -813,8 +818,13 @@ function IntegrationsPanel() {
const load = async () => {
setLoading(true)
try {
const d = await apiFetch('/integrations/services')
const [d, cfg] = await Promise.all([
apiFetch('/integrations/services'),
apiFetch('/config'),
])
setItems(d.items || [])
const mode = String(cfg?.external_apps_update_mode || 'tag').trim().toLowerCase()
setUpdateMode(mode === 'branch' ? 'branch' : 'tag')
} finally {
setLoading(false)
}
@@ -859,6 +869,22 @@ function IntegrationsPanel() {
}
}
const updateInstallMode = async (nextMode: 'tag' | 'branch') => {
setBusy('update-mode')
try {
await apiFetch('/config', {
method: 'PUT',
body: JSON.stringify({ data: { external_apps_update_mode: nextMode } }),
})
setUpdateMode(nextMode)
message.success(nextMode === 'tag' ? '已切换到 tag 模式' : '已切换到分支模式')
} catch (e: any) {
message.error(e?.message || '切换失败')
} finally {
setBusy('')
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{false ? (
@@ -919,6 +945,24 @@ function IntegrationsPanel() {
</pre>
</Modal>
<Card title="安装/更新策略">
<Space wrap align="center">
<Select
style={{ width: 320 }}
value={updateMode}
options={SELECT_FIELDS.external_apps_update_mode}
onChange={(value) => setUpdateMode(value as 'tag' | 'branch')}
/>
<Button
type="primary"
loading={busy === 'update-mode'}
onClick={() => updateInstallMode(updateMode)}
>
</Button>
</Space>
</Card>
<Card title="批量操作">
<Space wrap>
<Button loading={busy === 'start-all'} onClick={() => doAction('start-all', apiFetch('/integrations/services/start-all', { method: 'POST' }))}>
@@ -964,9 +1008,16 @@ function IntegrationsPanel() {
loading={busy === `install-${item.name}`}
onClick={() => doAction(`install-${item.name}`, apiFetch(`/integrations/services/${item.name}/install`, { method: 'POST' }))}
>
</Button>
) : null}
) : (
<Button
loading={busy === `install-${item.name}`}
onClick={() => doAction(`install-${item.name}`, apiFetch(`/integrations/services/${item.name}/install`, { method: 'POST' }))}
>
</Button>
)}
<Button
loading={busy === `start-${item.name}`}
disabled={!item.repo_exists}
@@ -980,6 +1031,21 @@ function IntegrationsPanel() {
>
</Button>
<Button
danger
loading={busy === `uninstall-${item.name}`}
disabled={!item.repo_exists}
onClick={() => {
const ok = window.confirm(`确认卸载 ${item.label}\n会停止服务并删除本地插件目录。`)
if (!ok) return
doAction(
`uninstall-${item.name}`,
apiFetch(`/integrations/services/${item.name}/uninstall`, { method: 'POST' }),
)
}}
>
</Button>
{item.name === 'grok2api' ? (
<Button
loading={busy === 'backfill-grok'}

View File

@@ -3,6 +3,8 @@
from __future__ import annotations
import os
import re
import stat
import shutil
import subprocess
import sys
@@ -64,6 +66,7 @@ _PROCS: dict[str, subprocess.Popen] = {}
_LOG_FILES: dict[str, Any] = {}
_LAST_ERROR: dict[str, str] = {}
_LOCK = threading.Lock()
_SEMVER_TAG_PATTERN = re.compile(r"^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$")
def _get_setting(key: str, default: str = "") -> str:
@@ -104,25 +107,280 @@ def _open_log(name: str):
return f
def _clone_repo_if_missing(name: str):
repo = _repo_path(name)
if repo.exists():
def _make_tree_writable(path: Path):
if not path.exists():
return
repo.parent.mkdir(parents=True, exist_ok=True)
for root, dirs, files in os.walk(path):
for dirname in dirs:
p = Path(root) / dirname
try:
p.chmod(p.stat().st_mode | stat.S_IWRITE)
except Exception:
pass
for filename in files:
p = Path(root) / filename
try:
p.chmod(p.stat().st_mode | stat.S_IWRITE)
except Exception:
pass
try:
path.chmod(path.stat().st_mode | stat.S_IWRITE)
except Exception:
pass
def _kill_processes_touching_path(path: Path):
if os.name != "nt":
return
try:
subprocess.run(
[
"powershell",
"-NoProfile",
"-Command",
"$p=$args[0]; "
"Get-CimInstance Win32_Process | "
"Where-Object { (($_.CommandLine -like ('*' + $p + '*')) -or ($_.ExecutablePath -like ('*' + $p + '*'))) } | "
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }",
str(path),
],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=_creationflags(),
)
except Exception:
pass
def _external_apps_update_mode() -> str:
mode = _get_setting("external_apps_update_mode", "tag").strip().lower()
return "branch" if mode == "branch" else "tag"
def _git_has_remote_branch(repo: Path, branch: str) -> bool:
if not branch:
return False
check = subprocess.run(
["git", "-C", str(repo), "show-ref", "--verify", "--quiet", f"refs/remotes/origin/{branch}"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=_creationflags(),
)
return check.returncode == 0
def _origin_default_branch(repo: Path) -> str:
try:
out = subprocess.check_output(
["git", "-C", str(repo), "symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
text=True,
creationflags=_creationflags(),
).strip()
if out.startswith("origin/"):
branch = out.split("/", 1)[1].strip()
if branch:
return branch
except Exception:
pass
for candidate in ("main", "master"):
if _git_has_remote_branch(repo, candidate):
return candidate
return "main"
def _current_local_branch(repo: Path) -> str:
try:
branch = subprocess.check_output(
["git", "-C", str(repo), "rev-parse", "--abbrev-ref", "HEAD"],
text=True,
creationflags=_creationflags(),
).strip()
except Exception:
return ""
return branch if branch and branch != "HEAD" else ""
def _sync_repo_to_branch_head(repo: Path, preferred_branch: str = ""):
candidates = []
preferred = str(preferred_branch or "").strip()
if preferred:
candidates.append(preferred)
local_branch = _current_local_branch(repo)
if local_branch and local_branch not in candidates:
candidates.append(local_branch)
default_branch = _origin_default_branch(repo)
if default_branch and default_branch not in candidates:
candidates.append(default_branch)
for fallback in ("main", "master"):
if fallback not in candidates:
candidates.append(fallback)
for branch in candidates:
if not _git_has_remote_branch(repo, branch):
continue
subprocess.run(
["git", "-C", str(repo), "checkout", "-B", branch, f"origin/{branch}"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=_creationflags(),
)
subprocess.run(
["git", "-C", str(repo), "reset", "--hard", f"origin/{branch}"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=_creationflags(),
)
subprocess.run(
["git", "-C", str(repo), "clean", "-fd"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=_creationflags(),
)
return
raise RuntimeError(f"未找到可用远端分支repo={repo}")
def _latest_semver_tag(repo: Path) -> str:
try:
out = subprocess.check_output(
[
"git",
"-C",
str(repo),
"for-each-ref",
"refs/tags",
"--sort=-version:refname",
"--format=%(refname:strip=2)",
],
text=True,
creationflags=_creationflags(),
)
except Exception:
return ""
for line in out.splitlines():
tag = str(line or "").strip()
if not tag:
continue
if _SEMVER_TAG_PATTERN.fullmatch(tag):
return tag
return ""
def _sync_repo_to_latest_semver_tag(repo: Path) -> bool:
tag = _latest_semver_tag(repo)
if not tag:
return False
subprocess.run(
["git", "clone", _REMOTE_URLS[name], str(repo)],
["git", "-C", str(repo), "checkout", "--force", tag],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=_creationflags(),
)
subprocess.run(
["git", "-C", str(repo), "reset", "--hard", tag],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=_creationflags(),
)
subprocess.run(
["git", "-C", str(repo), "clean", "-fd"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=_creationflags(),
)
return True
def _sync_repo_to_latest(name: str):
repo = _repo_path(name)
repo.parent.mkdir(parents=True, exist_ok=True)
if not repo.exists():
subprocess.run(
["git", "clone", _REMOTE_URLS[name], str(repo)],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=_creationflags(),
)
subprocess.run(
["git", "-C", str(repo), "fetch", "--all", "--tags", "--prune"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=_creationflags(),
)
mode = _external_apps_update_mode()
if mode == "branch":
_sync_repo_to_branch_head(repo)
return
if not _sync_repo_to_latest_semver_tag(repo):
_sync_repo_to_branch_head(repo)
def install(name: str) -> dict[str, Any]:
with _LOCK:
if name not in _SERVICE_META:
raise KeyError(name)
_clone_repo_if_missing(name)
_sync_repo_to_latest(name)
return _status_one(name)
def uninstall(name: str) -> dict[str, Any]:
if name not in _SERVICE_META:
raise KeyError(name)
try:
stop(name)
except Exception:
pass
with _LOCK:
repo = _repo_path(name)
if repo.exists():
_kill_processes_touching_path(repo)
last_exc: Exception | None = None
for _ in range(12):
try:
_make_tree_writable(repo)
shutil.rmtree(repo)
last_exc = None
break
except Exception as exc:
last_exc = exc
_kill_processes_touching_path(repo)
try:
subprocess.run(
["attrib", "-R", str(repo / "*"), "/S", "/D"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=_creationflags(),
)
except Exception:
pass
time.sleep(0.5)
if repo.exists():
_LAST_ERROR[name] = (
f"卸载失败:目录仍存在 {repo}"
+ (f",原因:{last_exc}" if last_exc else "")
)
raise RuntimeError(_LAST_ERROR[name])
_PROCS.pop(name, None)
_LAST_ERROR.pop(name, None)
_close_log(name)
return _status_one(name)