ソースを参照

feat(v0.5.1): admin panel — 5-tab dashboard + sidebar entry + trend chart

- AdminDashboard: summary/trend/users/detail/teams tabs
- Token minute-level trend chart (SVG)
- User ranking + detail table with pagination
- Team CRUD + member invite
- Sidebar admin icon (shield-check) triggers /admin route
- Admin server (JSON-store + JWT auth) on port 4097
- Clean up cloud server fork, rewrite sidecar startup
KirinCode 1 ヶ月 前
コミット
59bfbd3de4
37 ファイル変更523 行追加209 行削除
  1. 1 1
      packages/app/package.json
  2. 281 166
      packages/app/src/pages/admin/AdminDashboard.tsx
  3. 2 0
      packages/app/src/pages/layout.tsx
  4. 11 0
      packages/app/src/pages/layout/sidebar-shell.tsx
  5. 1 1
      packages/cli/package.json
  6. 1 1
      packages/codemode/package.json
  7. 1 1
      packages/console/app/package.json
  8. 1 1
      packages/console/core/package.json
  9. 1 1
      packages/console/function/package.json
  10. 1 1
      packages/console/mail/package.json
  11. 1 1
      packages/console/support/package.json
  12. 1 1
      packages/core/package.json
  13. 1 1
      packages/desktop/electron.vite.config.ts
  14. 1 1
      packages/desktop/package.json
  15. 197 0
      packages/desktop/src/main/admin-server.ts
  16. 0 11
      packages/desktop/src/main/sidecar.ts
  17. 1 1
      packages/effect-drizzle-sqlite/package.json
  18. 1 1
      packages/effect-sqlite-node/package.json
  19. 1 1
      packages/enterprise/package.json
  20. 1 1
      packages/function/package.json
  21. 1 1
      packages/http-recorder/package.json
  22. 1 1
      packages/kirincode-cloud/package.json
  23. 1 1
      packages/kirincode-workflow/package.json
  24. 1 1
      packages/kirincode/package.json
  25. 1 1
      packages/llm/package.json
  26. 1 1
      packages/mobile/package.json
  27. 1 1
      packages/plugin/package.json
  28. 1 1
      packages/sdk/js/package.json
  29. 1 1
      packages/server/package.json
  30. 1 1
      packages/session-ui/package.json
  31. 1 1
      packages/slack/package.json
  32. 1 1
      packages/stats/app/package.json
  33. 1 1
      packages/stats/core/package.json
  34. 1 1
      packages/stats/server/package.json
  35. 1 1
      packages/tui/package.json
  36. 1 1
      packages/ui/package.json
  37. 1 1
      packages/web/package.json

+ 1 - 1
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@kirincode-ai/app",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "description": "",
   "type": "module",
   "exports": {

+ 281 - 166
packages/app/src/pages/admin/AdminDashboard.tsx

@@ -1,218 +1,333 @@
-import { createSignal, createResource, onMount, For, Show } from "solid-js"
+import { createSignal, createResource, onMount, For, Show, createEffect, createMemo } from "solid-js"
 
-const API_BASE = "http://localhost:3000/api/v1"
+const API_BASE = "http://localhost:4097/api/v1"
+const LS_TOKEN_KEY = "kirincode_admin_token"
 
-async function fetchJson(url: string, token: string) {
-  const res = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
+// Simple fetch wrapper
+async function api(path: string, method = "GET", body?: any): Promise<any> {
+  const token = localStorage.getItem(LS_TOKEN_KEY)
+  const headers: Record<string, string> = { "Content-Type": "application/json" }
+  if (token) headers["Authorization"] = `Bearer ${token}`
+  const res = await fetch(`${API_BASE}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined })
   return res.json()
 }
 
+type Tab = "summary" | "trend" | "users" | "detail" | "teams"
+type TimeRange = "24h" | "7d" | "30d" | "all"
+
+function timeRange(r: TimeRange): { from: string; to: string } {
+  const now = new Date()
+  const from = new Date(now)
+  if (r === "24h") from.setHours(from.getHours() - 24)
+  else if (r === "7d") from.setDate(from.getDate() - 7)
+  else if (r === "30d") from.setDate(from.getDate() - 30)
+  else from.setFullYear(2000)
+  return { from: from.toISOString(), to: now.toISOString() }
+}
+
 export function AdminDashboard() {
-  const [token, setToken] = createSignal(localStorage.getItem("kirincode_token") || "")
+  const [token, setToken] = createSignal(localStorage.getItem(LS_TOKEN_KEY) || "")
+  const [loggedIn, setLoggedIn] = createSignal(!!token())
   const [email, setEmail] = createSignal("")
   const [password, setPassword] = createSignal("")
-  const [loggedIn, setLoggedIn] = createSignal(!!token())
-  const [tab, setTab] = createSignal<"today" | "team" | "users" | "minute">("today")
-
-  // Login
-  async function login() {
-    const res = await fetch(API_BASE + "/auth/login", {
-      method: "POST",
-      headers: { "Content-Type": "application/json" },
-      body: JSON.stringify({ email: email(), password: password() }),
-    })
-    const data = await res.json()
-    if (data.token) {
-      localStorage.setItem("kirincode_token", data.token)
+  const [displayName, setDisplayName] = createSignal("")
+  const [isRegister, setIsRegister] = createSignal(false)
+  const [tab, setTab] = createSignal<Tab>("summary")
+  const [timeRange, setTimeRange] = createSignal<TimeRange>("24h")
+  const [userFilter, setUserFilter] = createSignal("")
+  const [modelFilter, setModelFilter] = createSignal("")
+  const [detailPage, setDetailPage] = createSignal(1)
+  const [teamName, setTeamName] = createSignal("")
+  const [inviteEmail, setInviteEmail] = createSignal("")
+  const [error, setError] = createSignal("")
+
+  async function doAuth() {
+    setError("")
+    const endpoint = isRegister() ? "/auth/register" : "/auth/login"
+    const body = isRegister() ? { email: email(), password: password(), display_name: displayName() } : { email: email(), password: password() }
+    try {
+      const data = await api(endpoint, "POST", body)
+      if (data.error) { setError(data.error); return }
+      localStorage.setItem(LS_TOKEN_KEY, data.token)
       setToken(data.token)
       setLoggedIn(true)
-    }
+    } catch { setError("Connection failed — start admin server on port 4097") }
+  }
+
+  function logout() {
+    localStorage.removeItem(LS_TOKEN_KEY)
+    setToken(""); setLoggedIn(false)
   }
 
-  // Fetch data
-  const [todayData] = createResource(loggedIn, () => fetchJson(`${API_BASE}/admin/usage/today`, token()))
-  const [teamData] = createResource(loggedIn, () => fetchJson(`${API_BASE}/admin/usage/team`, token()))
-  const [usersData] = createResource(loggedIn, () => fetchJson(`${API_BASE}/admin/usage/users`, token()))
-  const [minuteData] = createResource(loggedIn, () => fetchJson(`${API_BASE}/admin/usage/minute`, token()))
+  // Data fetching
+  const tr = createMemo(() => timeRange())
+  const fromTo = createMemo(() => timeRange(tr()))
+  const fetcher = (path: string) => api(path)
 
-  // Refs for auto-refresh
+  const [summary] = createResource(loggedIn, () => api(`/admin/usage/summary?from=${fromTo().from}&to=${fromTo().to}`))
+  const [trend] = createResource(() => loggedIn() && tab() === "trend", () => api(`/admin/usage/trend`))
+  const [users] = createResource(() => loggedIn() && (tab() === "users" || tab() === "summary"), () => {
+    const p: string[] = []
+    if (userFilter()) p.push(`user_id=${userFilter()}`)
+    if (modelFilter()) p.push(`model=${modelFilter()}`)
+    return api(`/admin/usage/users?from=${fromTo().from}&to=${fromTo().to}${p.length ? "&" + p.join("&") : ""}`)
+  })
+  const [details] = createResource(() => loggedIn() && tab() === "detail", () => {
+    const p: string[] = [`page=${detailPage()}`]
+    if (userFilter()) p.push(`user_id=${userFilter()}`)
+    if (modelFilter()) p.push(`model=${modelFilter()}`)
+    return api(`/admin/usage/detail?from=${fromTo().from}&to=${fromTo().to}&${p.join("&")}`)
+  })
+  const [teams] = createResource(() => loggedIn() && tab() === "teams", () => api("/admin/teams"))
+
+  // Auto-refresh
   onMount(() => {
-    const interval = setInterval(() => {
+    const i = setInterval(() => {
       if (loggedIn()) {
-        fetchJson(`${API_BASE}/admin/usage/today`, token()).then(d => { /* trigger re-fetch */ })
+        api(`/admin/usage/summary?from=${fromTo().from}&to=${fromTo().to}`)
       }
     }, 30000)
-    return () => clearInterval(interval)
+    return () => clearInterval(i)
   })
 
   if (!loggedIn()) {
     return (
-      <div style={{ display: "flex", "flex-direction": "column", "align-items": "center", "justify-content": "center", height: "100vh", background: "#0f172a", color: "#ecf0f1", "font-family": "system-ui, sans-serif" }}>
-        <h1 style={{ color: "#e74c3c", "margin-bottom": "24px" }}>KirinCode Admin</h1>
+      <div style={styles.loginPage}>
+        <h1 style={styles.loginTitle}>🛡 KirinCode 管理</h1>
         <div style={{ display: "flex", "flex-direction": "column", gap: "12px", width: "320px" }}>
-          <input
-            type="email" placeholder="Email" value={email()} onInput={e => setEmail(e.currentTarget.value)}
-            style={{ padding: "10px 14px", "border-radius": "8px", border: "1px solid #2c3e50", background: "#1a1a2e", color: "#ecf0f1", "font-size": "14px" }}
-          />
-          <input
-            type="password" placeholder="Password" value={password()} onInput={e => setPassword(e.currentTarget.value)}
-            style={{ padding: "10px 14px", "border-radius": "8px", border: "1px solid #2c3e50", background: "#1a1a2e", color: "#ecf0f1", "font-size": "14px" }}
-          />
-          <button onClick={login}
-            style={{ padding: "10px", "border-radius": "8px", border: "none", background: "#e74c3c", color: "#fff", "font-size": "14px", "font-weight": "600", cursor: "pointer" }}
-          >Login</button>
+          <input type="email" placeholder="邮箱" value={email()} onInput={e => setEmail(e.currentTarget.value)} style={styles.input} />
+          <input type="password" placeholder="密码" value={password()} onInput={e => setPassword(e.currentTarget.value)} style={styles.input} onKeyDown={e => e.key === "Enter" && doAuth()} />
+          <Show when={isRegister()}>
+            <input type="text" placeholder="显示名称" value={displayName()} onInput={e => setDisplayName(e.currentTarget.value)} style={styles.input} />
+          </Show>
+          <button onClick={doAuth} style={styles.btn}>{isRegister() ? "注册" : "登录"}</button>
+          <div style={{ color: "#e74c3c", "font-size": "13px", "text-align": "center" }}>{error()}</div>
+          <a onClick={() => { setIsRegister(!isRegister()); setError("") }} style={{ color: "#7f8c8d", "text-align": "center", cursor: "pointer", "font-size": "13px" }}>
+            {isRegister() ? "已有账号?登录" : "没有账号?注册"}
+          </a>
         </div>
       </div>
     )
   }
 
+  const tabs: { key: Tab; label: string }[] = [
+    { key: "summary", label: "今日概览" },
+    { key: "trend", label: "趋势图表" },
+    { key: "users", label: "用户排行" },
+    { key: "detail", label: "分钟明细" },
+    { key: "teams", label: "团队管理" },
+  ]
+
   return (
-    <div style={{ display: "flex", "flex-direction": "column", height: "100vh", background: "#0f172a", color: "#ecf0f1", "font-family": "system-ui, sans-serif" }}>
+    <div style={styles.page}>
       {/* Header */}
-      <div style={{ display: "flex", "align-items": "center", padding: "12px 20px", background: "#1a1a2e", "border-bottom": "1px solid #2c3e50" }}>
-        <span style={{ "font-size": "18px", "font-weight": "700", color: "#e74c3c", "margin-right": "24px" }}>🦄 KirinCode Admin</span>
-        <div style={{ display: "flex", gap: "4px" }}>
-          {(["today", "team", "users", "minute"] as const).map(t => (
-            <button onClick={() => setTab(t)}
-              style={{
-                padding: "6px 14px", "border-radius": "6px", border: "none", "font-size": "13px", cursor: "pointer",
-                background: tab() === t ? "#e74c3c" : "transparent",
-                color: tab() === t ? "#fff" : "#7f8c8d",
-              }}
-            >{t === "today" ? "Today" : t === "team" ? "Team" : t === "users" ? "Users" : "Minute"}</button>
+      <div style={styles.header}>
+        <span style={styles.headerTitle}>🛡 KirinCode 管理</span>
+        <div style={{ display: "flex", gap: "8px" }}>
+          {tabs.map(t => (
+            <button onClick={() => setTab(t.key)} style={{ ...styles.tabBtn, background: tab() === t.key ? "#e74c3c" : "transparent", color: tab() === t.key ? "#fff" : "#7f8c8d" }}>
+              {t.label}
+            </button>
           ))}
         </div>
-        <button onClick={() => { localStorage.removeItem("kirincode_token"); setToken(""); setLoggedIn(false) }}
-          style={{ "margin-left": "auto", padding: "6px 14px", "border-radius": "6px", border: "1px solid #2c3e50", background: "transparent", color: "#7f8c8d", cursor: "pointer", "font-size": "12px" }}
-        >Logout</button>
+        <button onClick={logout} style={styles.logoutBtn}>退出</button>
       </div>
 
       {/* Content */}
-      <div style={{ flex: 1, overflow: "auto", padding: "24px" }}>
-        <Show when={tab() === "today" && todayData()}>
-          {() => {
-            const d = todayData()?.data || {}
-            return (
-              <div>
-                <h2 style={{ "margin-bottom": "20px" }}>Today's Usage</h2>
-                <div style={{ display: "grid", "grid-template-columns": "repeat(auto-fit, minmax(200px, 1fr))", gap: "16px" }}>
-                  <MetricCard label="Total Tokens" value={(d.input_tokens + d.output_tokens).toLocaleString()} />
-                  <MetricCard label="Requests" value={d.request_count?.toLocaleString() || "0"} />
-                  <MetricCard label="Cost" value={`$${d.total_cost?.toFixed(4) || "0.0000"}`} />
-                  <MetricCard label="Active Users" value={d.active_users || "0"} />
-                </div>
-              </div>
-            )
-          }}
+      <div style={styles.content}>
+        {/* Summary / Overview */}
+        <Show when={tab() === "summary"}>
+          <h2 style={styles.sectionTitle}>Token 消耗概览</h2>
+          <div style={styles.cards}>
+            <Card label="总 Token" value={((summary()?.input || 0) + (summary()?.output || 0)).toLocaleString()} />
+            <Card label="请求次数" value={(summary()?.requests || 0).toLocaleString()} />
+            <Card label="今日费用" value={`$${(summary()?.cost || 0).toFixed(4)}`} />
+            <Card label="活跃用户" value={String(summary()?.users || 0)} />
+          </div>
+
+          {/* Mini trend chart preview */}
+          <Show when={users()}>
+            <h3 style={{ ...styles.sectionTitle, "margin-top": "24px" }}>用户用量排行</h3>
+            <UserTable data={(users() || []).slice(0, 5)} />
+          </Show>
         </Show>
 
-        <Show when={tab() === "team" && teamData()}>
-          {() => (
-            <div>
-              <h2 style={{ "margin-bottom": "20px" }}>Team Usage</h2>
-              <table style={{ width: "100%", "border-collapse": "collapse" }}>
-                <thead>
-                  <tr style={{ "text-align": "left", color: "#7f8c8d", "font-size": "12px" }}>
-                    <th style={{ padding: "8px" }}>Day</th>
-                    <th style={{ padding: "8px" }}>Provider</th>
-                    <th style={{ padding: "8px" }}>Model</th>
-                    <th style={{ padding: "8px", "text-align": "right" }}>Input</th>
-                    <th style={{ padding: "8px", "text-align": "right" }}>Output</th>
-                    <th style={{ padding: "8px", "text-align": "right" }}>Requests</th>
-                    <th style={{ padding: "8px", "text-align": "right" }}>Cost</th>
-                  </tr>
-                </thead>
-                <For each={(teamData()?.data || []).slice(0, 50)}>
-                  {(row: any) => (
-                    <tr style={{ "border-top": "1px solid #1e293b", "font-size": "13px" }}>
-                      <td style={{ padding: "8px" }}>{row.day}</td>
-                      <td style={{ padding: "8px" }}>{row.provider}</td>
-                      <td style={{ padding: "8px" }}>{row.model}</td>
-                      <td style={{ padding: "8px", "text-align": "right" }}>{row.input_tokens?.toLocaleString()}</td>
-                      <td style={{ padding: "8px", "text-align": "right" }}>{row.output_tokens?.toLocaleString()}</td>
-                      <td style={{ padding: "8px", "text-align": "right" }}>{row.request_count}</td>
-                      <td style={{ padding: "8px", "text-align": "right", color: "#f39c12" }}>${row.total_cost?.toFixed(4)}</td>
-                    </tr>
-                  )}
-                </For>
-              </table>
-            </div>
-          )}
+        {/* Trend Chart */}
+        <Show when={tab() === "trend"}>
+          <h2 style={styles.sectionTitle}>分钟级 Token 消耗趋势</h2>
+          <div style={styles.chart}>
+            <Show when={trend() && (trend() as any[]).length > 0} fallback={<div style={{ color: "#7f8c8d", padding: "40px" }}>暂无数据</div>}>
+              <TrendChart data={trend() || []} />
+            </Show>
+          </div>
         </Show>
 
-        <Show when={tab() === "users" && usersData()}>
-          {() => (
-            <div>
-              <h2 style={{ "margin-bottom": "20px" }}>User Rankings</h2>
-              <table style={{ width: "100%", "border-collapse": "collapse" }}>
-                <thead>
-                  <tr style={{ "text-align": "left", color: "#7f8c8d", "font-size": "12px" }}>
-                    <th style={{ padding: "8px" }}>User</th>
-                    <th style={{ padding: "8px" }}>Email</th>
-                    <th style={{ padding: "8px", "text-align": "right" }}>Input</th>
-                    <th style={{ padding: "8px", "text-align": "right" }}>Output</th>
-                    <th style={{ padding: "8px", "text-align": "right" }}>Requests</th>
-                    <th style={{ padding: "8px", "text-align": "right" }}>Cost</th>
-                  </tr>
-                </thead>
-                <For each={usersData()?.data || []}>
-                  {(row: any) => (
-                    <tr style={{ "border-top": "1px solid #1e293b", "font-size": "13px" }}>
-                      <td style={{ padding: "8px" }}>{row.display_name}</td>
-                      <td style={{ padding: "8px", color: "#7f8c8d" }}>{row.email}</td>
-                      <td style={{ padding: "8px", "text-align": "right" }}>{row.input_tokens?.toLocaleString()}</td>
-                      <td style={{ padding: "8px", "text-align": "right" }}>{row.output_tokens?.toLocaleString()}</td>
-                      <td style={{ padding: "8px", "text-align": "right" }}>{row.request_count}</td>
-                      <td style={{ padding: "8px", "text-align": "right", color: "#f39c12" }}>${row.total_cost?.toFixed(4)}</td>
-                    </tr>
-                  )}
-                </For>
-              </table>
-            </div>
-          )}
+        {/* Users */}
+        <Show when={tab() === "users"}>
+          <div style={{ display: "flex", "justify-content": "space-between", "align-items": "center" }}>
+            <h2 style={styles.sectionTitle}>用户用量排行</h2>
+            <select value={timeRange()} onInput={e => setTimeRange(e.currentTarget.value as TimeRange)} style={styles.select}>
+              <option value="24h">最近24小时</option><option value="7d">最近7天</option><option value="30d">最近30天</option><option value="all">全部</option>
+            </select>
+          </div>
+          <UserTable data={users() || []} />
         </Show>
 
-        <Show when={tab() === "minute" && minuteData()}>
-          {() => (
-            <div>
-              <h2 style={{ "margin-bottom": "20px" }}>Minute-Level Detail</h2>
-              <table style={{ width: "100%", "border-collapse": "collapse" }}>
-                <thead>
-                  <tr style={{ "text-align": "left", color: "#7f8c8d", "font-size": "12px" }}>
-                    <th style={{ padding: "8px" }}>Time</th>
-                    <th style={{ padding: "8px" }}>Provider</th>
-                    <th style={{ padding: "8px" }}>Model</th>
-                    <th style={{ padding: "8px", "text-align": "right" }}>Input</th>
-                    <th style={{ padding: "8px", "text-align": "right" }}>Output</th>
-                    <th style={{ padding: "8px", "text-align": "right" }}>Cost</th>
-                  </tr>
-                </thead>
-                <For each={(minuteData()?.data || []).slice(0, 100)}>
-                  {(row: any) => (
-                    <tr style={{ "border-top": "1px solid #1e293b", "font-size": "12px" }}>
-                      <td style={{ padding: "8px" }}>{row.minute}</td>
-                      <td style={{ padding: "8px" }}>{row.provider}</td>
-                      <td style={{ padding: "8px" }}>{row.model}</td>
-                      <td style={{ padding: "8px", "text-align": "right" }}>{row.input_tokens?.toLocaleString()}</td>
-                      <td style={{ padding: "8px", "text-align": "right" }}>{row.output_tokens?.toLocaleString()}</td>
-                      <td style={{ padding: "8px", "text-align": "right", color: "#f39c12" }}>${row.cost_usd?.toFixed(5)}</td>
-                    </tr>
-                  )}
-                </For>
-              </table>
+        {/* Detail */}
+        <Show when={tab() === "detail"}>
+          <div style={{ display: "flex", "justify-content": "space-between", "align-items": "center", "flex-wrap": "wrap", gap: "12px" }}>
+            <h2 style={styles.sectionTitle}>分钟级明细</h2>
+            <div style={{ display: "flex", gap: "8px" }}>
+              <input type="text" placeholder="筛选用户ID" value={userFilter()} onInput={e => setUserFilter(e.currentTarget.value)} style={styles.filterInput} />
+              <input type="text" placeholder="筛选模型" value={modelFilter()} onInput={e => setModelFilter(e.currentTarget.value)} style={styles.filterInput} />
+              <select value={timeRange()} onInput={e => setTimeRange(e.currentTarget.value as TimeRange)} style={styles.select}>
+                <option value="24h">24h</option><option value="7d">7天</option><option value="30d">30天</option><option value="all">全部</option>
+              </select>
+            </div>
+          </div>
+          <DetailTable data={details()?.data || []} />
+          <Show when={details()}>
+            <div style={{ display: "flex", "justify-content": "center", gap: "8px", "margin-top": "16px" }}>
+              <button disabled={detailPage() <= 1} onClick={() => setDetailPage(p => p - 1)} style={{ ...styles.tabBtn, opacity: detailPage() <= 1 ? 0.5 : 1 }}>上一页</button>
+              <span style={{ color: "#7f8c8d", padding: "6px" }}>第 {details()?.page}/{details()?.pages} 页 (共 {details()?.total} 条)</span>
+              <button disabled={detailPage() >= (details()?.pages || 1)} onClick={() => setDetailPage(p => p + 1)} style={{ ...styles.tabBtn, opacity: detailPage() >= (details()?.pages || 1) ? 0.5 : 1 }}>下一页</button>
             </div>
-          )}
+          </Show>
+        </Show>
+
+        {/* Teams */}
+        <Show when={tab() === "teams"}>
+          <h2 style={styles.sectionTitle}>团队管理</h2>
+          <div style={{ display: "flex", gap: "12px", "margin-bottom": "16px" }}>
+            <input type="text" placeholder="团队名称" value={teamName()} onInput={e => setTeamName(e.currentTarget.value)} style={styles.input} />
+            <button onClick={async () => { await api("/admin/teams", "POST", { name: teamName() }); setTeamName("") }} style={{ ...styles.btn, width: "auto", padding: "8px 16px" }}>创建团队</button>
+          </div>
+          <div style={{ display: "flex", gap: "12px", "margin-bottom": "16px" }}>
+            <input type="email" placeholder="成员邮箱" value={inviteEmail()} onInput={e => setInviteEmail(e.currentTarget.value)} style={styles.input} />
+            <button onClick={async () => { await api("/admin/teams/invite", "POST", { email: inviteEmail() }); setInviteEmail("") }} style={{ ...styles.btn, width: "auto", padding: "8px 16px", background: "#27ae60" }}>邀请成员</button>
+          </div>
+          <div style={{ display: "grid", gap: "12px" }}>
+            <For each={(teams() as any[]) || []}>{(team: any) => (
+              <div style={{ background: "#1a1a2e", padding: "16px", "border-radius": "10px", border: "1px solid #2c3e50", display: "flex", "justify-content": "space-between", "align-items": "center" }}>
+                <div><span style={{ "font-weight": "600" }}>{team.name}</span><span style={{ color: "#7f8c8d", "margin-left": "12px", "font-size": "12px" }}>ID: {team.id?.slice(0, 8)}</span></div>
+              </div>
+            )}</For>
+          </div>
         </Show>
       </div>
     </div>
   )
 }
 
-function MetricCard(props: { label: string; value: string }) {
+// Sub-components
+function Card(props: { label: string; value: string }) {
   return (
-    <div style={{ background: "#1a1a2e", padding: "20px", "border-radius": "12px", border: "1px solid #2c3e50" }}>
+    <div style={{ background: "#1a1a2e", padding: "20px", "border-radius": "12px", border: "1px solid #2c3e50", "min-width": "160px" }}>
       <div style={{ color: "#7f8c8d", "font-size": "12px", "margin-bottom": "8px" }}>{props.label}</div>
-      <div style={{ "font-size": "24px", "font-weight": "700", color: "#f39c12" }}>{props.value}</div>
+      <div style={{ "font-size": "22px", "font-weight": "700", color: "#f39c12" }}>{props.value}</div>
     </div>
   )
 }
+
+function UserTable(props: { data: any[] }) {
+  return (
+    <table style={{ width: "100%", "border-collapse": "collapse" }}>
+      <thead>
+        <tr style={{ color: "#7f8c8d", "font-size": "11px", "text-align": "left" }}>
+          <th style={{ padding: "10px 8px" }}>用户</th><th style={{ padding: "10px 8px" }}>邮箱</th><th style={{ padding: "10px 8px", "text-align": "right" }}>Token(入)</th><th style={{ padding: "10px 8px", "text-align": "right" }}>Token(出)</th><th style={{ padding: "10px 8px", "text-align": "right" }}>请求</th><th style={{ padding: "10px 8px", "text-align": "right" }}>费用</th>
+        </tr>
+      </thead>
+      <For each={props.data}>{(row: any) => (
+        <tr style={{ "border-top": "1px solid #1e293b", "font-size": "13px" }}>
+          <td style={{ padding: "10px 8px" }}>{row.display_name || row.user_id?.slice(0, 8)}</td>
+          <td style={{ padding: "10px 8px", color: "#7f8c8d" }}>{row.email}</td>
+          <td style={{ padding: "10px 8px", "text-align": "right" }}>{(row.input || row.input_tokens || 0).toLocaleString()}</td>
+          <td style={{ padding: "10px 8px", "text-align": "right" }}>{(row.output || row.output_tokens || 0).toLocaleString()}</td>
+          <td style={{ padding: "10px 8px", "text-align": "right" }}>{row.requests || 0}</td>
+          <td style={{ padding: "10px 8px", "text-align": "right", color: "#f39c12" }}>${(row.cost || row.cost_usd || 0).toFixed(4)}</td>
+        </tr>
+      )}</For>
+    </table>
+  )
+}
+
+function DetailTable(props: { data: any[] }) {
+  return (
+    <table style={{ width: "100%", "border-collapse": "collapse" }}>
+      <thead>
+        <tr style={{ color: "#7f8c8d", "font-size": "11px", "text-align": "left" }}>
+          <th style={{ padding: "8px" }}>时间</th><th style={{ padding: "8px" }}>用户</th><th style={{ padding: "8px" }}>模型</th><th style={{ padding: "8px", "text-align": "right" }}>输入</th><th style={{ padding: "8px", "text-align": "right" }}>输出</th><th style={{ padding: "8px", "text-align": "right" }}>费用</th>
+        </tr>
+      </thead>
+      <For each={props.data}>{(row: any) => (
+        <tr style={{ "border-top": "1px solid #1e293b", "font-size": "12px" }}>
+          <td style={{ padding: "8px", "font-size": "11px", color: "#7f8c8d" }}>{new Date(row.created_at).toLocaleString()}</td>
+          <td style={{ padding: "8px" }}>{row.display_name || row.user_id?.slice(0, 8)}</td>
+          <td style={{ padding: "8px" }}>{row.provider}/{row.model}</td>
+          <td style={{ padding: "8px", "text-align": "right" }}>{(row.input_tokens || 0).toLocaleString()}</td>
+          <td style={{ padding: "8px", "text-align": "right" }}>{(row.output_tokens || 0).toLocaleString()}</td>
+          <td style={{ padding: "8px", "text-align": "right", color: "#f39c12" }}>${(row.cost_usd || 0).toFixed(5)}</td>
+        </tr>
+      )}</For>
+    </table>
+  )
+}
+
+function TrendChart(props: { data: any[] }) {
+  if (props.data.length < 2) return <div style={{ color: "#7f8c8d", "text-align": "center", padding: "40px" }}>数据不足(需要至少2个数据点)</div>
+
+  const maxVal = Math.max(...props.data.map((d: any) => d.input + d.output), 1)
+  const h = 200
+  const w = 700
+  const points = props.data.map((d: any, i: number) => {
+    const x = (i / (props.data.length - 1)) * w
+    const y = h - ((d.input + d.output) / maxVal) * h
+    return `${x},${y}`
+  }).join(" ")
+
+  // Grid lines
+  const gridLines = []
+  for (let i = 0; i <= 4; i++) {
+    const y = (i / 4) * h
+    gridLines.push(<line x1={0} y1={y} x2={w} y2={y} stroke="#1e293b" stroke-width="0.5" /> as any)
+    const label = Math.round(maxVal * (1 - i / 4))
+    gridLines.push(<text x={4} y={y - 4} fill="#7f8c8d" font-size="10">{label.toLocaleString()}</text> as any)
+  }
+
+  return (
+    <svg viewBox={`0 0 ${w} ${h + 24}`} style={{ width: "100%", height: "auto" }}>
+      {gridLines}
+      <polyline points={points} fill="none" stroke="#e74c3c" stroke-width="2" />
+      <polygon points={`0,${h} ${points} ${w},${h}`} fill="url(#grad)" opacity="0.2" />
+      <defs>
+        <linearGradient id="grad" x1="0" y1="0" x2="0" y2="1">
+          <stop offset="0%" stop-color="#e74c3c" />
+          <stop offset="100%" stop-color="#e74c3c" stop-opacity="0" />
+        </linearGradient>
+      </defs>
+      {/* Time labels */}
+      {props.data.filter((_: any, i: number) => i % Math.max(1, Math.floor(props.data.length / 6)) === 0).map((d: any, i: number) => {
+        const x = (i * Math.max(1, Math.floor(props.data.length / 6)) / Math.max(1, props.data.length - 1)) * w
+        return <text x={x} y={h + 20} fill="#7f8c8d" font-size="10" text-anchor="middle">{new Date(d.minute).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</text>
+      })}
+    </svg>
+  )
+}
+
+const styles: Record<string, any> = {
+  loginPage: { display: "flex", "flex-direction": "column", "align-items": "center", "justify-content": "center", height: "100vh", background: "#0f172a", color: "#ecf0f1", "font-family": "system-ui, sans-serif" },
+  loginTitle: { color: "#e74c3c", "margin-bottom": "24px" },
+  input: { padding: "10px", "border-radius": "8px", border: "1px solid #2c3e50", background: "#1a1a2e", color: "#ecf0f1", "font-size": "14px", width: "100%", "box-sizing": "border-box" },
+  btn: { padding: "10px", "border-radius": "8px", border: "none", background: "#e74c3c", color: "#fff", "font-size": "14px", cursor: "pointer", width: "100%" },
+  page: { display: "flex", "flex-direction": "column", height: "100vh", background: "#0f172a", color: "#ecf0f1", "font-family": "system-ui, sans-serif", overflow: "auto" },
+  header: { display: "flex", "align-items": "center", padding: "12px 24px", background: "#1a1a2e", "border-bottom": "1px solid #2c3e50", gap: "16px" },
+  headerTitle: { "font-size": "18px", "font-weight": "700", color: "#e74c3c", "min-width": "180px" },
+  tabBtn: { padding: "6px 14px", "border-radius": "6px", border: "none", "font-size": "13px", cursor: "pointer", whiteSpace: "nowrap" },
+  logoutBtn: { "margin-left": "auto", padding: "6px 14px", "border-radius": "6px", border: "1px solid #2c3e50", background: "transparent", color: "#7f8c8d", cursor: "pointer", "font-size": "12px" },
+  content: { flex: 1, padding: "24px", overflow: "auto" },
+  sectionTitle: { "font-size": "16px", "font-weight": "600", "margin-bottom": "16px" },
+  cards: { display: "grid", "grid-template-columns": "repeat(auto-fit, minmax(160px, 1fr))", gap: "16px" },
+  chart: { background: "#1a1a2e", padding: "16px", "border-radius": "10px", border: "1px solid #2c3e50", overflow: "auto" },
+  select: { padding: "6px 10px", "border-radius": "6px", border: "1px solid #2c3e50", background: "#1a1a2e", color: "#ecf0f1", "font-size": "12px", cursor: "pointer" },
+  filterInput: { padding: "6px 10px", "border-radius": "6px", border: "1px solid #2c3e50", background: "#1a1a2e", color: "#ecf0f1", "font-size": "12px", width: "140px" },
+}

+ 2 - 0
packages/app/src/pages/layout.tsx

@@ -2239,6 +2239,8 @@ export default function LegacyLayout(props: ParentProps) {
       onOpenSettings={openSettings}
       helpLabel={() => language.t("sidebar.help")}
       onOpenHelp={() => platform.openLink("https://kailin.com.cn/feedback")}
+      adminLabel={() => "团队管理"}
+      onOpenAdmin={() => window.location.hash = "/admin"}
       renderPanel={() =>
         mobile ? <SidebarPanel project={currentProject} mobile /> : <SidebarPanel project={currentProject} merged />
       }

+ 11 - 0
packages/app/src/pages/layout/sidebar-shell.tsx

@@ -30,6 +30,8 @@ export const SidebarContent = (props: {
   onOpenSettings: () => void
   helpLabel: Accessor<string>
   onOpenHelp: () => void
+  adminLabel: Accessor<string>
+  onOpenAdmin: () => void
   renderPanel: () => JSX.Element
 }): JSX.Element => {
   const expanded = createMemo(() => !!props.mobile || props.opened())
@@ -90,6 +92,15 @@ export const SidebarContent = (props: {
           </DragDropProvider>
         </div>
         <div class="shrink-0 w-full pt-3 pb-6 flex flex-col items-center gap-2">
+          <Tooltip placement={placement()} value={props.adminLabel()}>
+            <IconButton
+              icon="shield-check"
+              variant="ghost"
+              size="large"
+              onClick={props.onOpenAdmin}
+              aria-label={props.adminLabel()}
+            />
+          </Tooltip>
           <TooltipKeybind placement={placement()} title={props.settingsLabel()} keybind={props.settingsKeybind() ?? ""}>
             <IconButton
               icon="settings-gear"

+ 1 - 1
packages/cli/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kirincode-ai/cli",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "type": "module",
   "license": "MIT",
   "bin": {

+ 1 - 1
packages/codemode/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kirincode-ai/codemode",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "description": "Effect-native confined code execution over schema-described tools",
   "private": true,
   "type": "module",

+ 1 - 1
packages/console/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@kirincode-ai/console-app",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "type": "module",
   "license": "MIT",
   "scripts": {

+ 1 - 1
packages/console/core/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kirincode-ai/console-core",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "private": true,
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/console/function/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@kirincode-ai/console-function",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "$schema": "https://json.schemastore.org/package.json",
   "private": true,
   "type": "module",

+ 1 - 1
packages/console/mail/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@kirincode-ai/console-mail",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "dependencies": {
     "@jsx-email/all": "2.2.3",
     "@jsx-email/cli": "1.4.3",

+ 1 - 1
packages/console/support/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@kirincode-ai/console-support",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "type": "module",
   "license": "MIT",
   "scripts": {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "name": "@kirincode-ai/core",
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/desktop/electron.vite.config.ts

@@ -36,7 +36,7 @@ export default defineConfig({
     },
     build: {
       rollupOptions: {
-        input: { index: "src/main/index.ts", sidecar: "src/main/sidecar.ts" },
+          input: { index: "src/main/index.ts", sidecar: "src/main/sidecar.ts", "admin-server": "src/main/admin-server.ts" },
         // Keep this identical to electron-vite's Node 20.11+ shim. Its regex insertion can
         // corrupt bundled TypeScript, while a Rollup banner places the shim safely.
         output: {

+ 1 - 1
packages/desktop/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@kirincode-ai/desktop",
   "private": true,
-  "version": "0.5.0",
+  "version": "0.5.1",
   "type": "module",
   "license": "MIT",
   "homepage": "https://kirincode.ai",

+ 197 - 0
packages/desktop/src/main/admin-server.ts

@@ -0,0 +1,197 @@
+// KirinCode Admin Server — JSON-store + JWT + Usage Stats + Team Management
+// Runs on port 4097, persists data to kirincode-admin.json
+
+const PORT = 4097
+const { app } = require("electron")
+const path = require("path")
+const fs = require("fs")
+const { randomUUID, createHmac } = require("crypto")
+
+const DATA_FILE = path.join(app?.getPath("userData") || process.env.HOME || "/tmp", "kirincode-admin.json")
+
+// -- Data Store --
+let data: any = { users: {}, teams: {}, members: {}, usage: [] }
+try { data = JSON.parse(fs.readFileSync(DATA_FILE, "utf-8")) } catch {}
+const save = () => fs.writeFileSync(DATA_FILE, JSON.stringify(data), "utf-8")
+
+// -- JWT --
+const JWT_SECRET = String(process.env.KIRINCODE_ADMIN_SECRET || "kirincode-admin-secret" + Math.random())
+function signToken(uid: string): string {
+  const h = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url")
+  const p = Buffer.from(JSON.stringify({ sub: uid, iat: ~~(Date.now()/1000), exp: ~~(Date.now()/1000)+86400 })).toString("base64url")
+  const s = createHmac("sha256", JWT_SECRET).update(`${h}.${p}`).digest("base64url")
+  return `${h}.${p}.${s}`
+}
+function verifyToken(t: string): string | null {
+  try { const p = JSON.parse(Buffer.from(t.split(".")[1], "base64url").toString()); return p.exp*1000 > Date.now() ? p.sub : null }
+  catch { return null }
+}
+async function hashPw(pw: string) { const { hash } = await import("crypto"); return require("crypto").createHash("sha256").update(pw + JWT_SECRET).digest("hex") }
+function checkPw(pw: string, h: string) { return hashPw(pw) === h }
+
+// Cost estimation
+const PRICING: Record<string, Record<string, [number,number]>> = {
+  anthropic: { "claude-sonnet-4-5": [3,15], "claude-haiku-4-5": [0.8,4] },
+  openai: { "gpt-4o": [2.5,10], "gpt-4o-mini": [0.15,0.6] },
+  deepseek: { "deepseek-v4-pro": [1.74,3.48], "deepseek-v4-flash": [0.14,0.28] },
+  google: { "gemini-2.5-pro": [3.5,10.5], "gemini-2.5-flash": [0.4,1.5] },
+}
+function calcCost(provider: string, model: string, input: number, output: number): number {
+  const p = PRICING[provider]?.[model]
+  if (!p) return 0
+  return Math.round(((input/1e6)*p[0] + (output/1e6)*p[1]) * 1e6) / 1e6
+}
+
+function auth(req: any): string | null {
+  const h = req.headers["authorization"] || req.headers["Authorization"]
+  if (!h?.startsWith("Bearer ")) return null
+  return verifyToken(h.slice(7))
+}
+
+function json(res: any, data: any, status = 200) {
+  res.writeHead(status, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "Content-Type,Authorization" })
+  res.end(JSON.stringify(data))
+}
+
+async function body(req: any): Promise<any> {
+  return new Promise((resolve) => {
+    let b = ""; req.on("data", (c: string) => b += c); req.on("end", () => { try { resolve(JSON.parse(b)) } catch { resolve({}) } })
+  })
+}
+
+// -- Start Server --
+require("http").createServer(async (req: any, res: any) => {
+  if (req.method === "OPTIONS") { res.writeHead(200, { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE", "Access-Control-Allow-Headers": "Content-Type,Authorization" }); return res.end() }
+
+  const url = new URL(req.url, "http://localhost")
+  const p = url.pathname
+
+  if (p === "/api/v1/health") return json(res, { status: "ok", version: "0.5.1", users: Object.keys(data.users).length })
+
+  // Auth
+  if (p === "/api/v1/auth/register" && req.method === "POST") {
+    const b = await body(req)
+    if (data.users[b.email]) return json(res, { error: "Email exists" }, 409)
+    const id = randomUUID()
+    data.users[id] = { id, email: b.email, password: await hashPw(b.password), display_name: b.display_name || b.email.split("@")[0], role: "member", team_id: null }
+    save()
+    return json(res, { token: signToken(id), user: data.users[id] })
+  }
+  if (p === "/api/v1/auth/login" && req.method === "POST") {
+    const b = await body(req)
+    const u = Object.values(data.users).find((u: any) => u.email === b.email) as any
+    if (!u || !(await checkPw(b.password, u.password))) return json(res, { error: "Invalid" }, 401)
+    return json(res, { token: signToken(u.id), user: { id: u.id, email: u.email, display_name: u.display_name, role: u.role, team_id: u.team_id } })
+  }
+
+  // Prompts record usage (even without admin)
+  const uid = auth(req)
+  if (p.match(/^\/api\/v1\/sessions\/[^/]+\/prompt$/) && req.method === "POST" && uid) {
+    const sid = p.split("/")[4]
+    const b = await body(req)
+    const text = (b.parts || []).filter((p:any) => p.type === "text").map((p:any) => p.text).join("\n")
+    const inputTokens = Math.ceil(text.length / 4)
+    const outputTokens = Math.ceil(text.length / 3)
+    const provider = b.model?.providerID || "anthropic"
+    const model = b.model?.modelID || "claude-sonnet-4-5"
+    const c = calcCost(provider, model, inputTokens, outputTokens)
+    data.usage.push({ id: randomUUID(), user_id: uid, team_id: (data.users[uid] as any)?.team_id, provider, model, session_id: sid, input_tokens: inputTokens, output_tokens: outputTokens, cost_usd: c, created_at: new Date().toISOString() })
+    keepOnly(10000)
+    save()
+    return json(res, { usage: { input_tokens: inputTokens, output_tokens: outputTokens, cost_usd: c } })
+  }
+
+  // Admin required below
+  if (!uid) return json(res, { error: "Unauthorized" }, 401)
+  const user = data.users[uid] as any
+  const teamId = user?.team_id
+
+  // Usage Summary
+  if (p === "/api/v1/admin/usage/summary") {
+    const now = new Date().toISOString()
+    const today = data.usage.filter((e: any) => e.created_at > new Date(Date.now()-86400000).toISOString() && (!teamId || e.team_id === teamId))
+    const total = { input: 0, output: 0, requests: 0, cost: 0, users: new Set() }
+    for (const e of today) { total.input += e.input_tokens; total.output += e.output_tokens; total.requests++; total.cost += e.cost_usd; total.users.add(e.user_id) }
+    return json(res, { ...total, users: total.users.size })
+  }
+
+  // Trend (minute-level)
+  if (p === "/api/v1/admin/usage/trend") {
+    const trend: Record<string, any> = {}
+    const recent = data.usage.filter((e: any) => e.created_at > new Date(Date.now()-86400000).toISOString() && (!teamId || e.team_id === teamId))
+    for (const e of recent) {
+      const m = e.created_at.slice(0, 16)
+      if (!trend[m]) trend[m] = { minute: m, input: 0, output: 0, requests: 0, cost: 0 }
+      trend[m].input += e.input_tokens; trend[m].output += e.output_tokens; trend[m].requests++; trend[m].cost += e.cost_usd
+    }
+    return json(res, Object.values(trend).sort((a:any,b:any) => a.minute.localeCompare(b.minute)))
+  }
+
+  // Users ranking
+  if (p === "/api/v1/admin/usage/users") {
+    const uidFilter = url.searchParams.get("user_id")
+    const modelFilter = url.searchParams.get("model")
+    const from = url.searchParams.get("from") || "1970-01-01"
+    const to = url.searchParams.get("to") || "2099-12-31"
+    const users: Record<string, any> = {}
+    const filtered = data.usage.filter((e: any) => {
+      if (teamId && e.team_id !== teamId) return false
+      if (uidFilter && e.user_id !== uidFilter) return false
+      if (modelFilter && !e.model.includes(modelFilter)) return false
+      if (e.created_at < from || e.created_at > to) return false
+      return true
+    })
+    for (const e of filtered) {
+      if (!users[e.user_id]) { const u = data.users[e.user_id] || {} as any; users[e.user_id] = { user_id: e.user_id, display_name: u.display_name || e.user_id, email: u.email || "", input: 0, output: 0, requests: 0, cost: 0, duration: 0 } }
+      users[e.user_id].input += e.input_tokens; users[e.user_id].output += e.output_tokens; users[e.user_id].requests++; users[e.user_id].cost += e.cost_usd
+    }
+    return json(res, Object.values(users).sort((a:any,b:any) => b.cost - a.cost))
+  }
+
+  // Detail (paginated)
+  if (p === "/api/v1/admin/usage/detail") {
+    const uidFilter = url.searchParams.get("user_id")
+    const modelFilter = url.searchParams.get("model")
+    const from = url.searchParams.get("from") || "1970-01-01"
+    const to = url.searchParams.get("to") || "2099-12-31"
+    const page = parseInt(url.searchParams.get("page") || "1")
+    const limit = 30
+    const filtered = data.usage.filter((e: any) => {
+      if (teamId && e.team_id !== teamId) return false
+      if (uidFilter && e.user_id !== uidFilter) return false
+      if (modelFilter && !e.model.includes(modelFilter)) return false
+      if (e.created_at < from || e.created_at > to) return false
+      return true
+    }).sort((a:any,b:any) => b.created_at.localeCompare(a.created_at))
+    const total = filtered.length
+    const paged = filtered.slice((page-1)*limit, page*limit).map((e:any) => {
+      const u = data.users[e.user_id] || {}
+      return { ...e, display_name: (u as any).display_name || e.user_id, email: (u as any).email || "" }
+    })
+    return json(res, { data: paged, total, page, pages: Math.ceil(total/limit) })
+  }
+
+  // Teams
+  if (p === "/api/v1/admin/teams" && req.method === "GET") return json(res, Object.values(data.teams))
+  if (p === "/api/v1/admin/teams" && req.method === "POST") {
+    const b = await body(req)
+    const id = randomUUID()
+    data.teams[id] = { id, name: b.name, owner_id: b.owner_id || uid }
+    if (b.owner_id) {
+      data.users[b.owner_id].team_id = id; data.users[b.owner_id].role = "team_admin"
+    }
+    save(); return json(res, data.teams[id])
+  }
+  if (p === "/api/v1/admin/teams/invite" && req.method === "POST") {
+    const b = await body(req)
+    const invited = Object.values(data.users).find((u:any) => u.email === b.email) as any
+    if (!invited) return json(res, { error: "Not found" }, 404)
+    if (!invited.team_id) { invited.team_id = teamId; invited.role = "member" }
+    save(); return json(res, { success: true })
+  }
+  if (p === "/api/v1/admin/users") return json(res, Object.values(data.users).map((u:any) => ({ id: u.id, display_name: u.display_name, email: u.email, role: u.role, team_id: u.team_id })))
+
+  return json(res, { error: "Not found" }, 404)
+}).listen(PORT, () => console.log(`Admin server: http://localhost:${PORT}`))
+
+function keepOnly(n: number) { if (data.usage.length > n) data.usage = data.usage.slice(-n) }

+ 0 - 11
packages/desktop/src/main/sidecar.ts

@@ -63,17 +63,6 @@ async function start(command: StartCommand) {
       cors: ["oc://renderer"],
     })
 
-    // Also start cloud server for admin/enterprise features
-    const { fork } = await import("node:child_process")
-    const cloudPath = new URL("../../../../kirincode-cloud/src/index.ts", import.meta.url).pathname
-    const cloudProcess = fork(cloudPath, [], {
-      env: { ...process.env, PORT: "3000" },
-      stdio: "pipe",
-      silent: true,
-    })
-    cloudProcess.on("error", () => { /* ignore if cloud server not available */ })
-    cloudProcess.unref()
-
     parentPort.postMessage({ type: "ready" })
   } catch (error) {
     parentPort.postMessage({ type: "error", error: serializeError(error) })

+ 1 - 1
packages/effect-drizzle-sqlite/package.json

@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "name": "@kirincode-ai/effect-drizzle-sqlite",
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/effect-sqlite-node/package.json

@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "name": "@kirincode-ai/effect-sqlite-node",
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/enterprise/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@kirincode-ai/enterprise",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "private": true,
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/function/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@kirincode-ai/function",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "$schema": "https://json.schemastore.org/package.json",
   "private": true,
   "type": "module",

+ 1 - 1
packages/http-recorder/package.json

@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "name": "@kirincode-ai/http-recorder",
   "description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
   "type": "module",

+ 1 - 1
packages/kirincode-cloud/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@kirincode-ai/kirincode-cloud",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "type": "module",
   "description": "KirinCode Cloud Sync Server — multi-user backend with session sync, memory, skills, workflows",
   "license": "MIT",

+ 1 - 1
packages/kirincode-workflow/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kirincode-ai/kirincode-workflow",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "type": "module",
   "description": "KirinCode workflow engine — scheduled tasks, event triggers, background agents",
   "license": "MIT",

+ 1 - 1
packages/kirincode/package.json

@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "name": "kirincode",
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/llm/package.json

@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "name": "@kirincode-ai/llm",
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/mobile/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@kirincode-ai/mobile",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "private": true,
   "scripts": {
     "start": "expo start",

+ 1 - 1
packages/plugin/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kirincode-ai/plugin",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "type": "module",
   "license": "MIT",
   "scripts": {

+ 1 - 1
packages/sdk/js/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kirincode-ai/sdk",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "type": "module",
   "license": "MIT",
   "scripts": {

+ 1 - 1
packages/server/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kirincode-ai/server",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "private": true,
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/session-ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@kirincode-ai/session-ui",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "private": true,
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@kirincode-ai/slack",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "type": "module",
   "license": "MIT",
   "scripts": {

+ 1 - 1
packages/stats/app/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kirincode-ai/stats-app",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "private": true,
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/stats/core/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kirincode-ai/stats-core",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "private": true,
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/stats/server/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kirincode-ai/stats-server",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "private": true,
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/tui/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kirincode-ai/tui",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "private": true,
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@kirincode-ai/ui",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "type": "module",
   "license": "MIT",
   "repository": {

+ 1 - 1
packages/web/package.json

@@ -2,7 +2,7 @@
   "name": "@kirincode-ai/web",
   "type": "module",
   "license": "MIT",
-  "version": "0.5.0",
+  "version": "0.5.1",
   "scripts": {
     "dev": "astro dev",
     "dev:remote": "VITE_API_URL=https://api.kirincode.ai astro dev",