初始化仓库

This commit is contained in:
2026-06-02 23:14:41 +08:00
commit 0bc3f02670
520 changed files with 191097 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
{"onlyBuiltDependencies":["esbuild"]}
+27
View File
@@ -0,0 +1,27 @@
//go:build !no_web
package web
import (
"embed"
"io/fs"
"log/slog"
"github.com/chenhg5/cc-connect/core"
)
//go:embed all:dist
var distFS embed.FS
func init() {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
slog.Warn("web: embedded dist directory unavailable; web admin disabled", "error", err)
return
}
if _, err := fs.Stat(sub, "index.html"); err != nil {
slog.Warn("web: embedded dist assets missing index.html; web admin disabled", "error", err)
return
}
core.RegisterWebAssets(sub)
}
+3
View File
@@ -0,0 +1,3 @@
//go:build no_web
package web
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CC-Connect Admin</title>
</head>
<body class="bg-gray-50 dark:bg-gray-950">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
{
"name": "cc-connect-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/typography": "^0.5.19",
"clsx": "^2.1.1",
"highlight.js": "^11.11.1",
"i18next": "^25.1.2",
"lucide-react": "^0.487.0",
"qrcode.react": "^4.2.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^15.5.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.5.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"zustand": "^5.0.5"
},
"devDependencies": {
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "~5.8.3",
"vite": "^6.3.2"
}
}
+2773
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
onlyBuiltDependencies: esbuild
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+344
View File
@@ -0,0 +1,344 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vibe Usage</title>
<style>
body {
margin: 0;
height: 100vh;
background: linear-gradient(180deg,#cfe8ff,#eaf4ff);
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
display: flex;
align-items: center;
justify-content: center;
}
/* panel */
.panel {
width: 920px;
background: rgba(0,0,0,0.85);
backdrop-filter: blur(20px);
color: white;
border-radius: 20px;
padding: 20px;
box-shadow: 0 30px 80px rgba(0,0,0,0.5);
animation: panelIn 0.4s ease;
}
@keyframes panelIn {
from {
opacity: 0;
transform: scale(0.96) translateY(20px);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* header */
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.title {
font-size: 22px;
font-weight: 600;
}
/* tabs */
.tabs {
display: flex;
gap: 10px;
}
.tab {
padding: 6px 12px;
border-radius: 8px;
background: #222;
color: #aaa;
cursor: pointer;
transition: 0.2s;
}
.tab:hover {
background: #333;
}
.tab.active {
background: #555;
color: white;
box-shadow: 0 0 10px rgba(255,255,255,0.2);
}
/* cards */
.cards {
display: flex;
gap: 14px;
margin-top: 16px;
}
.card {
flex: 1;
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 14px;
transition: 0.25s;
animation: floatIn 0.4s ease;
}
.card:hover {
transform: translateY(-4px);
background: rgba(255,255,255,0.08);
box-shadow: 0 10px 20px rgba(0,0,0,0.5);
}
@keyframes floatIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card-title {
color: #aaa;
font-size: 13px;
}
.card-value {
font-size: 22px;
margin-top: 6px;
font-weight: bold;
}
.green {
color: #42ff9c;
}
/* chart */
.chart {
margin-top: 20px;
background: rgba(255,255,255,0.04);
border-radius: 14px;
padding: 20px;
height: 240px;
display: flex;
align-items: flex-end;
gap: 6px;
overflow: hidden;
}
.bar {
width: 16px;
background: #777;
border-radius: 4px;
transform: scaleY(0);
transform-origin: bottom;
animation: grow 0.6s ease forwards;
}
.bar.light {
background: #ddd;
}
@keyframes grow {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
/* footer */
.footer {
margin-top: 10px;
font-size: 12px;
color: #888;
display: flex;
justify-content: space-between;
}
</style>
</head>
<body>
<div class="panel">
<div class="header">
<div class="title">Vibe Usage</div>
<div class="tabs">
<div class="tab">1D</div>
<div class="tab">7D</div>
<div class="tab active">30D</div>
</div>
</div>
<div class="cards">
<div class="card">
<div class="card-title">预估费用</div>
<div class="card-value green">$2372.04</div>
</div>
<div class="card">
<div class="card-title">总 Token</div>
<div class="card-value">142.1M</div>
</div>
<div class="card">
<div class="card-title">输入 Token</div>
<div class="card-value">127.1M</div>
</div>
<div class="card">
<div class="card-title">输出 Token</div>
<div class="card-value">12.2M</div>
</div>
<div class="card">
<div class="card-title">缓存 Token</div>
<div class="card-value">4415.5M</div>
</div>
</div>
<div class="chart" id="chart"></div>
<div class="footer">
✔ 上次同步:刚刚
<div>更新数据 · 关闭</div>
</div>
</div>
<script>
const chart = document.getElementById("chart")
for (let i = 0; i < 28; i++) {
let bar = document.createElement("div")
bar.className = "bar"
let h = Math.random() * 200 + 20
bar.style.height = h + "px"
bar.style.animationDelay = i * 0.03 + "s"
if (Math.random() > 0.7) {
bar.classList.add("light")
}
chart.appendChild(bar)
}
</script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#111"/>
<path d="M8 16c0-4.4 3.6-8 8-8s8 3.6 8 8-3.6 8-8 8" stroke="#42ff9c" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="16" cy="16" r="3" fill="#42ff9c"/>
</svg>

After

Width:  |  Height:  |  Size: 296 B

+40
View File
@@ -0,0 +1,40 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from '@/store/auth';
import Layout from '@/components/Layout/Layout';
import Login from '@/pages/Login';
import Dashboard from '@/pages/Dashboard';
import ProjectList from '@/pages/Projects/ProjectList';
import ProjectDetail from '@/pages/Projects/ProjectDetail';
import ChatList from '@/pages/Chat/ChatList';
import ChatView from '@/pages/Chat/ChatView';
import CronList from '@/pages/Cron/CronList';
import SystemConfig from '@/pages/System/Config';
import ProviderList from '@/pages/Providers/ProviderList';
import SkillList from '@/pages/Skills/SkillList';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
if (!isAuthenticated) return <Navigate to="/login" replace />;
return <>{children}</>;
}
export default function App() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
return (
<Routes>
<Route path="/login" element={isAuthenticated ? <Navigate to="/" replace /> : <Login />} />
<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route index element={<Dashboard />} />
<Route path="projects" element={<ProjectList />} />
<Route path="projects/:name" element={<ProjectDetail />} />
<Route path="providers" element={<ProviderList />} />
<Route path="skills" element={<SkillList />} />
<Route path="chat" element={<ChatList />} />
<Route path="chat/:name" element={<ChatView />} />
<Route path="cron" element={<CronList />} />
<Route path="system" element={<SystemConfig />} />
</Route>
</Routes>
);
}
+10
View File
@@ -0,0 +1,10 @@
import api from './client';
export interface BridgeAdapter {
platform: string;
project: string;
capabilities: string[];
connected_at: string;
}
export const listBridgeAdapters = () => api.get<{ adapters: BridgeAdapter[] }>('/bridge/adapters');
+77
View File
@@ -0,0 +1,77 @@
const API_BASE = '/api/v1';
type UnauthorizedHandler = () => void;
class ApiClient {
private token: string = '';
private onUnauthorized?: UnauthorizedHandler;
setToken(token: string) {
this.token = token;
}
getToken(): string {
return this.token;
}
setOnUnauthorized(handler: UnauthorizedHandler) {
this.onUnauthorized = handler;
}
private headers(): HeadersInit {
const h: HeadersInit = { 'Content-Type': 'application/json' };
if (this.token) h['Authorization'] = `Bearer ${this.token}`;
return h;
}
async request<T = any>(method: string, path: string, body?: any, params?: Record<string, string>): Promise<T> {
let url = `${API_BASE}${path}`;
if (params) {
const qs = new URLSearchParams(params).toString();
if (qs) url += `?${qs}`;
}
const res = await fetch(url, {
method,
headers: this.headers(),
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 401 && this.onUnauthorized) {
this.onUnauthorized();
throw new ApiError('Unauthorized', 401);
}
const json = await res.json();
if (!json.ok) {
throw new ApiError(json.error || 'Unknown error', res.status);
}
return json.data as T;
}
get<T = any>(path: string, params?: Record<string, string>) { return this.request<T>('GET', path, undefined, params); }
post<T = any>(path: string, body?: any) { return this.request<T>('POST', path, body); }
put<T = any>(path: string, body?: any) { return this.request<T>('PUT', path, body); }
patch<T = any>(path: string, body?: any) { return this.request<T>('PATCH', path, body); }
delete<T = any>(path: string) { return this.request<T>('DELETE', path); }
/** Fetch raw text (non-JSON) from an API endpoint. */
async raw(path: string): Promise<string> {
const h: HeadersInit = {};
if (this.token) h['Authorization'] = `Bearer ${this.token}`;
const res = await fetch(`${API_BASE}${path}`, { headers: h });
if (res.status === 401 && this.onUnauthorized) {
this.onUnauthorized();
throw new ApiError('Unauthorized', 401);
}
if (!res.ok) throw new ApiError(res.statusText, res.status);
return res.text();
}
}
export class ApiError extends Error {
constructor(message: string, public status: number) {
super(message);
this.name = 'ApiError';
}
}
export const api = new ApiClient();
export default api;
+27
View File
@@ -0,0 +1,27 @@
import api from './client';
export interface CronJob {
id: string;
project: string;
session_key: string;
cron_expr: string;
prompt: string;
exec: string;
work_dir: string;
description: string;
enabled: boolean;
silent: boolean;
mute: boolean;
session_mode: string;
mode: string;
timeout_mins: number | null;
created_at: string;
last_run: string;
last_error: string;
}
export const listCronJobs = (project?: string) =>
api.get<{ jobs: CronJob[] }>('/cron', project ? { project } : undefined);
export const createCronJob = (body: Partial<CronJob>) => api.post<CronJob>('/cron', body);
export const updateCronJob = (id: string, fields: Record<string, any>) => api.patch<CronJob>(`/cron/${id}`, fields);
export const deleteCronJob = (id: string) => api.delete(`/cron/${id}`);
+22
View File
@@ -0,0 +1,22 @@
import api from './client';
export interface HeartbeatStatus {
enabled: boolean;
paused: boolean;
interval_mins: number;
only_when_idle: boolean;
session_key: string;
silent: boolean;
run_count: number;
error_count: number;
skipped_busy: number;
last_run: string;
last_error: string;
}
export const getHeartbeat = (project: string) => api.get<HeartbeatStatus>(`/projects/${project}/heartbeat`);
export const pauseHeartbeat = (project: string) => api.post(`/projects/${project}/heartbeat/pause`);
export const resumeHeartbeat = (project: string) => api.post(`/projects/${project}/heartbeat/resume`);
export const triggerHeartbeat = (project: string) => api.post(`/projects/${project}/heartbeat/run`);
export const setHeartbeatInterval = (project: string, minutes: number) =>
api.post(`/projects/${project}/heartbeat/interval`, { minutes });
+8
View File
@@ -0,0 +1,8 @@
export { api, ApiError } from './client';
export * from './status';
export * from './projects';
export * from './sessions';
export * from './providers';
export * from './cron';
export * from './heartbeat';
export * from './settings';
+66
View File
@@ -0,0 +1,66 @@
import api from './client';
export interface ProjectSummary {
name: string;
agent_type: string;
platforms: string[];
sessions_count: number;
heartbeat_enabled: boolean;
}
export interface PlatformConfigInfo {
type: string;
allow_from?: string;
}
export interface ProjectDetail {
name: string;
agent_type: string;
work_dir?: string;
agent_mode?: string;
show_context_indicator?: boolean;
reply_footer?: boolean;
inject_sender?: boolean;
provider_refs?: string[];
platform_configs?: PlatformConfigInfo[];
platforms: { type: string; connected: boolean }[];
sessions_count: number;
active_session_keys: string[];
heartbeat: {
enabled: boolean;
paused: boolean;
interval_mins: number;
session_key: string;
};
settings: {
admin_from: string;
language: string;
disabled_commands: string[];
};
}
export interface ProjectSettingsUpdate {
language?: string;
admin_from?: string;
disabled_commands?: string[];
work_dir?: string;
mode?: string;
agent_type?: string;
show_context_indicator?: boolean;
reply_footer?: boolean;
inject_sender?: boolean;
platform_allow_from?: Record<string, string>;
}
export const listAgentTypes = () => api.get<{ agents: string[]; platforms: string[] }>('/agents');
export const listProjects = () => api.get<{ projects: ProjectSummary[] }>('/projects');
export const getProject = (name: string) => api.get<ProjectDetail>(`/projects/${name}`);
export const updateProject = (name: string, body: ProjectSettingsUpdate) => api.patch(`/projects/${name}`, body);
export const addPlatformToProject = (projectName: string, body: {
type: string; options: Record<string, any>; work_dir?: string; agent_type?: string;
}) => api.post<{ message: string; restart_required: boolean }>(`/projects/${projectName}/add-platform`, body);
export const deleteProject = (name: string) =>
api.delete<{ message: string; restart_required: boolean }>(`/projects/${name}`);
+101
View File
@@ -0,0 +1,101 @@
import api from './client';
export interface ProviderModel {
model: string;
alias?: string;
}
export interface Provider {
name: string;
active: boolean;
model: string;
base_url: string;
}
export interface CodexConfig {
wire_api?: string;
http_headers?: Record<string, string>;
}
export interface GlobalProvider {
name: string;
api_key?: string;
base_url?: string;
model?: string;
thinking?: string;
env?: Record<string, string>;
agent_types?: string[];
models?: ProviderModel[];
endpoints?: Record<string, string>;
agent_models?: Record<string, string>;
agent_model_lists?: Record<string, ProviderModel[]>;
codex?: CodexConfig;
}
export interface PresetAgentConfig {
base_url: string;
model: string;
models?: string[];
codex_config?: { wire_api?: string; http_headers?: Record<string, string> };
}
export interface ProviderPreset {
name: string;
display_name: string;
agents: Record<string, PresetAgentConfig>;
invite_url?: string;
description?: string;
description_zh?: string;
features?: string[];
thinking?: string;
tier: number;
featured?: boolean;
website?: string;
}
export interface PresetsResponse {
version: number;
updated_at?: string;
providers: ProviderPreset[];
}
// Project-level provider APIs (existing)
export const listProviders = (project: string) =>
api.get<{ providers: Provider[]; active_provider: string }>(`/projects/${project}/providers`);
export const addProvider = (project: string, body: any) => api.post(`/projects/${project}/providers`, body);
export const removeProvider = (project: string, provider: string) => api.delete(`/projects/${project}/providers/${provider}`);
export const activateProvider = (project: string, provider: string) => api.post(`/projects/${project}/providers/${provider}/activate`);
export const listModels = (project: string) => api.get<{ models: string[]; current: string }>(`/projects/${project}/models`);
export const setModel = (project: string, model: string) => api.post(`/projects/${project}/model`, { model });
// Project provider_refs APIs
export const getProviderRefs = (project: string) =>
api.get<{ provider_refs: string[] }>(`/projects/${project}/provider-refs`);
export const saveProviderRefs = (project: string, refs: string[]) =>
api.put<{ message: string }>(`/projects/${project}/provider-refs`, { provider_refs: refs });
// Global provider APIs
export const listGlobalProviders = () =>
api.get<{ providers: GlobalProvider[] }>('/providers');
export const addGlobalProvider = (body: GlobalProvider) =>
api.post<{ name: string; message: string }>('/providers', body);
export const updateGlobalProvider = (name: string, body: Partial<GlobalProvider>) =>
api.put<{ message: string }>(`/providers/${name}`, body);
export const removeGlobalProvider = (name: string) =>
api.delete<{ message: string }>(`/providers/${name}`);
export const fetchProviderPresets = () =>
api.get<PresetsResponse>('/providers/presets');
// cc-switch migration
export interface CCSwitchProvider {
name: string;
app_type: string;
api_key?: string;
base_url?: string;
model?: string;
is_current: boolean;
}
export const listCCSwitchProviders = () =>
api.get<{ providers: CCSwitchProvider[]; available: boolean; error?: string }>('/providers/cc-switch');
export const importCCSwitchProviders = (names: string[]) =>
api.post<{ imported: string[]; skipped: string[] }>('/providers/cc-switch', { names });
+40
View File
@@ -0,0 +1,40 @@
import api from './client';
export interface LastMessage {
role: string;
content: string;
timestamp: string;
}
export interface Session {
id: string;
session_key: string;
name: string;
platform: string;
agent_type: string;
active: boolean;
live: boolean;
created_at: string;
updated_at: string;
history_count: number;
last_message: LastMessage | null;
user_name?: string;
chat_name?: string;
}
export interface SessionDetail extends Session {
agent_session_id: string;
history: { role: string; content: string; timestamp: string }[];
}
export const listSessions = (project: string) =>
api.get<{ sessions: Session[]; active_keys: Record<string, string> }>(`/projects/${project}/sessions`);
export const getSession = (project: string, id: string, historyLimit?: number) =>
api.get<SessionDetail>(`/projects/${project}/sessions/${id}`, historyLimit ? { history_limit: String(historyLimit) } : undefined);
export const createSession = (project: string, body: { session_key: string; name?: string }) =>
api.post(`/projects/${project}/sessions`, body);
export const deleteSession = (project: string, id: string) => api.delete(`/projects/${project}/sessions/${id}`);
export const switchSession = (project: string, body: { session_key: string; session_id: string }) =>
api.post(`/projects/${project}/sessions/switch`, body);
export const sendMessage = (project: string, body: { session_key: string; message: string }) =>
api.post(`/projects/${project}/send`, body);
+19
View File
@@ -0,0 +1,19 @@
import api from './client';
export interface GlobalSettings {
language: string;
attachment_send: string;
log_level: string;
idle_timeout_mins: number;
thinking_messages: boolean;
thinking_max_len: number;
tool_messages: boolean;
tool_max_len: number;
stream_preview_enabled: boolean;
stream_preview_interval_ms: number;
rate_limit_max_messages: number;
rate_limit_window_secs: number;
}
export const getGlobalSettings = () => api.get<GlobalSettings>('/settings');
export const updateGlobalSettings = (body: Partial<GlobalSettings>) => api.patch<GlobalSettings>('/settings', body);
+54
View File
@@ -0,0 +1,54 @@
import api from './client';
export interface FeishuBeginResponse {
device_code: string;
qr_url: string;
interval: number;
expires_in: number;
}
export interface FeishuPollResponse {
status: 'pending' | 'completed' | 'denied' | 'expired' | 'error';
base_url?: string;
app_id?: string;
app_secret?: string;
platform?: string;
owner_open_id?: string;
slow_down?: boolean;
error?: string;
}
export interface WeixinBeginResponse {
qr_key: string;
qr_url: string;
}
export interface WeixinPollResponse {
status: 'wait' | 'scaned' | 'confirmed' | 'expired';
bot_token?: string;
ilink_bot_id?: string;
base_url?: string;
ilink_user_id?: string;
}
export const setupFeishuBegin = () =>
api.post<FeishuBeginResponse>('/setup/feishu/begin', {});
export const setupFeishuPoll = (deviceCode: string, baseUrl?: string) =>
api.post<FeishuPollResponse>('/setup/feishu/poll', { device_code: deviceCode, base_url: baseUrl });
export const setupFeishuSave = (body: {
project: string; app_id: string; app_secret: string; platform_type: string;
owner_open_id?: string; work_dir?: string; agent_type?: string;
}) => api.post<{ message: string; restart_required: boolean }>('/setup/feishu/save', body);
export const setupWeixinBegin = (apiUrl?: string) =>
api.post<WeixinBeginResponse>('/setup/weixin/begin', { api_url: apiUrl });
export const setupWeixinPoll = (qrKey: string, apiUrl?: string) =>
api.post<WeixinPollResponse>('/setup/weixin/poll', { qr_key: qrKey, api_url: apiUrl });
export const setupWeixinSave = (body: {
project: string; token: string; base_url?: string;
ilink_bot_id?: string; ilink_user_id?: string; work_dir?: string; agent_type?: string;
}) => api.post<{ message: string; restart_required: boolean }>('/setup/weixin/save', body);
+54
View File
@@ -0,0 +1,54 @@
import api from './client';
export interface SkillInfo {
name: string;
display_name?: string;
description?: string;
source: string;
}
export interface ProjectSkills {
project: string;
agent_type: string;
dirs: string[];
skills: SkillInfo[];
}
export interface SkillSource {
provider: string;
name?: string;
url?: string;
}
export interface SkillPricing {
type: 'free' | 'paid' | 'freemium';
price?: number;
currency?: string;
}
export interface SkillPreset {
name: string;
display_name: string;
description?: string;
description_zh?: string;
version?: string;
author?: string;
url?: string;
agent_types?: string[];
tags?: string[];
featured?: boolean;
source?: SkillSource;
pricing?: SkillPricing;
}
export interface SkillPresetsResponse {
version: number;
updated_at?: string;
skills: SkillPreset[];
}
export const listSkills = () =>
api.get<{ projects: ProjectSkills[] }>('/skills');
export const fetchSkillPresets = () =>
api.get<SkillPresetsResponse>('/skills/presets');
+13
View File
@@ -0,0 +1,13 @@
import api from './client';
export interface SystemStatus {
version: string;
uptime_seconds: number;
connected_platforms: string[];
projects_count: number;
bridge_adapters: { platform: string; project: string; capabilities: string[] }[];
}
export const getStatus = () => api.get<SystemStatus>('/status');
export const restartSystem = (body?: { session_key?: string; platform?: string }) => api.post('/restart', body);
export const reloadConfig = () => api.post<{ message: string; projects_added: string[]; projects_removed: string[]; projects_updated: string[] }>('/reload');
+29
View File
@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
import { getStatus } from '@/api/status';
export default function Footer() {
const [version, setVersion] = useState('');
useEffect(() => {
getStatus().then(s => setVersion(s.version || '')).catch(() => {});
}, []);
const year = new Date().getFullYear();
return (
<footer className="shrink-0 mt-4 pt-3 pb-1 text-center text-xs text-gray-400 dark:text-gray-500 select-none">
<span>© {year} CC-Connect</span>
{version && <span className="mx-1.5">·</span>}
{version && <span>{version.startsWith('v') ? version : `v${version}`}</span>}
<span className="mx-1.5">·</span>
<a
href="https://github.com/chenhg5/cc-connect"
target="_blank"
rel="noopener noreferrer"
className="hover:text-accent transition-colors"
>
GitHub
</a>
</footer>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { useTranslation } from 'react-i18next';
import { useState, useRef, useEffect } from 'react';
import {
RefreshCw, Sun, Moon, Monitor, LogOut, Languages, ChevronDown,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useThemeStore } from '@/store/theme';
import { useAuthStore } from '@/store/auth';
const languages = [
{ code: 'en', label: 'EN' },
{ code: 'zh', label: '中文' },
{ code: 'zh-TW', label: '繁體' },
{ code: 'ja', label: '日本語' },
{ code: 'es', label: 'ES' },
];
export default function Header() {
const { t, i18n } = useTranslation();
const { theme, setTheme } = useThemeStore();
const logout = useAuthStore((s) => s.logout);
const [spinning, setSpinning] = useState(false);
const [langOpen, setLangOpen] = useState(false);
const langRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (langRef.current && !langRef.current.contains(e.target as Node)) setLangOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const handleRefresh = () => {
setSpinning(true);
window.dispatchEvent(new CustomEvent('cc:refresh'));
setTimeout(() => setSpinning(false), 1000);
};
const themeIcons = { light: Sun, dark: Moon, system: Monitor };
const nextTheme = { light: 'dark' as const, dark: 'system' as const, system: 'light' as const };
const ThemeIcon = themeIcons[theme];
const changeLang = (code: string) => {
i18n.changeLanguage(code);
localStorage.setItem('cc_lang', code);
setLangOpen(false);
};
const btnCls = cn(
'p-2 rounded-lg transition-all duration-200',
'text-gray-500 dark:text-gray-400',
'hover:bg-gray-100/90 dark:hover:bg-white/[0.08] hover:text-gray-800 dark:hover:text-white',
);
return (
<header
className={cn(
'h-14 flex items-center justify-end gap-1 px-4 shrink-0 relative z-20',
'border-b border-gray-200/80 dark:border-white/[0.08]',
'bg-white/70 backdrop-blur-xl dark:bg-[rgba(0,0,0,0.72)]',
)}
>
<button type="button" onClick={handleRefresh} className={btnCls} aria-label={t('common.refresh')}>
<RefreshCw size={16} className={spinning ? 'animate-spin' : ''} />
</button>
{/* Language */}
<div className="relative" ref={langRef}>
<button type="button" onClick={() => setLangOpen(!langOpen)} className={cn(btnCls, 'flex items-center gap-1')}>
<Languages size={16} />
<span className="text-xs hidden sm:inline">{languages.find(l => l.code === i18n.language)?.label}</span>
</button>
{langOpen && (
<div className={cn(
'absolute right-0 top-full mt-1 w-36 rounded-xl py-1 z-50 overflow-hidden',
'bg-white/95 backdrop-blur-xl border border-gray-200/80 shadow-xl shadow-black/10',
'dark:bg-[rgba(0,0,0,0.88)] dark:border-white/[0.1] dark:shadow-black/40',
)}>
{languages.map(l => (
<button
key={l.code}
type="button"
onClick={() => changeLang(l.code)}
className={cn(
'w-full text-left px-3 py-1.5 text-sm transition-colors',
i18n.language === l.code
? 'text-accent font-medium bg-accent/10'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100/80 dark:hover:bg-white/[0.06]',
)}
>
{l.label}
</button>
))}
</div>
)}
</div>
{/* Theme */}
<button type="button" onClick={() => setTheme(nextTheme[theme])} className={btnCls} aria-label="Theme">
<ThemeIcon size={16} />
</button>
{/* Logout */}
<button
type="button"
onClick={logout}
className={cn(
'p-2 rounded-lg transition-all duration-200',
'text-gray-400 hover:bg-red-500/10 hover:text-red-600 dark:hover:text-red-400',
)}
aria-label={t('login.logout')}
>
<LogOut size={16} />
</button>
</header>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { Outlet } from 'react-router-dom';
import Sidebar from './Sidebar';
import Header from './Header';
import Footer from './Footer';
import { cn } from '@/lib/utils';
export default function Layout() {
return (
<div
className={cn(
'flex h-screen overflow-hidden',
'bg-gradient-to-br from-gray-100 via-white to-gray-100',
'dark:from-gray-950 dark:via-[#0a0a0c] dark:to-gray-950',
)}
>
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
<Header />
<main className="flex-1 overflow-y-auto p-6 flex flex-col min-h-0">
<div className="flex-1 flex flex-col">
<Outlet />
</div>
<Footer />
</main>
</div>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
LayoutDashboard,
FolderKanban,
MessageSquare,
Clock,
Settings,
ChevronLeft,
ChevronRight,
Plug,
Puzzle,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useState } from 'react';
const navItems = [
{ key: 'dashboard', path: '/', icon: LayoutDashboard },
{ key: 'projects', path: '/projects', icon: FolderKanban },
{ key: 'providers', path: '/providers', icon: Plug },
{ key: 'skills', path: '/skills', icon: Puzzle },
{ key: 'chat', path: '/chat', icon: MessageSquare },
{ key: 'cron', path: '/cron', icon: Clock },
{ key: 'system', path: '/system', icon: Settings },
];
export default function Sidebar() {
const { t } = useTranslation();
const [collapsed, setCollapsed] = useState(false);
return (
<aside
className={cn(
'h-screen flex flex-col border-r transition-all duration-300 ease-out',
'bg-white/75 backdrop-blur-xl border-gray-200/80',
'dark:bg-[rgba(0,0,0,0.85)] dark:backdrop-blur-xl dark:border-white/[0.08]',
collapsed ? 'w-16' : 'w-56',
)}
>
{/* Brand */}
<div
className={cn(
'flex items-center px-4 h-14 border-b transition-colors shrink-0',
'border-gray-200/80 dark:border-white/[0.08]',
collapsed ? 'justify-center' : 'gap-0',
)}
>
{collapsed ? (
<span className="text-base font-bold tracking-tighter text-gray-900 dark:text-white">
CC
</span>
) : (
<span className="text-base font-bold tracking-tight text-gray-900 dark:text-white">
CC<span className="text-accent">-</span>Connect
</span>
)}
</div>
{/* Navigation */}
<nav className="flex-1 py-4 space-y-1 px-2 overflow-y-auto">
{navItems.map(({ key, path, icon: Icon }) => (
<NavLink
key={key}
to={path}
end={path === '/'}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200',
isActive
? 'bg-accent/12 text-accent ring-1 ring-accent/25'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100/80 dark:hover:bg-white/[0.06] hover:text-gray-900 dark:hover:text-white',
)
}
>
<Icon size={18} className="shrink-0" />
{!collapsed && <span>{t(`nav.${key}`)}</span>}
</NavLink>
))}
</nav>
{/* Collapse toggle */}
<div className={cn('border-t p-2', 'border-gray-200/80 dark:border-white/[0.08]')}>
<button
type="button"
onClick={() => setCollapsed(!collapsed)}
className={cn(
'flex items-center justify-center w-full px-3 py-2 rounded-xl transition-colors duration-200',
'text-gray-400 hover:bg-gray-100/80 dark:hover:bg-white/[0.06]',
)}
>
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
</button>
</div>
</aside>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { cn } from '@/lib/utils';
interface BadgeProps {
children: React.ReactNode;
variant?: 'default' | 'success' | 'warning' | 'danger' | 'info' | 'outline';
className?: string;
}
const variants = {
default: 'bg-gray-100/90 dark:bg-white/[0.08] text-gray-600 dark:text-gray-400 border border-gray-200/80 dark:border-white/[0.06]',
success:
'bg-emerald-100/90 dark:bg-emerald-900/25 text-emerald-700 dark:text-emerald-400 border border-emerald-200/50 dark:border-emerald-500/20',
warning:
'bg-amber-100/90 dark:bg-amber-900/25 text-amber-700 dark:text-amber-400 border border-amber-200/50 dark:border-amber-500/20',
danger:
'bg-red-100/90 dark:bg-red-900/25 text-red-700 dark:text-red-400 border border-red-200/50 dark:border-red-500/20',
info: 'bg-accent/10 text-accent border border-accent/20',
outline: 'bg-transparent text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-white/[0.12]',
};
export function Badge({ children, variant = 'default', className }: BadgeProps) {
return (
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium backdrop-blur-sm',
variants[variant],
className,
)}
>
{children}
</span>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { cn } from '@/lib/utils';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
children: ReactNode;
loading?: boolean;
}
const variants = {
primary: 'bg-accent text-white dark:text-black hover:bg-accent-dim font-medium dark:shadow-[0_0_20px_-6px_rgba(66,255,156,0.55)]',
secondary:
'bg-gray-100/90 dark:bg-white/[0.08] text-gray-700 dark:text-gray-300 hover:bg-gray-200/90 dark:hover:bg-white/[0.12] border border-transparent dark:border-white/[0.06]',
danger: 'bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20',
ghost:
'text-gray-500 dark:text-gray-400 hover:bg-gray-100/80 dark:hover:bg-white/[0.06] hover:text-gray-700 dark:hover:text-gray-200',
};
const sizes = {
sm: 'px-3 py-1.5 text-xs rounded-lg',
md: 'px-4 py-2 text-sm rounded-lg',
lg: 'px-6 py-2.5 text-sm rounded-xl',
};
export function Button({
variant = 'primary',
size = 'md',
className,
children,
loading,
disabled,
...props
}: ButtonProps) {
return (
<button
className={cn(
'inline-flex items-center justify-center gap-2 font-medium transition-all duration-200 disabled:opacity-50 disabled:pointer-events-none',
variants[variant],
sizes[size],
className
)}
disabled={disabled || loading}
{...props}
>
{loading && (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" aria-hidden>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)}
{children}
</button>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { cn } from '@/lib/utils';
import type { ReactNode } from 'react';
interface CardProps {
children: ReactNode;
className?: string;
hover?: boolean;
}
export function Card({ children, className, hover }: CardProps) {
return (
<div
className={cn(
'rounded-xl p-5 transition-all duration-200 animate-float-in',
'bg-white/80 backdrop-blur-md border border-gray-200/90',
'dark:bg-[rgba(0,0,0,0.55)] dark:backdrop-blur-xl dark:border-white/[0.08]',
hover &&
'hover:-translate-y-1 hover:shadow-lg hover:shadow-black/5 dark:hover:shadow-black/30 cursor-pointer',
className
)}
>
{children}
</div>
);
}
interface StatCardProps {
label: string;
value: string | number;
accent?: boolean;
}
export function StatCard({ label, value, accent }: StatCardProps) {
return (
<Card hover>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
{label}
</p>
<p
className={cn(
'text-2xl font-bold mt-1',
accent ? 'text-accent' : 'text-gray-900 dark:text-white'
)}
>
{value}
</p>
</Card>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { InboxIcon } from 'lucide-react';
import type { ElementType } from 'react';
interface EmptyStateProps {
message: string;
icon?: ElementType<{ size?: number; strokeWidth?: number; className?: string }>;
}
export function EmptyState({ message, icon: Icon = InboxIcon }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-gray-400 dark:text-gray-500">
<Icon size={48} strokeWidth={1} className="mb-4 opacity-80" />
<p className="text-sm">{message}</p>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { cn } from '@/lib/utils';
import type { InputHTMLAttributes, TextareaHTMLAttributes } from 'react';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
}
export function Input({ label, className, ...props }: InputProps) {
return (
<div className="space-y-1.5">
{label && (
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{label}</label>
)}
<input
className={cn(
'w-full px-3 py-2 text-sm rounded-lg transition-all duration-200',
'border border-gray-300/90 dark:border-white/[0.1]',
'bg-white/90 backdrop-blur-sm dark:bg-[rgba(0,0,0,0.45)] dark:backdrop-blur-md',
'text-gray-900 dark:text-white',
'focus:outline-none focus:ring-2 focus:ring-accent/45 focus:border-accent',
'placeholder:text-gray-400 dark:placeholder:text-gray-500',
className
)}
{...props}
/>
</div>
);
}
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
}
export function Textarea({ label, className, ...props }: TextareaProps) {
return (
<div className="space-y-1.5">
{label && (
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{label}</label>
)}
<textarea
className={cn(
'w-full px-3 py-2 text-sm rounded-lg transition-all duration-200 resize-none',
'border border-gray-300/90 dark:border-white/[0.1]',
'bg-white/90 backdrop-blur-sm dark:bg-[rgba(0,0,0,0.45)] dark:backdrop-blur-md',
'text-gray-900 dark:text-white',
'focus:outline-none focus:ring-2 focus:ring-accent/45 focus:border-accent',
'placeholder:text-gray-400 dark:placeholder:text-gray-500',
className
)}
{...props}
/>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { cn } from '@/lib/utils';
import { X } from 'lucide-react';
import { createPortal } from 'react-dom';
import type { ReactNode } from 'react';
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
className?: string;
}
export function Modal({ open, onClose, title, children, className }: ModalProps) {
if (!open) return null;
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity"
onClick={onClose}
role="presentation"
/>
<div
className={cn(
'relative w-full max-w-lg rounded-2xl p-6 shadow-2xl animate-fade-in',
'bg-white/95 backdrop-blur-xl border border-gray-200/90',
'dark:bg-[rgba(0,0,0,0.88)] dark:border-white/[0.1] dark:shadow-black/50',
className
)}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{title}</h2>
<button
type="button"
onClick={onClose}
className={cn(
'p-1 rounded-lg transition-colors duration-200',
'text-gray-400 hover:bg-gray-100/90 dark:hover:bg-white/[0.08]'
)}
aria-label="Close"
>
<X size={18} />
</button>
</div>
{children}
</div>
</div>,
document.body
);
}
+6
View File
@@ -0,0 +1,6 @@
export { Card, StatCard } from './Card';
export { Button } from './Button';
export { Badge } from './Badge';
export { Modal } from './Modal';
export { Input, Textarea } from './Input';
export { EmptyState } from './EmptyState';
+163
View File
@@ -0,0 +1,163 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import api from '@/api/client';
export type BridgeIncoming =
| { type: 'register_ack'; ok: boolean; error?: string }
| { type: 'reply'; session_key: string; reply_ctx: string; content: string; format?: string }
| { type: 'reply_stream'; session_key: string; reply_ctx: string; delta: string; full_text: string; preview_handle?: string; done: boolean }
| { type: 'card'; session_key: string; reply_ctx: string; card: any }
| { type: 'buttons'; session_key: string; reply_ctx: string; content: string; buttons: { text: string; data: string }[][] }
| { type: 'typing_start'; session_key: string }
| { type: 'typing_stop'; session_key: string }
| { type: 'preview_start'; ref_id: string; session_key: string; reply_ctx: string; content: string }
| { type: 'update_message'; session_key: string; preview_handle: string; content: string }
| { type: 'delete_message'; session_key: string; preview_handle: string }
| { type: 'error'; code: string; message: string }
| { type: 'pong'; ts: number }
| { type: string; [key: string]: any };
export interface BridgeConfig {
port: number;
path: string;
token: string;
}
export type BridgeStatus = 'connecting' | 'registering' | 'connected' | 'disconnected' | 'error';
export interface UseBridgeSocketOptions {
bridgeCfg: BridgeConfig | null;
platformName?: string;
sessionKey: string;
projectName?: string;
onMessage: (msg: BridgeIncoming) => void;
}
export function useBridgeSocket({ bridgeCfg, platformName = 'web', sessionKey, projectName, onMessage }: UseBridgeSocketOptions) {
const wsRef = useRef<WebSocket | null>(null);
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
const pingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [status, setStatus] = useState<BridgeStatus>('disconnected');
const send = useCallback((data: Record<string, any>) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(data));
}
}, []);
const sendMessage = useCallback((content: string) => {
send({
type: 'message',
msg_id: `web-${Date.now()}`,
session_key: sessionKey,
user_id: 'web-admin',
user_name: 'Web Admin',
content,
reply_ctx: sessionKey,
project: projectName || '',
});
}, [send, sessionKey, projectName]);
const sendCardAction = useCallback((action: string) => {
send({
type: 'card_action',
session_key: sessionKey,
action,
reply_ctx: sessionKey,
project: projectName || '',
});
}, [send, sessionKey, projectName]);
const sendPreviewAck = useCallback((refId: string, handle: string) => {
send({ type: 'preview_ack', ref_id: refId, preview_handle: handle });
}, [send]);
useEffect(() => {
if (!bridgeCfg) return;
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// Use current page host:port so the request goes through the Vite/nginx proxy
// instead of directly hitting the bridge port (which may not be reachable).
const wsUrl = `${proto}//${window.location.host}${bridgeCfg.path}?token=${encodeURIComponent(bridgeCfg.token)}`;
let ws: WebSocket;
let reconnectTimer: ReturnType<typeof setTimeout>;
let alive = true;
const connect = () => {
if (!alive) return;
setStatus('connecting');
ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setStatus('registering');
ws.send(JSON.stringify({
type: 'register',
platform: platformName,
capabilities: ['text', 'card', 'buttons', 'typing', 'update_message', 'preview', 'reconstruct_reply'],
metadata: { version: '1.0.0', description: 'Web Admin Dashboard' },
}));
};
ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data) as BridgeIncoming;
if (msg.type === 'register_ack') {
if (msg.ok) {
setStatus('connected');
pingRef.current = setInterval(() => {
send({ type: 'ping', ts: Date.now() });
}, 25000);
} else {
setStatus('error');
}
}
onMessageRef.current(msg);
} catch { /* ignore parse errors */ }
};
ws.onclose = () => {
setStatus('disconnected');
wsRef.current = null;
if (pingRef.current) clearInterval(pingRef.current);
if (alive) reconnectTimer = setTimeout(connect, 3000);
};
ws.onerror = () => {
setStatus('error');
};
};
connect();
return () => {
alive = false;
clearTimeout(reconnectTimer);
if (pingRef.current) clearInterval(pingRef.current);
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.close();
wsRef.current = null;
}
setStatus('disconnected');
};
}, [bridgeCfg, platformName, send]);
return { status, send, sendMessage, sendCardAction, sendPreviewAck };
}
// Fetch bridge config from the management API status endpoint.
export async function fetchBridgeConfig(): Promise<BridgeConfig | null> {
try {
const status = await api.get<any>('/status');
if (status.bridge?.enabled) {
return {
port: status.bridge.port,
path: status.bridge.path,
token: status.bridge.token,
};
}
} catch { /* bridge not available */ }
return null;
}
+24
View File
@@ -0,0 +1,24 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from './locales/en.json';
import zh from './locales/zh.json';
import zhTW from './locales/zh-TW.json';
import ja from './locales/ja.json';
import es from './locales/es.json';
const saved = localStorage.getItem('cc_lang') || navigator.language.split('-')[0] || 'en';
i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
zh: { translation: zh },
'zh-TW': { translation: zhTW },
ja: { translation: ja },
es: { translation: es },
},
lng: saved,
fallbackLng: 'en',
interpolation: { escapeValue: false },
});
export default i18n;
+408
View File
@@ -0,0 +1,408 @@
{
"nav": {
"dashboard": "Dashboard",
"projects": "Projects",
"providers": "Providers",
"sessions": "Sessions",
"chat": "Chat",
"cron": "Cron",
"bridge": "Bridge",
"skills": "Skills",
"system": "System"
},
"dashboard": {
"title": "Dashboard",
"version": "Version",
"uptime": "Uptime",
"platforms": "Platforms",
"projects": "Projects",
"bridgeAdapters": "Bridge adapters",
"noData": "No data available",
"recentSessions": "Recent sessions"
},
"projects": {
"title": "Projects",
"name": "Name",
"agent": "Agent",
"platforms": "Platforms",
"sessions": "Sessions",
"heartbeat": "Heartbeat",
"settings": "Settings",
"quiet": "Quiet mode",
"language": "Language",
"adminFrom": "Admin from",
"disabledCommands": "Disabled commands",
"save": "Save",
"detail": "Details",
"noProjects": "No projects configured",
"workDir": "Working directory",
"agentType": "Agent type",
"agentTypeChangeHint": "Changing agent type requires restart. Incompatible providers will be removed.",
"agentMode": "Permission mode",
"agentSettings": "Agent",
"generalSettings": "General",
"showCtxIndicator": "Context indicator",
"showCtxIndicatorHint": "Show [ctx: ~N%] suffix on replies",
"replyFooter": "Reply footer",
"replyFooterHint": "Append model/usage metadata to replies",
"injectSender": "Inject sender",
"injectSenderHint": "Prepend sender identity to messages sent to agent",
"platformAccess": "Platform access control",
"deleteTitle": "Delete Project",
"deleteConfirm": "Are you sure you want to delete project \"{{name}}\"? This will remove it from the config file.",
"dangerZone": "Danger Zone",
"deleteHint": "Remove this project from config. Requires restart.",
"tabs": {
"overview": "Overview",
"providers": "Providers",
"heartbeat": "Heartbeat",
"settings": "Settings"
}
},
"sessions": {
"title": "Sessions",
"id": "ID",
"sessionKey": "Session key",
"name": "Name",
"platform": "Platform",
"active": "Active",
"createdAt": "Created at",
"history": "History",
"send": "Send",
"messageInput": "Message",
"delete": "Delete",
"noSessions": "No sessions",
"noMessages": "No messages yet",
"notLiveHint": "This session is not active. Messages can only be sent when the agent is running.",
"offline": "offline",
"justNow": "just now",
"allProjects": "All projects",
"chat": "Chat",
"enterSession": "Open session",
"bridgeConnected": "connected",
"bridgeConnecting": "connecting...",
"bridgeDisconnected": "disconnected",
"bridgeNotAvailable": "Bridge not available. Enable [bridge] in config.toml to chat from web."
},
"providers": {
"title": "Providers",
"name": "Name",
"model": "Model",
"baseUrl": "Base URL",
"active": "Active",
"add": "Add provider",
"remove": "Remove",
"activate": "Activate",
"setModel": "Set model",
"models": "Models",
"global": "global",
"emptyProject": "No providers configured for this project.",
"emptyProjectHint": "Link a global provider or add a custom one.",
"linkGlobal": "Link global",
"addCustom": "Add custom",
"allLinked": "All global providers are already linked.",
"manageGlobal": "Manage global providers"
},
"cron": {
"title": "Scheduled jobs",
"expression": "Cron expression",
"prompt": "Prompt",
"exec": "Execute",
"description": "Description",
"enabled": "Enabled",
"silent": "Silent",
"lastRun": "Last run",
"lastError": "Last error",
"add": "Add job",
"delete": "Delete",
"noJobs": "No scheduled jobs",
"workDir": "Working directory",
"sessionKey": "Session key",
"project": "Project",
"editJob": "Edit job",
"schedule": "Schedule",
"selectProject": "Select project",
"descPlaceholder": "Job description",
"promptPlaceholder": "Prompt to send to agent...",
"selectSessionKey": "Select session (empty for default)",
"taskType": "Task type",
"mode": "Permission mode",
"modeDefault": "Use project default"
},
"heartbeat": {
"title": "Heartbeat",
"status": "Status",
"interval": "Interval",
"paused": "Paused",
"running": "Running",
"pause": "Pause",
"resume": "Resume",
"trigger": "Run now",
"setInterval": "Set interval",
"runCount": "Run count",
"errorCount": "Error count",
"skippedBusy": "Skipped (busy)",
"lastRun": "Last run",
"notEnabled": "Heartbeat is not configured for this project. Add [heartbeat] section in config.toml to enable."
},
"bridge": {
"title": "Bridge",
"platform": "Platform",
"capabilities": "Capabilities",
"connectedAt": "Connected at",
"noAdapters": "No bridge adapters"
},
"system": {
"title": "System",
"config": "Configuration",
"logs": "Logs",
"restart": "Restart",
"reload": "Reload config",
"restartConfirm": "Restart the service? Active sessions may be interrupted.",
"reloadConfirm": "Reload configuration from disk?",
"level": "Log level",
"limit": "Line limit",
"rawConfig": "Raw Config"
},
"login": {
"title": "CC-Connect Admin",
"subtitle": "Connect to your CC-Connect instance",
"token": "API token",
"serverUrl": "Server URL",
"connect": "Connect",
"invalidToken": "Invalid or expired token",
"logout": "Log out"
},
"common": {
"loading": "Loading…",
"error": "Error",
"success": "Success",
"confirm": "Confirm",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"back": "Back",
"refresh": "Refresh",
"search": "Search",
"noData": "No data",
"actions": "Actions",
"viewAll": "View all",
"optional": "optional",
"confirmDelete": "Are you sure you want to delete this?",
"close": "Close",
"saving": "Saving…"
},
"setup": {
"addPlatform": "Add platform",
"choosePlatform": "Choose a platform to connect:",
"scanToConnect": "Scan QR code to connect",
"feishuLabel": "Feishu / Lark",
"weixinLabel": "WeChat (ilink)",
"qrDescription": "Scan a QR code with your phone to quickly connect {{platform}}.",
"startQR": "Start QR Setup",
"generating": "Generating QR code...",
"scanFeishu": "Open the Feishu / Lark app and scan the QR code",
"scanWeixin": "Open WeChat and scan the QR code",
"waitingScan": "Waiting for scan...",
"scannedConfirm": "Scanned! Please confirm on your phone...",
"waitingConfirm": "Waiting for confirmation...",
"savingConfig": "Saving configuration...",
"completed": "Platform connected successfully!",
"restartHint": "Restart the service for the new platform to take effect.",
"restartRequired": "Restart required",
"restartNow": "Restart now",
"restarting": "Restarting service...",
"restartAfterDelete": "Project removed. Restart service to take effect?",
"later": "Later",
"expired": "QR code expired.",
"denied": "Authorization was denied.",
"retry": "Retry",
"addProject": "Add project",
"projectName": "Project name",
"workDir": "Working directory",
"agentType": "Agent type",
"next": "Next",
"manualSetup": "Manual setup",
"manualHint": "For {{platform}}, please configure credentials in config.toml and restart the service.",
"advancedOptions": "Advanced options",
"unsupportedPlatform": "Unsupported platform type: {{type}}"
},
"fields": {
"botToken": "Bot Token",
"appToken": "App Token",
"accessToken": "Access Token",
"allowFrom": "Allowed users",
"allowFromHintTelegram": "Telegram user IDs, comma-separated",
"groupReplyAll": "Reply to all group messages",
"sharedGroupSession": "Shared group session",
"sharedChannelSession": "Shared channel session",
"guildId": "Guild ID",
"guildIdHint": "For instant slash command registration",
"threadIsolation": "Thread isolation",
"clientId": "Client ID (AppKey)",
"clientSecret": "Client Secret (AppSecret)",
"corpId": "Corp ID",
"corpSecret": "Corp Secret",
"agentId": "Agent ID",
"callbackToken": "Callback Token",
"callbackAesKey": "Callback AES Key",
"callbackAesKeyHint": "43 characters",
"callbackPath": "Callback path",
"apiBaseUrl": "API base URL",
"port": "Port",
"wsUrl": "WebSocket URL",
"appId": "App ID",
"appSecret": "App Secret",
"sandboxMode": "Sandbox mode",
"channelSecret": "Channel Secret",
"channelToken": "Channel Access Token"
},
"chat": {
"noChats": "No projects yet",
"noMessages": "No messages yet",
"sessions": "Sessions",
"emptyHint": "Start a conversation with your agent",
"slashHint": "Press / to see available commands",
"inputPlaceholder": "Type a message or press / for commands...",
"commands": "Commands",
"defaultSession": "Web Session"
},
"cmd": {
"search": "Search commands...",
"groupSession": "Session",
"groupSettings": "Settings",
"groupInfo": "Info",
"groupAdvanced": "Advanced",
"new": "New session",
"list": "Session list",
"switch": "Switch session",
"current": "Current session",
"history": "History",
"stop": "Stop session",
"model": "Model",
"reasoning": "Reasoning",
"mode": "Mode",
"lang": "Language",
"provider": "Provider",
"quiet": "Quiet mode",
"status": "Status",
"help": "Help",
"doctor": "Diagnostics",
"version": "Version",
"whoami": "Who am I",
"commands": "All commands",
"dir": "Work directory",
"cron": "Scheduled jobs",
"heartbeat": "Heartbeat",
"alias": "Aliases",
"config": "Configuration",
"skills": "Skills",
"upgrade": "Upgrade",
"deleteMode": "Delete mode"
},
"settings": {
"title": "Global Settings",
"general": "General",
"language": "Language",
"quiet": "Quiet mode",
"quietHint": "Suppress start / end notifications globally",
"attachmentSend": "Attachment send",
"attachmentSendHint": "Send file/image attachments back to platform",
"default": "default",
"idleTimeout": "Idle timeout (min)",
"idleTimeoutHint": "Auto-stop agent after N minutes of inactivity; 0 = disabled",
"display": "Display",
"thinkingMessages": "Thinking messages",
"thinkingMessagesHint": "Show or hide intermediate thinking messages",
"thinkingMaxLen": "Thinking max length",
"thinkingMaxLenHint": "Max characters for thinking messages; 0 = no truncation",
"toolMessages": "Tool progress",
"toolMessagesHint": "Show or hide tool progress messages",
"toolMaxLen": "Tool max length",
"toolMaxLenHint": "Max characters for tool use messages; 0 = no truncation",
"streamPreview": "Stream preview",
"streamPreviewEnabled": "Enable",
"streamPreviewEnabledHint": "Show real-time streaming updates in IM",
"streamPreviewInterval": "Interval (ms)",
"streamPreviewIntervalHint": "Minimum milliseconds between preview updates",
"rateLimit": "Rate limit",
"rlMaxMessages": "Max messages",
"rlMaxMessagesHint": "Max messages per window; 0 = disabled",
"rlWindowSecs": "Window (sec)",
"rlWindowSecsHint": "Time window in seconds",
"log": "Log",
"logLevel": "Log level"
},
"globalProviders": {
"title": "Providers",
"subtitle": "Manage shared API providers across all projects",
"add": "Add Provider",
"importCCSwitch": "Import from CC-Switch",
"edit": "Edit Provider",
"empty": "No providers configured",
"emptyHint": "Add a global provider or import from presets to get started.",
"deleteHint": "Remove provider \"{{name}}\"? Projects referencing it will lose access.",
"noPresets": "No presets available",
"noPresetsHint": "Provider presets could not be loaded. Check your network connection.",
"register": "Register",
"addPreset": "Add",
"added": "Added",
"tab": {
"providers": "My Providers",
"presets": "Presets"
},
"form": {
"name": "Name",
"model": "Default model",
"modelHint": "The model used when switching to this provider. Click ✓ on a model below to set it.",
"models": "Available models",
"modelsHint": "Models users can switch to via /model. Click ✓ to set as default.",
"agentTypes": "Agent types",
"agentTypesHint": "Leave empty for all agent types",
"thinkingDefault": "Default (auto)",
"perAgentHint": "Different agents may use different Base URL / models. Configure per agent type below.",
"defaultConfig": "Default",
"baseUrl": "Base URL",
"codexWireApi": "Wire API"
},
"ccSwitch": {
"title": "Import from CC-Switch",
"notFound": "CC-Switch database not found. Make sure CC-Switch is installed.",
"empty": "No providers configured in CC-Switch.",
"hint": "Found {{count}} providers in CC-Switch. Select which to import:",
"active": "active",
"exists": "exists",
"import": "Import ({{count}})",
"result": "Import complete: {{imported}} imported, {{skipped}} skipped."
}
},
"skills": {
"title": "Skills",
"subtitle": "Manage agent skills and discover new ones",
"tab": {
"local": "Local Skills",
"recommended": "Recommended"
},
"projects": "Projects",
"skillCount": "{{count}} skills",
"scanDirs": "Scan directories",
"noSkills": "No skills found",
"noSkillsHint": "Skills are loaded from agent skill directories (e.g. ~/.claude/skills/)",
"emptyProject": "No skills found in this project's directories",
"noPresets": "No recommended skills available",
"noPresetsHint": "Skill recommendations could not be loaded. Check your network connection.",
"featured": "Featured",
"allSkills": "All Skills",
"author": "Author",
"source": "From",
"download": "Download",
"free": "Free",
"freemium": "Freemium",
"paid": "Paid"
},
"theme": {
"light": "Light",
"dark": "Dark",
"system": "System"
}
}
+408
View File
@@ -0,0 +1,408 @@
{
"nav": {
"dashboard": "Panel",
"projects": "Proyectos",
"providers": "Proveedores",
"sessions": "Sesiones",
"chat": "Chat",
"cron": "Cron",
"bridge": "Puente",
"skills": "Habilidades",
"system": "Sistema"
},
"dashboard": {
"title": "Panel",
"version": "Versión",
"uptime": "Tiempo activo",
"platforms": "Plataformas",
"projects": "Proyectos",
"bridgeAdapters": "Adaptadores de puente",
"noData": "No hay datos disponibles",
"recentSessions": "Sesiones recientes"
},
"projects": {
"title": "Proyectos",
"name": "Nombre",
"agent": "Agente",
"platforms": "Plataformas",
"sessions": "Sesiones",
"heartbeat": "Latido",
"settings": "Ajustes",
"quiet": "Modo silencioso",
"language": "Idioma",
"adminFrom": "Administración desde",
"disabledCommands": "Comandos deshabilitados",
"save": "Guardar",
"detail": "Detalles",
"noProjects": "No hay proyectos configurados",
"workDir": "Directorio de trabajo",
"agentType": "Tipo de agente",
"agentTypeChangeHint": "Cambiar el tipo de agente requiere reinicio. Los proveedores incompatibles serán eliminados.",
"agentMode": "Modo de permisos",
"agentSettings": "Agente",
"generalSettings": "General",
"showCtxIndicator": "Indicador de contexto",
"showCtxIndicatorHint": "Mostrar el sufijo [ctx: ~N%] al final de las respuestas",
"replyFooter": "Pie de respuesta",
"replyFooterHint": "Añadir metadatos de modelo/uso al final de las respuestas",
"injectSender": "Inyectar remitente",
"injectSenderHint": "Anteponer la identidad del remitente a los mensajes enviados al agente",
"platformAccess": "Control de acceso a plataformas",
"deleteTitle": "Eliminar proyecto",
"deleteConfirm": "¿Está seguro de que desea eliminar el proyecto \"{{name}}\"? Se eliminará del archivo de configuración.",
"dangerZone": "Zona de peligro",
"deleteHint": "Eliminar este proyecto de la configuración. Requiere reinicio.",
"tabs": {
"overview": "Resumen",
"providers": "Proveedores",
"heartbeat": "Latido",
"settings": "Ajustes"
}
},
"sessions": {
"title": "Sesiones",
"id": "ID",
"sessionKey": "Clave de sesión",
"name": "Nombre",
"platform": "Plataforma",
"active": "Activa",
"createdAt": "Creada el",
"history": "Historial",
"send": "Enviar",
"messageInput": "Mensaje",
"delete": "Eliminar",
"noSessions": "No hay sesiones",
"noMessages": "Sin mensajes aún",
"notLiveHint": "Esta sesión no está activa. Solo se pueden enviar mensajes cuando el agente está en ejecución.",
"offline": "sin conexión",
"justNow": "ahora",
"allProjects": "Todos los proyectos",
"chat": "Chat",
"enterSession": "Abrir sesión",
"bridgeConnected": "conectado",
"bridgeConnecting": "conectando...",
"bridgeDisconnected": "desconectado",
"bridgeNotAvailable": "Bridge no disponible. Habilite [bridge] en config.toml para chatear desde la web."
},
"providers": {
"title": "Proveedores",
"name": "Nombre",
"model": "Modelo",
"baseUrl": "URL base",
"active": "Activo",
"add": "Añadir proveedor",
"remove": "Quitar",
"activate": "Activar",
"setModel": "Establecer modelo",
"models": "Modelos disponibles",
"global": "global",
"emptyProject": "No hay proveedores configurados para este proyecto.",
"emptyProjectHint": "Vincula un proveedor global o añade uno personalizado.",
"linkGlobal": "Vincular global",
"addCustom": "Añadir personalizado",
"allLinked": "Todos los proveedores globales ya están vinculados.",
"manageGlobal": "Gestionar proveedores globales"
},
"cron": {
"title": "Tareas programadas",
"expression": "Expresión cron",
"prompt": "Indicación",
"exec": "Ejecutar",
"description": "Descripción",
"enabled": "Habilitada",
"silent": "Silenciosa",
"lastRun": "Última ejecución",
"lastError": "Último error",
"add": "Añadir tarea",
"delete": "Eliminar",
"noJobs": "No hay tareas programadas",
"workDir": "Directorio de trabajo",
"sessionKey": "Clave de sesión",
"project": "Proyecto",
"editJob": "Editar tarea",
"schedule": "Horario",
"selectProject": "Seleccionar proyecto",
"descPlaceholder": "Descripción de la tarea",
"promptPlaceholder": "Indicación para enviar al agente...",
"selectSessionKey": "Seleccionar sesión (vacío = predeterminada)",
"taskType": "Tipo de tarea",
"mode": "Modo de permisos",
"modeDefault": "Usar predeterminado del proyecto"
},
"heartbeat": {
"title": "Latido",
"status": "Estado",
"interval": "Intervalo",
"paused": "En pausa",
"running": "En ejecución",
"pause": "Pausar",
"resume": "Reanudar",
"trigger": "Ejecutar ahora",
"setInterval": "Establecer intervalo",
"runCount": "Ejecuciones",
"errorCount": "Errores",
"skippedBusy": "Omitida (ocupado)",
"lastRun": "Última ejecución",
"notEnabled": "El heartbeat no está configurado para este proyecto. Agregue la sección [heartbeat] en config.toml para habilitarlo."
},
"bridge": {
"title": "Puente",
"platform": "Plataforma",
"capabilities": "Capacidades",
"connectedAt": "Conectada el",
"noAdapters": "No hay adaptadores de puente"
},
"system": {
"title": "Sistema",
"config": "Configuración",
"logs": "Registros",
"restart": "Reiniciar",
"reload": "Recargar configuración",
"restartConfirm": "¿Reiniciar el servicio? Las sesiones activas pueden interrumpirse.",
"reloadConfirm": "¿Recargar la configuración desde el disco?",
"level": "Nivel de registro",
"limit": "Límite de líneas",
"rawConfig": "Config. sin procesar"
},
"login": {
"title": "CC-Connect Admin",
"subtitle": "Conéctese a su instancia de CC-Connect",
"token": "Token de API",
"serverUrl": "URL del servidor",
"connect": "Conectar",
"invalidToken": "Token no válido o caducado",
"logout": "Cerrar sesión"
},
"common": {
"loading": "Cargando…",
"error": "Error",
"success": "Correcto",
"confirm": "Confirmar",
"cancel": "Cancelar",
"save": "Guardar",
"delete": "Eliminar",
"back": "Volver",
"refresh": "Actualizar",
"search": "Buscar",
"noData": "Sin datos",
"actions": "Acciones",
"viewAll": "Ver todo",
"optional": "opcional",
"confirmDelete": "¿Seguro que desea eliminar esto?",
"close": "Cerrar",
"saving": "Guardando…"
},
"setup": {
"addPlatform": "Añadir plataforma",
"choosePlatform": "Elige una plataforma para conectar:",
"scanToConnect": "Escanea el código QR para conectar",
"feishuLabel": "Feishu / Lark",
"weixinLabel": "WeChat (ilink)",
"qrDescription": "Escanea un código QR con tu teléfono para conectar {{platform}} rápidamente.",
"startQR": "Iniciar configuración por QR",
"generating": "Generando código QR...",
"scanFeishu": "Abre la aplicación Feishu / Lark y escanea el código QR",
"scanWeixin": "Abre WeChat y escanea el código QR",
"waitingScan": "Esperando el escaneo...",
"scannedConfirm": "¡Escaneado! Confirma en tu teléfono...",
"waitingConfirm": "Esperando confirmación...",
"savingConfig": "Guardando configuración...",
"completed": "¡Plataforma conectada correctamente!",
"restartHint": "Reinicia el servicio para que la nueva plataforma surta efecto.",
"restartRequired": "Reinicio necesario",
"restartNow": "Reiniciar ahora",
"restarting": "Reiniciando el servicio...",
"restartAfterDelete": "Proyecto eliminado. ¿Reiniciar el servicio para aplicar?",
"later": "Más tarde",
"expired": "El código QR ha caducado.",
"denied": "Se denegó la autorización.",
"retry": "Reintentar",
"addProject": "Agregar proyecto",
"projectName": "Nombre del proyecto",
"workDir": "Directorio de trabajo",
"agentType": "Tipo de agente",
"next": "Siguiente",
"manualSetup": "Configuración manual",
"manualHint": "Para {{platform}}, configure las credenciales en config.toml y reinicie el servicio.",
"advancedOptions": "Opciones avanzadas",
"unsupportedPlatform": "Tipo de plataforma no soportado: {{type}}"
},
"fields": {
"botToken": "Token del bot",
"appToken": "Token de la app",
"accessToken": "Token de acceso",
"allowFrom": "Usuarios permitidos",
"allowFromHintTelegram": "IDs de usuario de Telegram, separados por comas",
"groupReplyAll": "Responder a todos los mensajes del grupo",
"sharedGroupSession": "Sesión de grupo compartida",
"sharedChannelSession": "Sesión de canal compartida",
"guildId": "ID del servidor",
"guildIdHint": "Para registrar comandos slash instantáneamente",
"threadIsolation": "Aislamiento de hilos",
"clientId": "Client ID (AppKey)",
"clientSecret": "Client Secret (AppSecret)",
"corpId": "ID de empresa",
"corpSecret": "Secreto de empresa",
"agentId": "ID del agente",
"callbackToken": "Token de callback",
"callbackAesKey": "Clave AES de callback",
"callbackAesKeyHint": "43 caracteres",
"callbackPath": "Ruta de callback",
"apiBaseUrl": "URL base de API",
"port": "Puerto",
"wsUrl": "URL de WebSocket",
"appId": "App ID",
"appSecret": "App Secret",
"sandboxMode": "Modo sandbox",
"channelSecret": "Channel Secret",
"channelToken": "Channel Access Token"
},
"chat": {
"noChats": "No hay proyectos",
"noMessages": "No hay mensajes",
"sessions": "Lista de sesiones",
"emptyHint": "Inicia una conversación con tu agente",
"slashHint": "Presiona / para ver los comandos disponibles",
"inputPlaceholder": "Escribe un mensaje o presiona / para comandos...",
"commands": "Comandos",
"defaultSession": "Sesión Web"
},
"cmd": {
"search": "Buscar comandos...",
"groupSession": "Sesión",
"groupSettings": "Configuración",
"groupInfo": "Información",
"groupAdvanced": "Avanzado",
"new": "Nueva sesión",
"list": "Lista de sesiones",
"switch": "Cambiar sesión",
"current": "Sesión actual",
"history": "Historial",
"stop": "Detener sesión",
"model": "Modelo",
"reasoning": "Razonamiento",
"mode": "Modo",
"lang": "Idioma",
"provider": "Proveedor",
"quiet": "Modo silencioso",
"status": "Estado",
"help": "Ayuda",
"doctor": "Diagnóstico",
"version": "Versión",
"whoami": "Quién soy",
"commands": "Todos los comandos",
"dir": "Directorio de trabajo",
"cron": "Tareas programadas",
"heartbeat": "Heartbeat",
"alias": "Alias",
"config": "Configuración",
"skills": "Habilidades",
"upgrade": "Actualizar",
"deleteMode": "Modo eliminación"
},
"settings": {
"title": "Ajustes globales",
"general": "General",
"language": "Idioma",
"quiet": "Modo silencioso",
"quietHint": "Suprimir notificaciones de inicio/fin globalmente",
"attachmentSend": "Envío de adjuntos",
"attachmentSendHint": "Enviar archivos/imágenes adjuntos de vuelta a la plataforma",
"default": "predeterminado",
"idleTimeout": "Tiempo de inactividad (min)",
"idleTimeoutHint": "Detener agente automáticamente tras N minutos de inactividad; 0 = desactivado",
"display": "Visualización",
"thinkingMessages": "Mensajes de pensamiento",
"thinkingMessagesHint": "Mostrar u ocultar mensajes de proceso de pensamiento",
"thinkingMaxLen": "Longitud máx. de pensamiento",
"thinkingMaxLenHint": "Caracteres máximos para mensajes de pensamiento; 0 = sin truncar",
"toolMessages": "Progreso de herramientas",
"toolMessagesHint": "Mostrar u ocultar mensajes de progreso de herramientas",
"toolMaxLen": "Longitud máx. de herramientas",
"toolMaxLenHint": "Caracteres máximos para mensajes de uso de herramientas; 0 = sin truncar",
"streamPreview": "Vista previa en tiempo real",
"streamPreviewEnabled": "Activar",
"streamPreviewEnabledHint": "Mostrar actualizaciones en tiempo real en IM",
"streamPreviewInterval": "Intervalo (ms)",
"streamPreviewIntervalHint": "Milisegundos mínimos entre actualizaciones de vista previa",
"rateLimit": "Límite de frecuencia",
"rlMaxMessages": "Máx. mensajes",
"rlMaxMessagesHint": "Máx. mensajes por ventana; 0 = desactivado",
"rlWindowSecs": "Ventana (seg)",
"rlWindowSecsHint": "Ventana de tiempo en segundos",
"log": "Registro",
"logLevel": "Nivel de registro"
},
"globalProviders": {
"title": "Gestión de proveedores",
"subtitle": "Administra proveedores de API compartidos globalmente entre todos los proyectos",
"add": "Agregar proveedor",
"importCCSwitch": "Importar desde CC-Switch",
"edit": "Editar proveedor",
"empty": "No hay proveedores configurados",
"emptyHint": "Agrega un proveedor global o importa desde los preajustes.",
"deleteHint": "¿Eliminar proveedor \"{{name}}\"? Los proyectos que lo referencien perderán acceso.",
"noPresets": "Sin preajustes",
"noPresetsHint": "No se pudieron cargar los preajustes. Verifica tu conexión de red.",
"register": "Registrar",
"addPreset": "Agregar",
"added": "Agregado",
"tab": {
"providers": "Mis proveedores",
"presets": "Preajustes"
},
"form": {
"name": "Nombre",
"model": "Modelo predeterminado",
"modelHint": "Modelo usado al cambiar a este proveedor",
"models": "Modelos disponibles",
"modelsHint": "Modelos que los usuarios pueden cambiar con /model",
"agentTypes": "Tipos de agente",
"agentTypesHint": "Dejar vacío para todos los tipos de agente",
"thinkingDefault": "Predeterminado (auto)",
"perAgentHint": "Cada tipo de agente puede usar diferente Base URL / modelos. Configura por tipo abajo.",
"defaultConfig": "Predeterminado",
"baseUrl": "Base URL",
"codexWireApi": "Wire API"
},
"ccSwitch": {
"title": "Importar desde CC-Switch",
"notFound": "No se encontró la base de datos de CC-Switch. Asegúrese de que CC-Switch esté instalado.",
"empty": "No hay proveedores configurados en CC-Switch.",
"hint": "Se encontraron {{count}} proveedores en CC-Switch. Seleccione cuáles importar:",
"active": "activo",
"exists": "existente",
"import": "Importar ({{count}})",
"result": "Importación completa: {{imported}} importados, {{skipped}} omitidos."
}
},
"skills": {
"title": "Habilidades",
"subtitle": "Gestiona las habilidades del agente y descubre nuevas",
"tab": {
"local": "Locales",
"recommended": "Recomendadas"
},
"projects": "Proyectos",
"skillCount": "{{count}} habilidades",
"scanDirs": "Directorios escaneados",
"noSkills": "No se encontraron habilidades",
"noSkillsHint": "Las habilidades se cargan desde los directorios de habilidades del agente (ej. ~/.claude/skills/)",
"emptyProject": "No se encontraron habilidades en los directorios de este proyecto",
"noPresets": "No hay habilidades recomendadas",
"noPresetsHint": "No se pudieron cargar las recomendaciones. Verifica tu conexión a internet.",
"featured": "Destacadas",
"allSkills": "Todas",
"author": "Autor",
"source": "Fuente",
"download": "Descargar",
"free": "Gratis",
"freemium": "Freemium",
"paid": "De pago"
},
"theme": {
"light": "Claro",
"dark": "Oscuro",
"system": "Sistema"
}
}
+408
View File
@@ -0,0 +1,408 @@
{
"nav": {
"dashboard": "ダッシュボード",
"projects": "プロジェクト",
"providers": "プロバイダー",
"sessions": "セッション",
"chat": "チャット",
"cron": "Cron",
"bridge": "ブリッジ",
"skills": "スキル",
"system": "システム"
},
"dashboard": {
"title": "ダッシュボード",
"version": "バージョン",
"uptime": "稼働時間",
"platforms": "プラットフォーム",
"projects": "プロジェクト",
"bridgeAdapters": "ブリッジアダプター",
"noData": "データがありません",
"recentSessions": "最近のセッション"
},
"projects": {
"title": "プロジェクト",
"name": "名前",
"agent": "エージェント",
"platforms": "プラットフォーム",
"sessions": "セッション",
"heartbeat": "ハートビート",
"settings": "設定",
"quiet": "サイレントモード",
"language": "言語",
"adminFrom": "管理元",
"disabledCommands": "無効化したコマンド",
"save": "保存",
"detail": "詳細",
"noProjects": "プロジェクトが設定されていません",
"workDir": "作業ディレクトリ",
"agentType": "エージェントタイプ",
"agentTypeChangeHint": "エージェントタイプの変更には再起動が必要です。互換性のないプロバイダーは削除されます。",
"agentMode": "権限モード",
"agentSettings": "エージェント",
"generalSettings": "一般",
"showCtxIndicator": "コンテキスト表示",
"showCtxIndicatorHint": "返信の末尾に [ctx: ~N%] を表示",
"replyFooter": "返信フッター",
"replyFooterHint": "返信の末尾にモデル/使用量のメタ情報を付加",
"injectSender": "送信者の注入",
"injectSenderHint": "エージェントに送信するメッセージの前に送信者の身元情報を付加",
"platformAccess": "プラットフォームのアクセス制御",
"deleteTitle": "プロジェクトを削除",
"deleteConfirm": "プロジェクト「{{name}}」を削除してもよろしいですか?設定ファイルから削除されます。",
"dangerZone": "危険ゾーン",
"deleteHint": "このプロジェクトを設定から削除します。再起動が必要です。",
"tabs": {
"overview": "概要",
"providers": "プロバイダー",
"heartbeat": "ハートビート",
"settings": "設定"
}
},
"sessions": {
"title": "セッション",
"id": "ID",
"sessionKey": "セッションキー",
"name": "名前",
"platform": "プラットフォーム",
"active": "アクティブ",
"createdAt": "作成日時",
"history": "履歴",
"send": "送信",
"messageInput": "メッセージ",
"delete": "削除",
"noSessions": "セッションがありません",
"noMessages": "メッセージはまだありません",
"notLiveHint": "このセッションは現在アクティブではありません。エージェント実行中のみメッセージを送信できます。",
"offline": "オフライン",
"justNow": "たった今",
"allProjects": "すべてのプロジェクト",
"chat": "チャット",
"enterSession": "セッションを開く",
"bridgeConnected": "接続済み",
"bridgeConnecting": "接続中...",
"bridgeDisconnected": "未接続",
"bridgeNotAvailable": "ブリッジが利用できません。config.toml で [bridge] を有効にしてください。"
},
"providers": {
"title": "プロバイダー",
"name": "名前",
"model": "モデル",
"baseUrl": "ベース URL",
"active": "有効",
"add": "プロバイダーを追加",
"remove": "削除",
"activate": "有効化",
"setModel": "モデルを設定",
"models": "利用可能なモデル",
"global": "グローバル",
"emptyProject": "このプロジェクトにはプロバイダーが設定されていません。",
"emptyProjectHint": "グローバルプロバイダーをリンクするか、カスタムで追加してください。",
"linkGlobal": "グローバルをリンク",
"addCustom": "カスタム追加",
"allLinked": "すべてのグローバルプロバイダーがリンク済みです。",
"manageGlobal": "グローバルプロバイダーの管理"
},
"cron": {
"title": "スケジュールジョブ",
"expression": "Cron 式",
"prompt": "プロンプト",
"exec": "実行",
"description": "説明",
"enabled": "有効",
"silent": "サイレント",
"lastRun": "最終実行",
"lastError": "最後のエラー",
"add": "ジョブを追加",
"delete": "削除",
"noJobs": "スケジュールジョブがありません",
"workDir": "作業ディレクトリ",
"sessionKey": "セッションキー",
"project": "プロジェクト",
"editJob": "ジョブを編集",
"schedule": "スケジュール",
"selectProject": "プロジェクトを選択",
"descPlaceholder": "ジョブの説明",
"promptPlaceholder": "エージェントに送信するプロンプト...",
"selectSessionKey": "セッションを選択(空=デフォルト)",
"taskType": "タスク種別",
"mode": "権限モード",
"modeDefault": "プロジェクトのデフォルトを使用"
},
"heartbeat": {
"title": "ハートビート",
"status": "状態",
"interval": "間隔",
"paused": "一時停止",
"running": "実行中",
"pause": "一時停止",
"resume": "再開",
"trigger": "今すぐ実行",
"setInterval": "間隔を設定",
"runCount": "実行回数",
"errorCount": "エラー回数",
"skippedBusy": "スキップ(ビジー)",
"lastRun": "最終実行",
"notEnabled": "このプロジェクトではハートビートが設定されていません。config.toml に [heartbeat] セクションを追加して有効にしてください。"
},
"bridge": {
"title": "ブリッジ",
"platform": "プラットフォーム",
"capabilities": "機能",
"connectedAt": "接続日時",
"noAdapters": "ブリッジアダプターがありません"
},
"system": {
"title": "システム",
"config": "設定",
"logs": "ログ",
"restart": "再起動",
"reload": "設定の再読み込み",
"restartConfirm": "サービスを再起動しますか?アクティブなセッションが中断される場合があります。",
"reloadConfirm": "ディスクから設定を再読み込みしますか?",
"level": "ログレベル",
"limit": "行数の上限",
"rawConfig": "生の設定"
},
"login": {
"title": "CC-Connect 管理コンソール",
"subtitle": "CC-Connect インスタンスに接続",
"token": "API トークン",
"serverUrl": "サーバー URL",
"connect": "接続",
"invalidToken": "トークンが無効または期限切れです",
"logout": "ログアウト"
},
"common": {
"loading": "読み込み中…",
"error": "エラー",
"success": "成功",
"confirm": "確認",
"cancel": "キャンセル",
"save": "保存",
"delete": "削除",
"back": "戻る",
"refresh": "更新",
"search": "検索",
"noData": "データなし",
"actions": "操作",
"viewAll": "すべて表示",
"optional": "任意",
"confirmDelete": "本当に削除しますか?",
"close": "閉じる",
"saving": "保存中…"
},
"setup": {
"addPlatform": "プラットフォームを追加",
"choosePlatform": "接続するプラットフォームを選択:",
"scanToConnect": "QRコードをスキャンして接続",
"feishuLabel": "Feishu / Lark",
"weixinLabel": "WeChat (ilink)",
"qrDescription": "スマートフォンで QR コードをスキャンすると、{{platform}} にすばやく接続できます。",
"startQR": "QR セットアップを開始",
"generating": "QRコードを生成しています...",
"scanFeishu": "Feishu / Lark アプリを開いて QR コードをスキャンしてください",
"scanWeixin": "WeChat を開いて QR コードをスキャンしてください",
"waitingScan": "スキャン待ち...",
"scannedConfirm": "スキャンしました。スマートフォンで確認してください...",
"waitingConfirm": "確認待ち...",
"savingConfig": "設定を保存しています...",
"completed": "プラットフォームに接続しました!",
"restartHint": "新しいプラットフォームを有効にするにはサービスを再起動してください。",
"restartRequired": "再起動が必要です",
"restartNow": "今すぐ再起動",
"restarting": "サービスを再起動中...",
"restartAfterDelete": "プロジェクトを削除しました。サービスを再起動しますか?",
"later": "あとで",
"expired": "QRコードの有効期限が切れました。",
"denied": "認証が拒否されました。",
"retry": "再試行",
"addProject": "プロジェクト追加",
"projectName": "プロジェクト名",
"workDir": "作業ディレクトリ",
"agentType": "エージェントの種類",
"next": "次へ",
"manualSetup": "手動設定",
"manualHint": "{{platform}} は config.toml で認証情報を設定し、サービスを再起動してください。",
"advancedOptions": "詳細オプション",
"unsupportedPlatform": "サポートされていないプラットフォーム:{{type}}"
},
"fields": {
"botToken": "ボットトークン",
"appToken": "アプリトークン",
"accessToken": "アクセストークン",
"allowFrom": "許可ユーザー",
"allowFromHintTelegram": "Telegram ユーザー ID(カンマ区切り)",
"groupReplyAll": "すべてのグループメッセージに返信",
"sharedGroupSession": "グループセッションを共有",
"sharedChannelSession": "チャンネルセッションを共有",
"guildId": "サーバー ID",
"guildIdHint": "スラッシュコマンドの即時登録用",
"threadIsolation": "スレッド分離",
"clientId": "クライアント ID (AppKey)",
"clientSecret": "クライアントシークレット (AppSecret)",
"corpId": "企業 ID",
"corpSecret": "企業シークレット",
"agentId": "エージェント ID",
"callbackToken": "コールバックトークン",
"callbackAesKey": "コールバック AES キー",
"callbackAesKeyHint": "43文字",
"callbackPath": "コールバックパス",
"apiBaseUrl": "API ベース URL",
"port": "ポート",
"wsUrl": "WebSocket URL",
"appId": "アプリ ID",
"appSecret": "アプリシークレット",
"sandboxMode": "サンドボックスモード",
"channelSecret": "チャンネルシークレット",
"channelToken": "チャンネルアクセストークン"
},
"chat": {
"noChats": "プロジェクトがありません",
"noMessages": "メッセージがありません",
"sessions": "セッション一覧",
"emptyHint": "エージェントとの会話を始めましょう",
"slashHint": "/ を押して利用可能なコマンドを表示",
"inputPlaceholder": "メッセージを入力、または / でコマンド...",
"commands": "コマンド",
"defaultSession": "Web セッション"
},
"cmd": {
"search": "コマンドを検索...",
"groupSession": "セッション",
"groupSettings": "設定",
"groupInfo": "情報",
"groupAdvanced": "詳細",
"new": "新規セッション",
"list": "セッション一覧",
"switch": "セッション切替",
"current": "現在のセッション",
"history": "履歴",
"stop": "セッション停止",
"model": "モデル",
"reasoning": "推論モード",
"mode": "動作モード",
"lang": "言語",
"provider": "プロバイダー",
"quiet": "静音モード",
"status": "ステータス",
"help": "ヘルプ",
"doctor": "診断",
"version": "バージョン",
"whoami": "自分の情報",
"commands": "全コマンド",
"dir": "作業ディレクトリ",
"cron": "スケジュールジョブ",
"heartbeat": "ハートビート",
"alias": "エイリアス",
"config": "設定",
"skills": "スキル",
"upgrade": "アップグレード",
"deleteMode": "削除モード"
},
"settings": {
"title": "グローバル設定",
"general": "一般",
"language": "言語",
"quiet": "サイレントモード",
"quietHint": "開始/終了通知をグローバルで抑制",
"attachmentSend": "添付ファイル送信",
"attachmentSendHint": "ファイル/画像の添付ファイルをプラットフォームに返送",
"default": "デフォルト",
"idleTimeout": "アイドルタイムアウト(分)",
"idleTimeoutHint": "N分間アイドル後にエージェントを自動停止;0 = 無効",
"display": "表示",
"thinkingMessages": "思考メッセージ",
"thinkingMessagesHint": "中間思考プロセスの表示・非表示を切替",
"thinkingMaxLen": "思考の最大文字数",
"thinkingMaxLenHint": "思考メッセージの最大文字数;0 = 制限なし",
"toolMessages": "ツール進捗",
"toolMessagesHint": "ツール呼び出し進捗メッセージの表示・非表示を切替",
"toolMaxLen": "ツールの最大文字数",
"toolMaxLenHint": "ツール使用メッセージの最大文字数;0 = 制限なし",
"streamPreview": "ストリームプレビュー",
"streamPreviewEnabled": "有効化",
"streamPreviewEnabledHint": "IMでリアルタイムのストリーミング更新を表示",
"streamPreviewInterval": "間隔(ミリ秒)",
"streamPreviewIntervalHint": "プレビュー更新間の最小ミリ秒数",
"rateLimit": "レート制限",
"rlMaxMessages": "最大メッセージ数",
"rlMaxMessagesHint": "ウィンドウあたりの最大メッセージ数;0 = 無効",
"rlWindowSecs": "ウィンドウ(秒)",
"rlWindowSecsHint": "タイムウィンドウの秒数",
"log": "ログ",
"logLevel": "ログレベル"
},
"globalProviders": {
"title": "プロバイダー管理",
"subtitle": "全プロジェクトで共有できるグローバル API プロバイダーを管理",
"add": "プロバイダーを追加",
"importCCSwitch": "CC-Switch からインポート",
"edit": "プロバイダーを編集",
"empty": "プロバイダーが設定されていません",
"emptyHint": "グローバルプロバイダーを追加するか、プリセットからインポートしてください。",
"deleteHint": "プロバイダー「{{name}}」を削除しますか?参照しているプロジェクトはアクセスできなくなります。",
"noPresets": "プリセットなし",
"noPresetsHint": "プリセット一覧を読み込めませんでした。ネットワーク接続をご確認ください。",
"register": "登録",
"addPreset": "追加",
"added": "追加済み",
"tab": {
"providers": "マイプロバイダー",
"presets": "おすすめプリセット"
},
"form": {
"name": "名前",
"model": "デフォルトモデル",
"modelHint": "このプロバイダーに切り替えた時に使用するモデル",
"models": "利用可能なモデル",
"modelsHint": "/model コマンドで切り替え可能なモデル一覧",
"agentTypes": "対応エージェント",
"agentTypesHint": "空の場合はすべてのエージェントに対応",
"thinkingDefault": "デフォルト(自動)",
"perAgentHint": "エージェントごとに異なる Base URL やモデルを設定できます。",
"defaultConfig": "デフォルト",
"baseUrl": "Base URL",
"codexWireApi": "Wire API"
},
"ccSwitch": {
"title": "CC-Switch からインポート",
"notFound": "CC-Switch データベースが見つかりません。CC-Switch がインストールされているか確認してください。",
"empty": "CC-Switch にプロバイダーが設定されていません。",
"hint": "CC-Switch で {{count}} 件のプロバイダーが見つかりました。インポートするものを選択:",
"active": "アクティブ",
"exists": "既存",
"import": "インポート ({{count}})",
"result": "インポート完了:{{imported}} 件成功、{{skipped}} 件スキップ。"
}
},
"skills": {
"title": "スキル",
"subtitle": "エージェントスキルの管理とおすすめスキルの発見",
"tab": {
"local": "ローカルスキル",
"recommended": "おすすめ"
},
"projects": "プロジェクト",
"skillCount": "{{count}} 件のスキル",
"scanDirs": "スキャンディレクトリ",
"noSkills": "スキルが見つかりません",
"noSkillsHint": "スキルはエージェントのスキルディレクトリから読み込まれます(例: ~/.claude/skills/",
"emptyProject": "このプロジェクトのディレクトリにスキルがありません",
"noPresets": "おすすめスキルがありません",
"noPresetsHint": "スキル一覧の読み込みに失敗しました。ネットワーク接続を確認してください。",
"featured": "注目",
"allSkills": "すべてのスキル",
"author": "作者",
"source": "提供元",
"download": "ダウンロード",
"free": "無料",
"freemium": "フリーミアム",
"paid": "有料"
},
"theme": {
"light": "ライト",
"dark": "ダーク",
"system": "システムに合わせる"
}
}
+408
View File
@@ -0,0 +1,408 @@
{
"nav": {
"dashboard": "總覽",
"projects": "專案",
"providers": "服務商",
"sessions": "工作階段",
"chat": "對話",
"cron": "排程工作",
"bridge": "橋接",
"skills": "技能",
"system": "系統"
},
"dashboard": {
"title": "總覽",
"version": "版本",
"uptime": "運作時間",
"platforms": "平台",
"projects": "專案",
"bridgeAdapters": "橋接介接器",
"noData": "尚無資料",
"recentSessions": "最近會話"
},
"projects": {
"title": "專案",
"name": "名稱",
"agent": "智慧代理",
"platforms": "平台",
"sessions": "工作階段",
"heartbeat": "心跳",
"settings": "設定",
"quiet": "靜音模式",
"language": "語言",
"adminFrom": "管理來源",
"disabledCommands": "已停用指令",
"save": "儲存",
"detail": "詳細資料",
"noProjects": "尚未設定專案",
"workDir": "工作目錄",
"agentType": "Agent 類型",
"agentTypeChangeHint": "切換 Agent 類型需要重新啟動,不相容的服務商將被移除。",
"agentMode": "權限模式",
"agentSettings": "Agent 設定",
"generalSettings": "通用設定",
"showCtxIndicator": "上下文指示",
"showCtxIndicatorHint": "在回覆末尾顯示 [ctx: ~N%]",
"replyFooter": "回覆尾部資訊",
"replyFooterHint": "在回覆末尾附加模型/用量元資訊",
"injectSender": "注入發送者",
"injectSenderHint": "在發送給 Agent 的訊息前附加發送者身份資訊",
"platformAccess": "平台存取控制",
"deleteTitle": "刪除專案",
"deleteConfirm": "確定要刪除專案「{{name}}」嗎?這將從設定檔中移除該專案。",
"dangerZone": "危險操作",
"deleteHint": "從設定檔中移除此專案,需要重新啟動服務。",
"tabs": {
"overview": "總覽",
"providers": "供應商",
"heartbeat": "心跳",
"settings": "設定"
}
},
"sessions": {
"title": "工作階段",
"id": "編號",
"sessionKey": "工作階段金鑰",
"name": "名稱",
"platform": "平台",
"active": "使用中",
"createdAt": "建立時間",
"history": "歷程",
"send": "傳送",
"messageInput": "訊息",
"delete": "刪除",
"noSessions": "尚無工作階段",
"noMessages": "暫無訊息",
"notLiveHint": "此工作階段目前未活躍,僅在 Agent 執行時才能傳送訊息。",
"offline": "離線",
"justNow": "剛剛",
"allProjects": "全部專案",
"chat": "對話",
"enterSession": "進入工作階段",
"bridgeConnected": "已連線",
"bridgeConnecting": "連線中...",
"bridgeDisconnected": "未連線",
"bridgeNotAvailable": "Bridge 未啟用。請在 config.toml 中啟用 [bridge] 以支援網頁聊天。"
},
"providers": {
"title": "模型供應商",
"name": "名稱",
"model": "模型",
"baseUrl": "基礎 URL",
"active": "使用中",
"add": "新增供應商",
"remove": "移除",
"activate": "啟用",
"setModel": "設定模型",
"models": "可用模型",
"global": "全域",
"emptyProject": "此專案尚未設定服務商。",
"emptyProjectHint": "關聯全域服務商或新增自訂服務商。",
"linkGlobal": "關聯全域服務商",
"addCustom": "自訂新增",
"allLinked": "所有全域服務商已關聯。",
"manageGlobal": "管理全域服務商"
},
"cron": {
"title": "排程工作",
"expression": "Cron 運算式",
"prompt": "提示詞",
"exec": "執行",
"description": "說明",
"enabled": "已啟用",
"silent": "靜音",
"lastRun": "上次執行",
"lastError": "上次錯誤",
"add": "新增工作",
"delete": "刪除",
"noJobs": "尚無排程工作",
"workDir": "工作目錄",
"sessionKey": "工作階段金鑰",
"project": "專案",
"editJob": "編輯工作",
"schedule": "執行時間",
"selectProject": "選擇專案",
"descPlaceholder": "工作說明",
"promptPlaceholder": "傳送給 Agent 的提示詞...",
"selectSessionKey": "選擇工作階段(留空使用預設)",
"taskType": "任務類型",
"mode": "權限模式",
"modeDefault": "跟隨項目預設"
},
"heartbeat": {
"title": "心跳",
"status": "狀態",
"interval": "間隔",
"paused": "已暫停",
"running": "執行中",
"pause": "暫停",
"resume": "繼續",
"trigger": "立即執行",
"setInterval": "設定間隔",
"runCount": "執行次數",
"errorCount": "錯誤次數",
"skippedBusy": "略過(忙碌)",
"lastRun": "上次執行",
"notEnabled": "該專案未配置心跳功能。請在 config.toml 中添加 [heartbeat] 配置段以啟用。"
},
"bridge": {
"title": "橋接",
"platform": "平台",
"capabilities": "能力",
"connectedAt": "連線時間",
"noAdapters": "尚無橋接介接器"
},
"system": {
"title": "系統",
"config": "設定",
"logs": "記錄",
"restart": "重新啟動",
"reload": "重新載入設定",
"restartConfirm": "確定要重新啟動服務嗎?進行中的工作階段可能會中斷。",
"reloadConfirm": "從磁碟重新載入設定?",
"level": "記錄層級",
"limit": "筆數上限",
"rawConfig": "原始設定"
},
"login": {
"title": "CC-Connect 管理後台",
"subtitle": "連線至您的 CC-Connect 執行個體",
"token": "API 權杖",
"serverUrl": "伺服器位址",
"connect": "連線",
"invalidToken": "權杖無效或已過期",
"logout": "登出"
},
"common": {
"loading": "載入中…",
"error": "錯誤",
"success": "成功",
"confirm": "確認",
"cancel": "取消",
"save": "儲存",
"delete": "刪除",
"back": "返回",
"refresh": "重新整理",
"search": "搜尋",
"noData": "無資料",
"actions": "操作",
"viewAll": "檢視全部",
"optional": "選填",
"confirmDelete": "確定要刪除嗎?",
"close": "關閉",
"saving": "儲存中…"
},
"setup": {
"addPlatform": "新增平台",
"choosePlatform": "選擇要連接的平台:",
"scanToConnect": "掃描 QR 碼以連接",
"feishuLabel": "Feishu / Lark",
"weixinLabel": "WeChat (ilink)",
"qrDescription": "用手機掃描 QR 碼,快速連接 {{platform}}。",
"startQR": "開始 QR 設定",
"generating": "正在產生 QR 碼...",
"scanFeishu": "開啟飛書 / Lark App 並掃描 QR 碼",
"scanWeixin": "開啟微信並掃描 QR 碼",
"waitingScan": "等待掃描...",
"scannedConfirm": "已掃描!請在手機上確認...",
"waitingConfirm": "等待確認...",
"savingConfig": "正在儲存設定...",
"completed": "平台連接成功!",
"restartHint": "請重新啟動服務後,新平台才會生效。",
"restartRequired": "需要重新啟動",
"restartNow": "立即重新啟動",
"restarting": "正在重新啟動服務...",
"restartAfterDelete": "專案已刪除,是否重新啟動服務使其生效?",
"later": "稍後",
"expired": "QR 碼已過期。",
"denied": "授權被拒絕。",
"retry": "重試",
"addProject": "新增專案",
"projectName": "專案名稱",
"workDir": "工作目錄",
"agentType": "Agent 類型",
"next": "下一步",
"manualSetup": "手動設定",
"manualHint": "{{platform}} 需要在 config.toml 中手動設定憑證,然後重新啟動服務。",
"advancedOptions": "進階選項",
"unsupportedPlatform": "不支援的平台類型:{{type}}"
},
"fields": {
"botToken": "機器人 Token",
"appToken": "App Token",
"accessToken": "存取權杖",
"allowFrom": "允許的使用者",
"allowFromHintTelegram": "Telegram 使用者 ID,以逗號分隔",
"groupReplyAll": "回覆所有群組訊息",
"sharedGroupSession": "共享群組工作階段",
"sharedChannelSession": "共享頻道工作階段",
"guildId": "伺服器 ID",
"guildIdHint": "用於即時註冊斜線命令",
"threadIsolation": "討論串隔離",
"clientId": "Client ID (AppKey)",
"clientSecret": "Client Secret (AppSecret)",
"corpId": "企業 ID",
"corpSecret": "企業密鑰",
"agentId": "應用 ID",
"callbackToken": "回呼 Token",
"callbackAesKey": "回呼 AES Key",
"callbackAesKeyHint": "43 個字元",
"callbackPath": "回呼路徑",
"apiBaseUrl": "API 基礎網址",
"port": "連接埠",
"wsUrl": "WebSocket 網址",
"appId": "App ID",
"appSecret": "App Secret",
"sandboxMode": "沙盒模式",
"channelSecret": "Channel Secret",
"channelToken": "Channel Access Token"
},
"chat": {
"noChats": "尚無專案",
"noMessages": "尚無訊息",
"sessions": "工作階段列表",
"emptyHint": "開始與 Agent 對話",
"slashHint": "按 / 查看可用命令",
"inputPlaceholder": "輸入訊息或按 / 使用命令...",
"commands": "命令",
"defaultSession": "Web 對話"
},
"cmd": {
"search": "搜尋命令...",
"groupSession": "工作階段",
"groupSettings": "設定",
"groupInfo": "資訊",
"groupAdvanced": "進階",
"new": "新建工作階段",
"list": "工作階段列表",
"switch": "切換工作階段",
"current": "目前工作階段",
"history": "歷史記錄",
"stop": "停止工作階段",
"model": "模型",
"reasoning": "推理模式",
"mode": "運行模式",
"lang": "語言",
"provider": "提供方",
"quiet": "靜音模式",
"status": "狀態",
"help": "說明",
"doctor": "診斷",
"version": "版本",
"whoami": "我是誰",
"commands": "所有命令",
"dir": "工作目錄",
"cron": "排程工作",
"heartbeat": "心跳",
"alias": "別名",
"config": "設定",
"skills": "技能",
"upgrade": "升級",
"deleteMode": "刪除模式"
},
"settings": {
"title": "全域設定",
"general": "通用",
"language": "語言",
"quiet": "靜音模式",
"quietHint": "全域關閉開始/結束通知",
"attachmentSend": "附件回傳",
"attachmentSendHint": "將檔案/圖片附件回傳至平台",
"default": "預設",
"idleTimeout": "閒置逾時(分鐘)",
"idleTimeoutHint": "Agent 閒置 N 分鐘後自動停止;0 = 不啟用",
"display": "顯示",
"thinkingMessages": "思考訊息",
"thinkingMessagesHint": "顯示或隱藏中間思考過程訊息",
"thinkingMaxLen": "思考最大長度",
"thinkingMaxLenHint": "思考訊息的最大字元數;0 = 不截斷",
"toolMessages": "工具進度",
"toolMessagesHint": "顯示或隱藏工具呼叫進度訊息",
"toolMaxLen": "工具最大長度",
"toolMaxLenHint": "工具使用訊息的最大字元數;0 = 不截斷",
"streamPreview": "串流預覽",
"streamPreviewEnabled": "啟用",
"streamPreviewEnabledHint": "在 IM 中顯示即時串流更新",
"streamPreviewInterval": "間隔(毫秒)",
"streamPreviewIntervalHint": "預覽更新之間的最小毫秒數",
"rateLimit": "頻率限制",
"rlMaxMessages": "最大訊息數",
"rlMaxMessagesHint": "每個視窗內的最大訊息數;0 = 不限制",
"rlWindowSecs": "視窗時間(秒)",
"rlWindowSecsHint": "時間視窗的秒數",
"log": "記錄",
"logLevel": "記錄層級"
},
"globalProviders": {
"title": "服務商管理",
"subtitle": "管理全域共用的 API 服務商,可被所有專案引用",
"add": "新增服務商",
"importCCSwitch": "從 CC-Switch 匯入",
"edit": "編輯服務商",
"empty": "尚未配置服務商",
"emptyHint": "新增全域服務商或從預設清單中匯入。",
"deleteHint": "移除服務商「{{name}}」?引用該服務商的專案將失去存取權限。",
"noPresets": "暫無預設",
"noPresetsHint": "無法載入服務商預設清單,請確認網路連線。",
"register": "註冊",
"addPreset": "新增",
"added": "已新增",
"tab": {
"providers": "我的服務商",
"presets": "推薦預設"
},
"form": {
"name": "名稱",
"model": "預設模型",
"modelHint": "切換到該服務商時使用的模型",
"models": "可用模型",
"modelsHint": "使用者可透過 /model 指令切換的模型列表",
"agentTypes": "適用 Agent 類型",
"agentTypesHint": "留空表示適用所有 Agent 類型",
"thinkingDefault": "預設(自動)",
"perAgentHint": "不同 Agent 可能使用不同的 Base URL / 模型,可按 Agent 類型分別設定。",
"defaultConfig": "預設",
"baseUrl": "Base URL",
"codexWireApi": "Wire API"
},
"ccSwitch": {
"title": "從 CC-Switch 匯入",
"notFound": "未找到 CC-Switch 資料庫,請確認已安裝 CC-Switch。",
"empty": "CC-Switch 中沒有已設定的服務商。",
"hint": "在 CC-Switch 中找到 {{count}} 個服務商,選擇要匯入的:",
"active": "目前",
"exists": "已存在",
"import": "匯入 ({{count}})",
"result": "匯入完成:成功 {{imported}} 個,跳過 {{skipped}} 個。"
}
},
"skills": {
"title": "技能",
"subtitle": "管理 Agent 技能,探索更多推薦技能",
"tab": {
"local": "本地技能",
"recommended": "推薦"
},
"projects": "專案",
"skillCount": "{{count}} 個技能",
"scanDirs": "掃描目錄",
"noSkills": "未發現技能",
"noSkillsHint": "技能從 Agent 技能目錄載入(如 ~/.claude/skills/",
"emptyProject": "此專案的技能目錄中未發現技能",
"noPresets": "暫無推薦技能",
"noPresetsHint": "無法載入推薦技能列表,請檢查網路連線。",
"featured": "精選",
"allSkills": "全部技能",
"author": "作者",
"source": "來源",
"download": "下載",
"free": "免費",
"freemium": "免費增值",
"paid": "付費"
},
"theme": {
"light": "淺色",
"dark": "深色",
"system": "跟隨系統"
}
}
+408
View File
@@ -0,0 +1,408 @@
{
"nav": {
"dashboard": "概览",
"projects": "项目",
"providers": "服务商",
"sessions": "会话",
"chat": "对话",
"cron": "定时任务",
"bridge": "桥接",
"skills": "技能",
"system": "系统"
},
"dashboard": {
"title": "概览",
"version": "版本",
"uptime": "运行时间",
"platforms": "平台",
"projects": "项目",
"bridgeAdapters": "桥接适配器",
"noData": "暂无数据",
"recentSessions": "最近会话"
},
"projects": {
"title": "项目",
"name": "名称",
"agent": "智能体",
"platforms": "平台",
"sessions": "会话",
"heartbeat": "心跳",
"settings": "设置",
"quiet": "静默模式",
"language": "语言",
"adminFrom": "管理来源",
"disabledCommands": "已禁用命令",
"save": "保存",
"detail": "详情",
"noProjects": "尚未配置项目",
"workDir": "工作目录",
"agentType": "Agent 类型",
"agentTypeChangeHint": "切换 Agent 类型需要重启,不兼容的服务商将被移除。",
"agentMode": "权限模式",
"agentSettings": "Agent 配置",
"generalSettings": "通用设置",
"showCtxIndicator": "上下文指示",
"showCtxIndicatorHint": "在回复末尾显示 [ctx: ~N%]",
"replyFooter": "回复尾部信息",
"replyFooterHint": "在回复末尾附加模型/用量元信息",
"injectSender": "注入发送者",
"injectSenderHint": "在发送给 Agent 的消息前附加发送者身份信息",
"platformAccess": "平台访问控制",
"deleteTitle": "删除项目",
"deleteConfirm": "确定要删除项目「{{name}}」吗?这将从配置文件中移除该项目。",
"dangerZone": "危险操作",
"deleteHint": "从配置文件中移除此项目,需要重启服务。",
"tabs": {
"overview": "概览",
"providers": "服务商",
"heartbeat": "心跳",
"settings": "设置"
}
},
"sessions": {
"title": "会话",
"id": "编号",
"sessionKey": "会话键",
"name": "名称",
"platform": "平台",
"active": "活跃",
"createdAt": "创建时间",
"history": "历史",
"send": "发送",
"messageInput": "消息",
"delete": "删除",
"noSessions": "暂无会话",
"noMessages": "暂无消息",
"notLiveHint": "此会话当前未活跃,仅在 Agent 运行时才能发送消息。",
"offline": "离线",
"justNow": "刚刚",
"allProjects": "全部项目",
"chat": "对话",
"enterSession": "进入会话",
"bridgeConnected": "已连接",
"bridgeConnecting": "连接中...",
"bridgeDisconnected": "未连接",
"bridgeNotAvailable": "Bridge 未启用。请在 config.toml 中启用 [bridge] 以支持网页聊天。"
},
"providers": {
"title": "模型提供方",
"name": "名称",
"model": "模型",
"baseUrl": "基础 URL",
"active": "当前使用",
"add": "添加提供方",
"remove": "移除",
"activate": "启用",
"setModel": "设置模型",
"models": "可用模型",
"global": "全局",
"emptyProject": "该项目尚未配置服务商。",
"emptyProjectHint": "关联全局服务商或添加自定义服务商。",
"linkGlobal": "关联全局服务商",
"addCustom": "自定义添加",
"allLinked": "所有全局服务商已关联。",
"manageGlobal": "管理全局服务商"
},
"cron": {
"title": "定时任务",
"expression": "Cron 表达式",
"prompt": "提示词",
"exec": "执行",
"description": "描述",
"enabled": "已启用",
"silent": "静默",
"lastRun": "上次运行",
"lastError": "上次错误",
"add": "添加任务",
"delete": "删除",
"noJobs": "暂无定时任务",
"workDir": "工作目录",
"sessionKey": "会话键",
"project": "项目",
"editJob": "编辑任务",
"schedule": "执行时间",
"selectProject": "选择项目",
"descPlaceholder": "任务描述",
"promptPlaceholder": "发送给 Agent 的提示词...",
"selectSessionKey": "选择会话(留空使用默认)",
"taskType": "任务类型",
"mode": "权限模式",
"modeDefault": "跟随项目默认"
},
"heartbeat": {
"title": "心跳",
"status": "状态",
"interval": "间隔",
"paused": "已暂停",
"running": "运行中",
"pause": "暂停",
"resume": "恢复",
"trigger": "立即执行",
"setInterval": "设置间隔",
"runCount": "执行次数",
"errorCount": "错误次数",
"skippedBusy": "跳过(忙碌)",
"lastRun": "上次运行",
"notEnabled": "该项目未配置心跳功能。请在 config.toml 中添加 [heartbeat] 配置段以启用。"
},
"bridge": {
"title": "桥接",
"platform": "平台",
"capabilities": "能力",
"connectedAt": "连接时间",
"noAdapters": "暂无桥接适配器"
},
"system": {
"title": "系统",
"config": "配置",
"logs": "日志",
"restart": "重启",
"reload": "重载配置",
"restartConfirm": "确定要重启服务吗?进行中的会话可能会中断。",
"reloadConfirm": "从磁盘重新加载配置?",
"level": "日志级别",
"limit": "行数限制",
"rawConfig": "原始配置"
},
"login": {
"title": "CC-Connect 管理后台",
"subtitle": "连接到您的 CC-Connect 实例",
"token": "API 令牌",
"serverUrl": "服务器地址",
"connect": "连接",
"invalidToken": "令牌无效或已过期",
"logout": "退出登录"
},
"common": {
"loading": "加载中…",
"error": "错误",
"success": "成功",
"confirm": "确认",
"cancel": "取消",
"save": "保存",
"delete": "删除",
"back": "返回",
"refresh": "刷新",
"search": "搜索",
"noData": "无数据",
"actions": "操作",
"viewAll": "查看全部",
"optional": "可选",
"confirmDelete": "确定要删除吗?",
"close": "关闭",
"saving": "保存中…"
},
"setup": {
"addPlatform": "添加平台",
"choosePlatform": "选择要连接的平台:",
"scanToConnect": "扫描二维码以连接",
"feishuLabel": "Feishu / Lark",
"weixinLabel": "WeChat (ilink)",
"qrDescription": "使用手机扫描二维码,快速连接 {{platform}}。",
"startQR": "开始二维码设置",
"generating": "正在生成二维码...",
"scanFeishu": "打开飞书 / Lark 应用并扫描二维码",
"scanWeixin": "打开微信并扫描二维码",
"waitingScan": "等待扫描...",
"scannedConfirm": "已扫描!请在手机上确认...",
"waitingConfirm": "等待确认...",
"savingConfig": "正在保存配置...",
"completed": "平台连接成功!",
"restartHint": "重启服务后新平台才会生效。",
"restartRequired": "需要重启",
"restartNow": "立即重启",
"restarting": "正在重启服务...",
"restartAfterDelete": "项目已删除,是否重启服务使其生效?",
"later": "稍后",
"expired": "二维码已过期。",
"denied": "授权被拒绝。",
"retry": "重试",
"addProject": "新增项目",
"projectName": "项目名称",
"workDir": "工作目录",
"agentType": "Agent 类型",
"next": "下一步",
"manualSetup": "手动配置",
"manualHint": "{{platform}} 需要在 config.toml 中手动配置凭证,然后重启服务。",
"advancedOptions": "高级选项",
"unsupportedPlatform": "不支持的平台类型:{{type}}"
},
"fields": {
"botToken": "机器人 Token",
"appToken": "App Token",
"accessToken": "访问令牌",
"allowFrom": "允许的用户",
"allowFromHintTelegram": "Telegram 用户 ID,逗号分隔",
"groupReplyAll": "回复所有群消息",
"sharedGroupSession": "共享群会话",
"sharedChannelSession": "共享频道会话",
"guildId": "服务器 ID",
"guildIdHint": "用于即时注册斜杠命令",
"threadIsolation": "帖子隔离",
"clientId": "Client ID (AppKey)",
"clientSecret": "Client Secret (AppSecret)",
"corpId": "企业 ID",
"corpSecret": "企业密钥",
"agentId": "应用 ID",
"callbackToken": "回调 Token",
"callbackAesKey": "回调 AES Key",
"callbackAesKeyHint": "43 个字符",
"callbackPath": "回调路径",
"apiBaseUrl": "API 基础地址",
"port": "端口",
"wsUrl": "WebSocket 地址",
"appId": "App ID",
"appSecret": "App Secret",
"sandboxMode": "沙箱模式",
"channelSecret": "Channel Secret",
"channelToken": "Channel Access Token"
},
"chat": {
"noChats": "暂无项目",
"noMessages": "暂无消息",
"sessions": "会话列表",
"emptyHint": "开始和你的 Agent 对话",
"slashHint": "按 / 查看可用命令",
"inputPlaceholder": "输入消息或按 / 使用命令...",
"commands": "命令",
"defaultSession": "Web 会话"
},
"cmd": {
"search": "搜索命令...",
"groupSession": "会话",
"groupSettings": "设置",
"groupInfo": "信息",
"groupAdvanced": "高级",
"new": "新建会话",
"list": "会话列表",
"switch": "切换会话",
"current": "当前会话",
"history": "历史记录",
"stop": "停止会话",
"model": "模型",
"reasoning": "推理模式",
"mode": "运行模式",
"lang": "语言",
"provider": "提供方",
"quiet": "静默模式",
"status": "状态",
"help": "帮助",
"doctor": "诊断",
"version": "版本",
"whoami": "我是谁",
"commands": "所有命令",
"dir": "工作目录",
"cron": "定时任务",
"heartbeat": "心跳",
"alias": "别名",
"config": "配置",
"skills": "技能",
"upgrade": "升级",
"deleteMode": "删除模式"
},
"settings": {
"title": "全局设置",
"general": "通用",
"language": "语言",
"quiet": "静默模式",
"quietHint": "全局关闭开始/结束通知",
"attachmentSend": "附件回传",
"attachmentSendHint": "将文件/图片附件回传到平台",
"default": "默认",
"idleTimeout": "空闲超时(分钟)",
"idleTimeoutHint": "Agent 空闲 N 分钟后自动停止;0 = 不启用",
"display": "显示",
"thinkingMessages": "思考消息",
"thinkingMessagesHint": "显示或隐藏中间思考过程消息",
"thinkingMaxLen": "思考最大长度",
"thinkingMaxLenHint": "思考消息的最大字符数;0 = 不截断",
"toolMessages": "工具进度",
"toolMessagesHint": "显示或隐藏工具调用进度消息",
"toolMaxLen": "工具最大长度",
"toolMaxLenHint": "工具调用消息的最大字符数;0 = 不截断",
"streamPreview": "流式预览",
"streamPreviewEnabled": "启用",
"streamPreviewEnabledHint": "在 IM 中显示实时流式更新",
"streamPreviewInterval": "间隔(毫秒)",
"streamPreviewIntervalHint": "预览更新之间的最小毫秒数",
"rateLimit": "频率限制",
"rlMaxMessages": "最大消息数",
"rlMaxMessagesHint": "每个窗口内的最大消息数;0 = 不限制",
"rlWindowSecs": "窗口时间(秒)",
"rlWindowSecsHint": "时间窗口的秒数",
"log": "日志",
"logLevel": "日志级别"
},
"globalProviders": {
"title": "服务商管理",
"subtitle": "管理全局共享的 API 服务商,可被所有项目引用",
"add": "添加服务商",
"importCCSwitch": "从 CC-Switch 导入",
"edit": "编辑服务商",
"empty": "尚未配置服务商",
"emptyHint": "添加全局服务商或从预设列表中导入。",
"deleteHint": "移除服务商「{{name}}」?引用该服务商的项目将失去访问权限。",
"noPresets": "暂无预设",
"noPresetsHint": "无法加载服务商预设列表,请检查网络连接。",
"register": "注册",
"addPreset": "添加",
"added": "已添加",
"tab": {
"providers": "我的服务商",
"presets": "推荐预设"
},
"form": {
"name": "名称",
"model": "默认模型",
"modelHint": "切换到该服务商时使用的模型。点击下方模型的 ✓ 可设为默认。",
"models": "可用模型",
"modelsHint": "用户可通过 /model 切换的模型列表。点击 ✓ 设为默认。",
"agentTypes": "适用 Agent 类型",
"agentTypesHint": "留空表示适用所有 Agent 类型",
"thinkingDefault": "默认(自动)",
"perAgentHint": "不同 Agent 可能使用不同的 Base URL / 模型,可按 Agent 类型分别配置。",
"defaultConfig": "默认",
"baseUrl": "Base URL",
"codexWireApi": "Wire API"
},
"ccSwitch": {
"title": "从 CC-Switch 导入",
"notFound": "未找到 CC-Switch 数据库,请确认已安装 CC-Switch。",
"empty": "CC-Switch 中没有已配置的服务商。",
"hint": "在 CC-Switch 中找到 {{count}} 个服务商,选择要导入的:",
"active": "当前",
"exists": "已存在",
"import": "导入 ({{count}})",
"result": "导入完成:成功 {{imported}} 个,跳过 {{skipped}} 个。"
}
},
"skills": {
"title": "技能",
"subtitle": "管理 Agent 技能,发现更多推荐技能",
"tab": {
"local": "本地技能",
"recommended": "推荐"
},
"projects": "项目",
"skillCount": "{{count}} 个技能",
"scanDirs": "扫描目录",
"noSkills": "未发现技能",
"noSkillsHint": "技能从 Agent 技能目录加载(如 ~/.claude/skills/",
"emptyProject": "此项目的技能目录中未发现技能",
"noPresets": "暂无推荐技能",
"noPresetsHint": "无法加载推荐技能列表,请检查网络连接。",
"featured": "精选",
"allSkills": "全部技能",
"author": "作者",
"source": "来源",
"download": "下载",
"free": "免费",
"freemium": "免费增值",
"paid": "付费"
},
"theme": {
"light": "浅色",
"dark": "深色",
"system": "跟随系统"
}
}
+108
View File
@@ -0,0 +1,108 @@
@import 'highlight.js/styles/github.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--color-accent: 22 163 74;
--color-accent-dim: 21 128 61;
}
.dark {
--color-accent: 66 255 156;
--color-accent-dim: 43 200 122;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
pre code.hljs {
background: transparent;
padding: 0;
}
/* Dark mode: override highlight.js colors for github-dark feel */
.dark .hljs {
color: #e6edf3;
}
.dark .hljs-keyword,
.dark .hljs-selector-tag {
color: #ff7b72;
}
.dark .hljs-string,
.dark .hljs-attr {
color: #a5d6ff;
}
.dark .hljs-comment,
.dark .hljs-quote {
color: #8b949e;
font-style: italic;
}
.dark .hljs-number,
.dark .hljs-literal {
color: #79c0ff;
}
.dark .hljs-title,
.dark .hljs-title\.class_,
.dark .hljs-title\.function_ {
color: #d2a8ff;
}
.dark .hljs-built_in {
color: #ffa657;
}
.dark .hljs-type,
.dark .hljs-params {
color: #ffa657;
}
.dark .hljs-variable,
.dark .hljs-template-variable {
color: #ffa657;
}
.dark .hljs-addition {
color: #aff5b4;
background-color: rgba(46, 160, 67, 0.15);
}
.dark .hljs-deletion {
color: #ffdcd7;
background-color: rgba(248, 81, 73, 0.15);
}
.dark .hljs-meta {
color: #79c0ff;
}
.dark .hljs-selector-class,
.dark .hljs-selector-id {
color: #7ee787;
}
/* Responsive font scaling for larger screens */
html {
font-size: 16px;
}
@media (min-width: 1536px) {
html { font-size: 17px; }
}
@media (min-width: 1920px) {
html { font-size: 18px; }
}
@media (min-width: 2560px) {
html { font-size: 20px; }
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(128, 128, 128, 0.5);
}
+106
View File
@@ -0,0 +1,106 @@
export interface FieldDef {
key: string;
labelKey: string;
required?: boolean;
type?: 'text' | 'password' | 'number' | 'boolean';
placeholder?: string;
hintKey?: string;
group?: 'basic' | 'advanced';
}
export interface PlatformMeta {
label: string;
fields: FieldDef[];
}
export const platformMeta: Record<string, PlatformMeta> = {
telegram: {
label: 'Telegram',
fields: [
{ key: 'token', labelKey: 'fields.botToken', required: true, type: 'password', placeholder: '123456:ABC-DEF...' },
{ key: 'allow_from', labelKey: 'fields.allowFrom', placeholder: '* (all)', group: 'advanced', hintKey: 'fields.allowFromHintTelegram' },
{ key: 'group_reply_all', labelKey: 'fields.groupReplyAll', type: 'boolean', group: 'advanced' },
{ key: 'share_session_in_channel', labelKey: 'fields.sharedGroupSession', type: 'boolean', group: 'advanced' },
],
},
discord: {
label: 'Discord',
fields: [
{ key: 'token', labelKey: 'fields.botToken', required: true, type: 'password' },
{ key: 'allow_from', labelKey: 'fields.allowFrom', placeholder: '* (all)', group: 'advanced' },
{ key: 'guild_id', labelKey: 'fields.guildId', placeholder: '', group: 'advanced', hintKey: 'fields.guildIdHint' },
{ key: 'group_reply_all', labelKey: 'fields.groupReplyAll', type: 'boolean', group: 'advanced' },
{ key: 'share_session_in_channel', labelKey: 'fields.sharedChannelSession', type: 'boolean', group: 'advanced' },
{ key: 'thread_isolation', labelKey: 'fields.threadIsolation', type: 'boolean', group: 'advanced' },
],
},
slack: {
label: 'Slack',
fields: [
{ key: 'bot_token', labelKey: 'fields.botToken', required: true, type: 'password', placeholder: 'xoxb-...' },
{ key: 'app_token', labelKey: 'fields.appToken', required: true, type: 'password', placeholder: 'xapp-...' },
{ key: 'allow_from', labelKey: 'fields.allowFrom', placeholder: '* (all)', group: 'advanced' },
{ key: 'share_session_in_channel', labelKey: 'fields.sharedChannelSession', type: 'boolean', group: 'advanced' },
],
},
dingtalk: {
label: 'DingTalk',
fields: [
{ key: 'client_id', labelKey: 'fields.clientId', required: true },
{ key: 'client_secret', labelKey: 'fields.clientSecret', required: true, type: 'password' },
{ key: 'allow_from', labelKey: 'fields.allowFrom', placeholder: '* (all)', group: 'advanced' },
{ key: 'share_session_in_channel', labelKey: 'fields.sharedGroupSession', type: 'boolean', group: 'advanced' },
],
},
wecom: {
label: 'WeChat Work',
fields: [
{ key: 'corp_id', labelKey: 'fields.corpId', required: true },
{ key: 'corp_secret', labelKey: 'fields.corpSecret', required: true, type: 'password' },
{ key: 'agent_id', labelKey: 'fields.agentId', required: true, placeholder: '1000002' },
{ key: 'callback_token', labelKey: 'fields.callbackToken', required: true },
{ key: 'callback_aes_key', labelKey: 'fields.callbackAesKey', required: true, hintKey: 'fields.callbackAesKeyHint' },
{ key: 'port', labelKey: 'fields.port', required: true, placeholder: '8081' },
{ key: 'callback_path', labelKey: 'fields.callbackPath', placeholder: '/wecom/callback', group: 'advanced' },
{ key: 'api_base_url', labelKey: 'fields.apiBaseUrl', placeholder: 'https://qyapi.weixin.qq.com', group: 'advanced' },
{ key: 'allow_from', labelKey: 'fields.allowFrom', placeholder: '* (all)', group: 'advanced' },
],
},
qq: {
label: 'QQ (OneBot v11)',
fields: [
{ key: 'ws_url', labelKey: 'fields.wsUrl', required: true, placeholder: 'ws://127.0.0.1:3001' },
{ key: 'token', labelKey: 'fields.accessToken', type: 'password', group: 'advanced' },
{ key: 'allow_from', labelKey: 'fields.allowFrom', placeholder: '* (all)', group: 'advanced' },
{ key: 'share_session_in_channel', labelKey: 'fields.sharedGroupSession', type: 'boolean', group: 'advanced' },
],
},
qqbot: {
label: 'QQ Bot (Official)',
fields: [
{ key: 'app_id', labelKey: 'fields.appId', required: true },
{ key: 'app_secret', labelKey: 'fields.appSecret', required: true, type: 'password' },
{ key: 'sandbox', labelKey: 'fields.sandboxMode', type: 'boolean', group: 'advanced' },
{ key: 'allow_from', labelKey: 'fields.allowFrom', placeholder: '* (all)', group: 'advanced' },
{ key: 'share_session_in_channel', labelKey: 'fields.sharedGroupSession', type: 'boolean', group: 'advanced' },
],
},
line: {
label: 'LINE',
fields: [
{ key: 'channel_secret', labelKey: 'fields.channelSecret', required: true, type: 'password' },
{ key: 'channel_token', labelKey: 'fields.channelToken', required: true, type: 'password' },
{ key: 'port', labelKey: 'fields.port', required: true, placeholder: '8080' },
{ key: 'callback_path', labelKey: 'fields.callbackPath', placeholder: '/callback', group: 'advanced' },
{ key: 'allow_from', labelKey: 'fields.allowFrom', placeholder: '* (all)', group: 'advanced' },
],
},
weibo: {
label: 'Weibo (微博)',
fields: [
{ key: 'app_id', labelKey: 'fields.appId', required: true, placeholder: '1234567890' },
{ key: 'app_secret', labelKey: 'fields.appSecret', required: true, type: 'password' },
{ key: 'allow_from', labelKey: 'fields.allowFrom', placeholder: '* (all)', group: 'advanced' },
],
},
};
+23
View File
@@ -0,0 +1,23 @@
import { clsx, type ClassValue } from 'clsx';
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}
export function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
export function formatTime(iso: string): string {
if (!iso) return '-';
return new Date(iso).toLocaleString();
}
export function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max) + '...' : s;
}
+24
View File
@@ -0,0 +1,24 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
import './i18n';
import { useAuthStore } from './store/auth';
import { useThemeStore } from './store/theme';
import { api } from './api/client';
useAuthStore.getState().init();
useThemeStore.getState().init();
api.setOnUnauthorized(() => {
useAuthStore.getState().logout();
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Cable, Wifi } from 'lucide-react';
import { Card, Badge, EmptyState } from '@/components/ui';
import { listBridgeAdapters, type BridgeAdapter } from '@/api/bridge';
import { formatTime } from '@/lib/utils';
export default function BridgeAdapters() {
const { t } = useTranslation();
const [adapters, setAdapters] = useState<BridgeAdapter[]>([]);
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const data = await listBridgeAdapters();
setAdapters(data.adapters || []);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
const handler = () => fetchData();
window.addEventListener('cc:refresh', handler);
return () => window.removeEventListener('cc:refresh', handler);
}, [fetchData]);
if (loading && adapters.length === 0) {
return <div className="flex items-center justify-center h-64 text-gray-400 animate-pulse">Loading...</div>;
}
if (adapters.length === 0) {
return <EmptyState message={t('bridge.noAdapters')} icon={Cable} />;
}
return (
<div className="space-y-4 animate-fade-in">
{adapters.map((a, i) => (
<Card key={i}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<Wifi size={20} className="text-blue-500" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">{a.platform}</span>
<Badge variant="info">{a.project}</Badge>
</div>
<div className="flex flex-wrap gap-1 mt-1">
{a.capabilities?.map((c) => <Badge key={c} variant="default">{c}</Badge>)}
</div>
</div>
</div>
<span className="text-xs text-gray-400">{formatTime(a.connected_at)}</span>
</div>
</Card>
))}
</div>
);
}
+136
View File
@@ -0,0 +1,136 @@
import { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { MessageSquare, Bot, User, Circle, ArrowRight } from 'lucide-react';
import { Card, EmptyState, Badge } from '@/components/ui';
import { listProjects, type ProjectSummary } from '@/api/projects';
import { listSessions, type Session } from '@/api/sessions';
interface ChatEntry {
project: ProjectSummary;
latestSession: Session | null;
}
function timeAgo(iso: string, t: (k: string) => string): string {
if (!iso) return '';
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return t('sessions.justNow');
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
export default function ChatList() {
const { t } = useTranslation();
const [entries, setEntries] = useState<ChatEntry[]>([]);
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const { projects: projs } = await listProjects();
if (!projs?.length) {
setEntries([]);
return;
}
const results = await Promise.all(
projs.map(async (p) => {
try {
const { sessions } = await listSessions(p.name);
const sorted = (sessions || []).sort(
(a, b) => (b.updated_at || b.created_at || '').localeCompare(a.updated_at || a.created_at || ''),
);
return { project: p, latestSession: sorted[0] || null };
} catch {
return { project: p, latestSession: null };
}
}),
);
results.sort((a, b) => {
const ta = a.latestSession?.updated_at || a.latestSession?.created_at || '';
const tb = b.latestSession?.updated_at || b.latestSession?.created_at || '';
return tb.localeCompare(ta);
});
setEntries(results);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
const handler = () => fetchData();
window.addEventListener('cc:refresh', handler);
return () => window.removeEventListener('cc:refresh', handler);
}, [fetchData]);
if (loading && entries.length === 0) {
return <div className="flex items-center justify-center h-64 text-gray-400 animate-pulse">Loading...</div>;
}
return (
<div className="animate-fade-in space-y-4 ">
<h2 className="text-lg font-bold text-gray-900 dark:text-white">{t('nav.chat')}</h2>
{entries.length === 0 ? (
<EmptyState message={t('chat.noChats')} icon={MessageSquare} />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{entries.map(({ project, latestSession }) => {
const hasLive = latestSession?.live;
const lastMsg = latestSession?.last_message;
const ts = latestSession?.updated_at || latestSession?.created_at || '';
return (
<Link key={project.name} to={`/chat/${project.name}`}>
<Card hover className="h-full flex flex-col">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<MessageSquare size={18} className="text-accent" />
<h3 className="font-semibold text-gray-900 dark:text-white">{project.name}</h3>
{hasLive && <Circle size={6} className="fill-emerald-500 text-emerald-500" />}
</div>
<ArrowRight size={16} className="text-gray-300 dark:text-gray-600" />
</div>
<div className="flex-1 min-h-[2rem] mb-3">
{lastMsg ? (
<p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 leading-relaxed">
{lastMsg.role === 'user' ? (
<User size={10} className="inline mr-1 -mt-0.5 opacity-60" />
) : (
<Bot size={10} className="inline mr-1 -mt-0.5 opacity-60" />
)}
{lastMsg.content.replace(/\n/g, ' ').slice(0, 120)}
</p>
) : (
<p className="text-xs text-gray-400 dark:text-gray-500 italic">
{t('chat.noMessages')}
</p>
)}
</div>
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mt-auto pt-3 border-t border-gray-100 dark:border-gray-800">
<div className="flex items-center gap-1.5">
<Badge className="text-[9px]">{project.agent_type}</Badge>
{project.platforms?.slice(0, 2).map((pl) => <Badge key={pl}>{pl}</Badge>)}
{(project.platforms?.length ?? 0) > 2 && (
<Badge>+{project.platforms!.length - 2}</Badge>
)}
</div>
<div className="flex items-center gap-2">
<span>{project.sessions_count} {t('chat.sessions', 'sessions')}</span>
{ts && <span className="text-gray-400">{timeAgo(ts, t)}</span>}
</div>
</div>
</Card>
</Link>
);
})}
</div>
)}
</div>
);
}
+762
View File
@@ -0,0 +1,762 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams, Link } from 'react-router-dom';
import {
ArrowLeft, Send, User, Bot, Circle, WifiOff,
Copy, Check, FileText, Image as ImageIcon, Loader2,
Slash, ChevronDown,
} from 'lucide-react';
import { Badge, Button } from '@/components/ui';
import { listSessions, getSession, type Session, type SessionDetail } from '@/api/sessions';
import {
useBridgeSocket, fetchBridgeConfig,
type BridgeConfig, type BridgeIncoming, type BridgeStatus,
} from '@/hooks/useBridgeSocket';
import CommandPalette, { type SlashCommand, slashCommands } from './CommandPalette';
import SessionDrawer from './SessionDrawer';
import CommandResultPanel, { type CommandResult } from './CommandResultPanel';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import { cn } from '@/lib/utils';
// ── Markdown renderers ───────────────────────────────────────
function CopyButton({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-md bg-gray-200/80 dark:bg-gray-700/80 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-500 dark:text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity z-10"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
);
}
function PreBlock({ children, ...props }: React.HTMLAttributes<HTMLPreElement>) {
const codeEl = (children as any)?.props;
const lang = codeEl?.className?.replace(/^language-/, '') || '';
const code = typeof codeEl?.children === 'string' ? codeEl.children.replace(/\n$/, '') : '';
return (
<div className="not-prose relative group my-4">
{lang && (
<div className="absolute top-0 left-0 px-2.5 py-1 text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800 rounded-tl-lg rounded-br-lg border-b border-r border-gray-200 dark:border-gray-700 font-mono">
{lang}
</div>
)}
<CopyButton code={code} />
<pre className="overflow-x-auto rounded-lg bg-[#fafafa] dark:bg-[#0d1117] border border-gray-200 dark:border-gray-700/60 p-4 pt-8 text-[13px] leading-[1.6] font-mono" {...props}>
{children}
</pre>
</div>
);
}
function InlineCode({ children, className, ...props }: React.HTMLAttributes<HTMLElement>) {
if (className) return <code className={className} {...props}>{children}</code>;
return (
<code className="px-1.5 py-0.5 rounded-md bg-gray-100 dark:bg-gray-800 text-pink-600 dark:text-pink-400 text-[0.875em] font-mono border border-gray-200/60 dark:border-gray-700/40" {...props}>
{children}
</code>
);
}
function RenderMarkdown({ content }: { content: string }) {
return (
<div className={cn(
'prose max-w-none dark:prose-invert',
'prose-headings:font-semibold prose-headings:tracking-tight',
'prose-h1:text-xl prose-h1:mt-5 prose-h1:mb-3 prose-h1:pb-1.5 prose-h1:border-b prose-h1:border-gray-200 dark:prose-h1:border-gray-700',
'prose-h2:text-lg prose-h2:mt-5 prose-h2:mb-2',
'prose-h3:text-base prose-h3:mt-4 prose-h3:mb-2',
'prose-p:my-2.5 prose-p:leading-relaxed',
'prose-li:my-0.5', 'prose-ul:my-2 prose-ol:my-2',
'prose-a:text-accent prose-a:no-underline hover:prose-a:underline',
'prose-strong:text-gray-900 dark:prose-strong:text-white prose-strong:font-semibold',
'prose-blockquote:border-l-[3px] prose-blockquote:border-accent/40 prose-blockquote:bg-accent/[0.03] prose-blockquote:rounded-r-lg prose-blockquote:py-0.5 prose-blockquote:px-4 prose-blockquote:my-3 prose-blockquote:not-italic prose-blockquote:text-gray-600 dark:prose-blockquote:text-gray-300',
'prose-hr:my-5 prose-hr:border-gray-200 dark:prose-hr:border-gray-700',
'prose-table:text-sm prose-th:bg-gray-50 dark:prose-th:bg-gray-800 prose-th:px-3 prose-th:py-2 prose-td:px-3 prose-td:py-2',
'prose-img:rounded-lg prose-img:shadow-sm',
)}>
<Markdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]} components={{ pre: PreBlock as any, code: InlineCode as any }}>
{content}
</Markdown>
</div>
);
}
// ── Chat message types ───────────────────────────────────────
interface ChatMsg {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
format?: 'text' | 'markdown' | 'card' | 'buttons' | 'image' | 'file';
card?: any;
buttons?: { text: string; data: string }[][];
imageUrl?: string;
fileName?: string;
fileSize?: number;
streaming?: boolean;
timestamp?: string;
}
// ── Helpers ──────────────────────────────────────────────────
function parseListItemText(text: string): { cmd: string; desc: string } {
const m = text.match(/^\*\*(.+?)\*\*\s*(.*)/);
if (m) return { cmd: m[1], desc: m[2] };
const sp = text.indexOf(' ');
if (sp > 0) return { cmd: text.slice(0, sp), desc: text.slice(sp + 1) };
return { cmd: text, desc: '' };
}
function InlineMd({ text }: { text: string }) {
const parts = text.split(/(\*\*[^*]+\*\*)/g);
return (
<>
{parts.map((p, i) =>
p.startsWith('**') && p.endsWith('**')
? <strong key={i} className="font-semibold text-gray-900 dark:text-white">{p.slice(2, -2)}</strong>
: <span key={i}>{p}</span>
)}
</>
);
}
// ── Card renderer (flat, clean style for in-stream cards) ────
function CardBlock({ card, onAction }: { card: any; onAction: (v: string) => void }) {
if (!card) return null;
return (
<div className="space-y-3">
{card.header?.title && (
<div className="text-sm font-semibold text-gray-900 dark:text-white">{card.header.title}</div>
)}
{card.elements?.map((el: any, i: number) => (
<CardElement key={i} el={el} onAction={onAction} />
))}
</div>
);
}
function CardElement({ el, onAction }: { el: any; onAction: (v: string) => void }) {
if (el.type === 'markdown') return <RenderMarkdown content={el.content} />;
if (el.type === 'divider') return <div className="border-t border-gray-200/60 dark:border-gray-700/40" />;
if (el.type === 'note') return <p className="text-[11px] text-gray-400 dark:text-gray-500">{el.text}</p>;
if (el.type === 'actions') {
return (
<div className="flex flex-wrap gap-2">
{el.buttons?.map((btn: any, j: number) => (
<button key={j} onClick={() => onAction(btn.value)} className={cn(
'px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-150',
btn.btn_type === 'primary' ? 'bg-accent text-black hover:bg-accent-dim shadow-sm' :
btn.btn_type === 'danger' ? 'bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20' :
'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700',
)}>
{btn.text}
</button>
))}
</div>
);
}
if (el.type === 'list_item') {
const parsed = parseListItemText(el.text);
const isCommand = parsed.cmd.startsWith('/');
return (
<button
onClick={() => onAction(el.btn_value)}
className="w-full flex items-center gap-3 py-2 text-left group"
>
{isCommand ? (
<>
<code className="shrink-0 w-20 text-xs font-mono font-medium text-accent">{parsed.cmd}</code>
<span className="flex-1 text-sm text-gray-500 dark:text-gray-400 truncate">{parsed.desc}</span>
</>
) : (
<span className="flex-1 text-sm text-gray-700 dark:text-gray-300 truncate min-w-0">
<InlineMd text={el.text} />
</span>
)}
<span className={cn(
'shrink-0 px-2 py-0.5 rounded-md text-[11px] font-medium transition-all',
el.btn_type === 'primary'
? 'bg-accent/15 text-accent group-hover:bg-accent/25'
: 'text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800 group-hover:bg-accent/15 group-hover:text-accent',
)}>
{el.btn_text}
</span>
</button>
);
}
if (el.type === 'select') {
return (
<select
defaultValue={el.init_value}
onChange={(e) => onAction(e.target.value)}
className="w-full px-3 py-2 text-sm rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/80 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-accent/40"
>
{el.options?.map((opt: any, j: number) => (
<option key={j} value={opt.value}>{opt.text}</option>
))}
</select>
);
}
return null;
}
function ButtonsBlock({ content, buttons, onAction }: { content: string; buttons: { text: string; data: string }[][]; onAction: (v: string) => void }) {
return (
<div className="space-y-3">
<RenderMarkdown content={content} />
{buttons.map((row, i) => (
<div key={i} className="flex flex-wrap gap-2">
{row.map((btn, j) => (
<button key={j} onClick={() => onAction(btn.data)} className="px-3 py-1.5 rounded-lg text-xs font-medium bg-accent text-black hover:bg-accent-dim transition-colors">
{btn.text}
</button>
))}
</div>
))}
</div>
);
}
function FileBlock({ name, size }: { name: string; size?: number }) {
return (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<FileText size={16} className="text-gray-400 shrink-0" />
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{name}</div>
{size !== undefined && <div className="text-xs text-gray-400">{(size / 1024).toFixed(1)} KB</div>}
</div>
</div>
);
}
function ImageBlock({ url }: { url: string }) {
return <img src={url} alt="" className="max-w-sm rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm" />;
}
function StatusBadge({ status }: { status: BridgeStatus }) {
const { t } = useTranslation();
if (status === 'connected') {
return (
<span className="flex items-center gap-1 text-[10px] text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 px-1.5 py-0.5 rounded-full">
<Circle size={5} className="fill-current" /> {t('sessions.bridgeConnected')}
</span>
);
}
if (status === 'connecting' || status === 'registering') {
return (
<span className="flex items-center gap-1 text-[10px] text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 px-1.5 py-0.5 rounded-full">
<Loader2 size={9} className="animate-spin" /> {t('sessions.bridgeConnecting')}
</span>
);
}
return (
<span className="flex items-center gap-1 text-[10px] text-gray-400 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded-full">
<WifiOff size={9} /> {t('sessions.bridgeDisconnected')}
</span>
);
}
function MsgCopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
onClick={handleCopy}
className="absolute -bottom-3 right-2 p-1 rounded-md bg-gray-100/90 dark:bg-gray-700/90 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 opacity-0 group-hover/msg:opacity-100 transition-opacity shadow-sm"
title="Copy"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
);
}
// ── Main component ───────────────────────────────────────────
export default function ChatView() {
const { t } = useTranslation();
const { name: projectName } = useParams<{ name: string }>();
// Session state
const [sessions, setSessions] = useState<Session[]>([]);
const [currentSession, setCurrentSession] = useState<SessionDetail | null>(null);
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [loading, setLoading] = useState(true);
const [typing, setTyping] = useState(false);
const [bridgeCfg, setBridgeCfg] = useState<BridgeConfig | null>(null);
// Whether the user explicitly picked a session from the drawer
const [userPickedSession, setUserPickedSession] = useState(false);
// UI state
const [cmdOpen, setCmdOpen] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [cmdResult, setCmdResult] = useState<CommandResult | null>(null);
const messagesEnd = useRef<HTMLDivElement>(null);
const previewHandleCounter = useRef(0);
const cmdBtnRef = useRef<HTMLButtonElement>(null);
const sessionKeyRef = useRef('');
// Track pending slash command so the next reply can be routed to the panel
const pendingCmdRef = useRef<string | null>(null);
// Mirrors cmdResult.command so card-action callbacks can route follow-ups back to the panel
const cmdPanelRef = useRef<string | null>(null);
// Web platform uses its own per-project session key by default.
// Only use the original session's key when the user explicitly switches via the drawer.
const webSessionKey = projectName ? `bridge:web-admin:${projectName}` : '';
const sessionKey = userPickedSession && currentSession?.session_key
? currentSession.session_key
: webSessionKey;
sessionKeyRef.current = sessionKey;
// Load project sessions and auto-select latest
const fetchData = useCallback(async () => {
if (!projectName) return;
setLoading(true);
try {
const [{ sessions: allSessions }, cfg] = await Promise.all([
listSessions(projectName),
fetchBridgeConfig(),
]);
setBridgeCfg(cfg);
const sorted = (allSessions || []).sort(
(a, b) => (b.updated_at || b.created_at || '').localeCompare(a.updated_at || a.created_at || ''),
);
setSessions(sorted);
if (sorted.length > 0) {
const latest = sorted[0];
const detail = await getSession(projectName, latest.id, 200);
setCurrentSession(detail);
if (detail.history) {
setMessages(detail.history.map((h, i) => ({
id: `hist-${i}`,
role: h.role as 'user' | 'assistant',
content: h.content,
format: 'markdown',
timestamp: h.timestamp,
})));
}
} else {
setCurrentSession(null);
setMessages([]);
}
} finally {
setLoading(false);
}
}, [projectName]);
useEffect(() => { fetchData(); }, [fetchData]);
// Keep ref in sync with cmdResult so callbacks avoid stale closures
useEffect(() => {
cmdPanelRef.current = cmdResult?.command ?? null;
}, [cmdResult]);
// Switch to a different session (user explicitly chose from drawer)
const switchToSession = useCallback(async (s: Session) => {
if (!projectName) return;
setDrawerOpen(false);
setLoading(true);
setUserPickedSession(true);
try {
const detail = await getSession(projectName, s.id, 200);
setCurrentSession(detail);
if (detail.history) {
setMessages(detail.history.map((h, i) => ({
id: `hist-${i}`,
role: h.role as 'user' | 'assistant',
content: h.content,
format: 'markdown',
timestamp: h.timestamp,
})));
} else {
setMessages([]);
}
} finally {
setLoading(false);
}
}, [projectName]);
// Handle bridge incoming messages — only process messages for the current session
const handleBridgeMessage = useCallback((msg: BridgeIncoming) => {
const msgKey = (msg as any).session_key;
if (msgKey && sessionKeyRef.current && msgKey !== sessionKeyRef.current) {
return;
}
// If a slash command is pending, route the first reply/card to the panel
const pending = pendingCmdRef.current;
if (pending && (msg.type === 'reply' || msg.type === 'card' || msg.type === 'buttons')) {
pendingCmdRef.current = null;
if (msg.type === 'card') {
const card = msg as Extract<BridgeIncoming, { type: 'card' }>;
setCmdResult({ command: pending, content: '', format: 'card', card: card.card });
} else if (msg.type === 'buttons') {
const btns = msg as Extract<BridgeIncoming, { type: 'buttons' }>;
setCmdResult({ command: pending, content: btns.content, format: 'buttons', buttons: btns.buttons });
} else {
const reply = msg as Extract<BridgeIncoming, { type: 'reply' }>;
setCmdResult({ command: pending, content: reply.content, format: 'markdown' });
}
setTyping(false);
return;
}
if (msg.type === 'reply') {
setMessages(prev => {
const streamIdx = prev.findIndex(m => m.streaming && m.role === 'assistant');
if (streamIdx >= 0) {
const updated = [...prev];
updated[streamIdx] = { ...updated[streamIdx], content: msg.content, format: (msg as any).format === 'markdown' ? 'markdown' : 'text', streaming: false };
return updated;
}
return [...prev, { id: `reply-${Date.now()}`, role: 'assistant', content: msg.content, format: (msg as any).format === 'markdown' ? 'markdown' : 'text' }];
});
setTyping(false);
} else if (msg.type === 'reply_stream') {
const stream = msg as Extract<BridgeIncoming, { type: 'reply_stream' }>;
if (stream.done) {
setMessages(prev => {
const idx = prev.findIndex(m => m.streaming);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...updated[idx], content: stream.full_text, streaming: false };
return updated;
}
return [...prev, { id: `stream-done-${Date.now()}`, role: 'assistant', content: stream.full_text, format: 'markdown' }];
});
setTyping(false);
} else {
setMessages(prev => {
const idx = prev.findIndex(m => m.streaming);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...updated[idx], content: stream.full_text };
return updated;
}
return [...prev, { id: `stream-${Date.now()}`, role: 'assistant', content: stream.full_text, format: 'markdown', streaming: true }];
});
}
} else if (msg.type === 'card') {
const card = msg as Extract<BridgeIncoming, { type: 'card' }>;
setMessages(prev => [...prev, { id: `card-${Date.now()}`, role: 'assistant', content: '', format: 'card', card: card.card }]);
setTyping(false);
} else if (msg.type === 'buttons') {
const btns = msg as Extract<BridgeIncoming, { type: 'buttons' }>;
setMessages(prev => [...prev, { id: `btn-${Date.now()}`, role: 'assistant', content: btns.content, format: 'buttons', buttons: btns.buttons }]);
setTyping(false);
} else if (msg.type === 'typing_start') {
setTyping(true);
} else if (msg.type === 'typing_stop') {
setTyping(false);
} else if (msg.type === 'preview_start') {
const ps = msg as Extract<BridgeIncoming, { type: 'preview_start' }>;
const handle = `web-preview-${++previewHandleCounter.current}`;
sendPreviewAck(ps.ref_id, handle);
setMessages(prev => [...prev, { id: `stream-${handle}`, role: 'assistant', content: ps.content, format: 'markdown', streaming: true }]);
} else if (msg.type === 'update_message') {
const um = msg as Extract<BridgeIncoming, { type: 'update_message' }>;
setMessages(prev => {
const idx = prev.findIndex(m => m.streaming);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...updated[idx], content: um.content };
return updated;
}
return prev;
});
} else if (msg.type === 'delete_message') {
setMessages(prev => {
const idx = prev.findIndex(m => m.streaming);
if (idx >= 0) return prev.filter((_, i) => i !== idx);
return prev;
});
}
}, []);
const { status: bridgeStatus, sendMessage: bridgeSend, sendCardAction, sendPreviewAck } = useBridgeSocket({
bridgeCfg,
sessionKey,
projectName: projectName || '',
onMessage: handleBridgeMessage,
});
// Scroll to bottom on new messages
useEffect(() => {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, typing]);
// Send message
const handleSend = useCallback(() => {
if (!input.trim() || bridgeStatus !== 'connected') return;
const content = input.trim();
setInput('');
setSending(true);
const cmdToken = content.split(' ')[0];
const isKnownCmd = knownCommands.has(cmdToken);
if (isKnownCmd && !chatCommands.has(cmdToken)) {
pendingCmdRef.current = cmdToken;
} else {
setMessages(prev => [...prev, { id: `user-${Date.now()}`, role: 'user', content }]);
}
bridgeSend(content);
setTimeout(() => setSending(false), 300);
}, [input, bridgeStatus, bridgeSend]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
if (e.key === '/' && !input) {
e.preventDefault();
setCmdOpen(true);
}
};
// Commands whose result should go to the message stream (they change state)
const chatCommands = new Set(['/new', '/stop', '/switch', '/delete-mode', '/upgrade']);
const knownCommands = new Set(slashCommands.map(c => c.cmd));
const handleCmdSelect = useCallback((cmd: SlashCommand) => {
setCmdOpen(false);
if (bridgeStatus !== 'connected') return;
if (chatCommands.has(cmd.cmd)) {
setMessages(prev => [...prev, { id: `user-${Date.now()}`, role: 'user', content: cmd.cmd }]);
} else {
pendingCmdRef.current = cmd.cmd;
}
bridgeSend(cmd.cmd);
}, [bridgeStatus, bridgeSend]);
const handleCardAction = useCallback((value: string) => {
if (bridgeStatus !== 'connected') return;
// If the command panel is showing, route the follow-up response back to it
if (cmdPanelRef.current) {
pendingCmdRef.current = cmdPanelRef.current;
}
sendCardAction(value);
}, [bridgeStatus, sendCardAction]);
const handleNewSession = useCallback(() => {
if (bridgeStatus !== 'connected') return;
setUserPickedSession(false);
setMessages(prev => [...prev, { id: `user-${Date.now()}`, role: 'user', content: '/new' }]);
bridgeSend('/new');
setDrawerOpen(false);
}, [bridgeStatus, bridgeSend]);
const canSend = bridgeStatus === 'connected';
if (loading && !currentSession && sessions.length === 0) {
return <div className="flex items-center justify-center h-64 text-gray-400 animate-pulse">Loading...</div>;
}
return (
<div className="flex flex-col h-[calc(100vh-8rem)] animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between pb-3 border-b border-gray-200 dark:border-gray-800 shrink-0">
<div className="flex items-center gap-3">
<Link to="/chat" className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
<ArrowLeft size={18} className="text-gray-400" />
</Link>
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{projectName}</h2>
<StatusBadge status={bridgeStatus} />
</div>
<button
type="button"
onClick={() => setDrawerOpen(true)}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-accent transition-colors mt-0.5"
>
<span>{userPickedSession && currentSession
? (currentSession.name || currentSession.id.slice(0, 8))
: t('chat.defaultSession')}</span>
<ChevronDown size={12} />
</button>
</div>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto py-6 space-y-5">
{messages.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center h-full text-center py-12">
<div className="w-16 h-16 rounded-2xl bg-accent/10 flex items-center justify-center mb-4">
<Bot size={32} className="text-accent" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">{t('chat.emptyHint')}</p>
<p className="text-xs text-gray-400 dark:text-gray-500">{t('chat.slashHint')}</p>
</div>
)}
{messages.map((msg) => {
const isUser = msg.role === 'user';
const isEmpty = !msg.content && !msg.card && !msg.buttons && !msg.imageUrl && !msg.fileName;
return (
<div key={msg.id} className={cn('flex gap-3', isUser ? 'justify-end' : 'justify-start')}>
{!isUser && (
<div className="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 mt-1">
<Bot size={16} className="text-accent" />
</div>
)}
<div className={cn(
'group/msg relative rounded-2xl px-5 py-3.5 text-sm',
isUser
? 'max-w-[70%] bg-accent text-black rounded-br-md'
: 'max-w-[85%] bg-white dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700/60 text-gray-900 dark:text-gray-100 rounded-bl-md shadow-sm',
msg.streaming && 'animate-pulse-subtle',
)}>
{isEmpty ? (
<p className="text-xs text-gray-400 dark:text-gray-500 italic">{t('chat.unsupportedMessage', '[Unsupported message]')}</p>
) : msg.format === 'card' ? (
<CardBlock card={msg.card} onAction={handleCardAction} />
) : msg.format === 'buttons' && msg.buttons ? (
<ButtonsBlock content={msg.content} buttons={msg.buttons} onAction={handleCardAction} />
) : msg.format === 'image' && msg.imageUrl ? (
<ImageBlock url={msg.imageUrl} />
) : msg.format === 'file' && msg.fileName ? (
<FileBlock name={msg.fileName} size={msg.fileSize} />
) : isUser ? (
<div className="whitespace-pre-wrap">{msg.content}</div>
) : (
<RenderMarkdown content={msg.content} />
)}
{msg.streaming && (
<span className="inline-block w-1.5 h-4 bg-accent/60 rounded-sm ml-0.5 animate-pulse" />
)}
{!isUser && !msg.streaming && msg.content && (
<MsgCopyButton text={msg.content} />
)}
</div>
{isUser && (
<div className="w-8 h-8 rounded-lg bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0 mt-1">
<User size={16} className="text-gray-500" />
</div>
)}
</div>
);
})}
{typing && !messages.some(m => m.streaming) && (
<div className="flex gap-3 justify-start">
<div className="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 mt-1">
<Bot size={16} className="text-accent" />
</div>
<div className="rounded-2xl px-5 py-3.5 text-sm bg-white dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700/60 rounded-bl-md shadow-sm">
<div className="flex gap-1.5">
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
</div>
)}
<div ref={messagesEnd} />
</div>
{/* Input area */}
<div className="border-t border-gray-200 dark:border-gray-800 pt-3 shrink-0">
{canSend ? (
<div className="relative flex items-end gap-2">
{/* Command palette trigger */}
<div className="relative">
<button
ref={cmdBtnRef}
type="button"
onClick={() => setCmdOpen(!cmdOpen)}
className={cn(
'p-3 rounded-xl transition-all duration-200',
cmdOpen
? 'bg-accent/15 text-accent ring-1 ring-accent/30'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/[0.06]',
)}
title={t('chat.commands')}
>
<Slash size={18} />
</button>
<CommandPalette
open={cmdOpen}
onClose={() => setCmdOpen(false)}
onSelect={handleCmdSelect}
anchorRef={cmdBtnRef}
/>
</div>
{/* Text input */}
<div className="flex-1 relative">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('chat.inputPlaceholder')}
className="w-full px-4 py-3 text-sm rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent transition-colors placeholder:text-gray-400"
disabled={sending}
/>
</div>
{/* Send button */}
<button
type="button"
onClick={handleSend}
disabled={sending || !input.trim()}
className="p-3 rounded-xl bg-accent text-black hover:bg-accent-dim transition-colors disabled:opacity-50 flex items-center"
>
{sending ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button>
</div>
) : !bridgeCfg ? (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-xl">
<WifiOff size={14} />
<span>{t('sessions.bridgeNotAvailable')}</span>
</div>
) : bridgeStatus === 'disconnected' || bridgeStatus === 'error' ? (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-xl">
<WifiOff size={14} />
<span>{t('sessions.bridgeDisconnected')}</span>
</div>
) : (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded-xl">
<Loader2 size={14} className="animate-spin" />
<span>{t('sessions.bridgeConnecting')}</span>
</div>
)}
</div>
{/* Session drawer */}
<SessionDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
sessions={sessions}
currentSessionId={currentSession?.id || ''}
onSelect={switchToSession}
onNewSession={handleNewSession}
/>
{/* Command result panel */}
<CommandResultPanel
result={cmdResult}
onClose={() => setCmdResult(null)}
onCardAction={handleCardAction}
/>
</div>
);
}
+185
View File
@@ -0,0 +1,185 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Slash, Search, MessageSquarePlus, List, ArrowRightLeft, Eye, History,
Square, Brain, Cpu, Languages, Layers, Activity, Stethoscope, Info,
Settings, Timer, HeartPulse, Terminal, Tag, Wrench, Upload, Trash2,
FolderOpen, HelpCircle, User, BookOpen,
} from 'lucide-react';
import { cn } from '@/lib/utils';
export interface SlashCommand {
cmd: string;
labelKey: string;
icon: React.ElementType;
group: 'session' | 'settings' | 'info' | 'advanced';
local?: boolean; // handled locally, not sent to bridge
}
export const slashCommands: SlashCommand[] = [
// Session
{ cmd: '/new', labelKey: 'cmd.new', icon: MessageSquarePlus, group: 'session' },
{ cmd: '/list', labelKey: 'cmd.list', icon: List, group: 'session' },
{ cmd: '/switch', labelKey: 'cmd.switch', icon: ArrowRightLeft, group: 'session' },
{ cmd: '/current', labelKey: 'cmd.current', icon: Eye, group: 'session' },
{ cmd: '/history', labelKey: 'cmd.history', icon: History, group: 'session' },
{ cmd: '/stop', labelKey: 'cmd.stop', icon: Square, group: 'session' },
// Settings
{ cmd: '/model', labelKey: 'cmd.model', icon: Brain, group: 'settings' },
{ cmd: '/reasoning', labelKey: 'cmd.reasoning', icon: Cpu, group: 'settings' },
{ cmd: '/mode', labelKey: 'cmd.mode', icon: Layers, group: 'settings' },
{ cmd: '/lang', labelKey: 'cmd.lang', icon: Languages, group: 'settings' },
{ cmd: '/provider', labelKey: 'cmd.provider', icon: Activity, group: 'settings' },
// Info
{ cmd: '/status', labelKey: 'cmd.status', icon: Info, group: 'info' },
{ cmd: '/help', labelKey: 'cmd.help', icon: HelpCircle, group: 'info' },
{ cmd: '/doctor', labelKey: 'cmd.doctor', icon: Stethoscope, group: 'info' },
{ cmd: '/version', labelKey: 'cmd.version', icon: Tag, group: 'info' },
{ cmd: '/whoami', labelKey: 'cmd.whoami', icon: User, group: 'info' },
{ cmd: '/commands', labelKey: 'cmd.commands', icon: Terminal, group: 'info' },
// Advanced
{ cmd: '/dir', labelKey: 'cmd.dir', icon: FolderOpen, group: 'advanced' },
{ cmd: '/cron', labelKey: 'cmd.cron', icon: Timer, group: 'advanced' },
{ cmd: '/heartbeat', labelKey: 'cmd.heartbeat', icon: HeartPulse, group: 'advanced' },
{ cmd: '/alias', labelKey: 'cmd.alias', icon: Tag, group: 'advanced' },
{ cmd: '/config', labelKey: 'cmd.config', icon: Settings, group: 'advanced' },
{ cmd: '/skills', labelKey: 'cmd.skills', icon: BookOpen, group: 'advanced' },
{ cmd: '/upgrade', labelKey: 'cmd.upgrade', icon: Upload, group: 'advanced' },
{ cmd: '/delete-mode', labelKey: 'cmd.deleteMode', icon: Trash2, group: 'advanced' },
];
const groupOrder: { key: string; labelKey: string }[] = [
{ key: 'session', labelKey: 'cmd.groupSession' },
{ key: 'settings', labelKey: 'cmd.groupSettings' },
{ key: 'info', labelKey: 'cmd.groupInfo' },
{ key: 'advanced', labelKey: 'cmd.groupAdvanced' },
];
interface Props {
open: boolean;
onClose: () => void;
onSelect: (cmd: SlashCommand) => void;
anchorRef: React.RefObject<HTMLElement | null>;
}
export default function CommandPalette({ open, onClose, onSelect, anchorRef }: Props) {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [activeIdx, setActiveIdx] = useState(0);
const panelRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const filtered = useMemo(() => {
if (!query) return slashCommands;
const q = query.toLowerCase().replace(/^\//, '');
return slashCommands.filter(
(c) => c.cmd.toLowerCase().includes(q) || t(c.labelKey).toLowerCase().includes(q),
);
}, [query, t]);
useEffect(() => {
if (open) {
setQuery('');
setActiveIdx(0);
setTimeout(() => searchRef.current?.focus(), 50);
}
}, [open]);
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as Node) &&
anchorRef.current && !anchorRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open, onClose, anchorRef]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIdx((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIdx((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter' && filtered[activeIdx]) {
e.preventDefault();
onSelect(filtered[activeIdx]);
} else if (e.key === 'Escape') {
onClose();
}
};
if (!open) return null;
const grouped = groupOrder
.map((g) => ({
...g,
items: filtered.filter((c) => c.group === g.key),
}))
.filter((g) => g.items.length > 0);
let flatIdx = 0;
return (
<div
ref={panelRef}
className={cn(
'absolute bottom-full left-0 mb-2 w-80 max-h-[420px] flex flex-col rounded-xl overflow-hidden z-50',
'bg-white/95 backdrop-blur-xl border border-gray-200/80 shadow-2xl shadow-black/15',
'dark:bg-[rgba(20,20,20,0.96)] dark:border-white/[0.1] dark:shadow-black/50',
'animate-in slide-in-from-bottom-2 fade-in duration-200',
)}
>
<div className="px-3 pt-3 pb-2">
<div className="flex items-center gap-2 px-2.5 py-2 rounded-lg bg-gray-100/80 dark:bg-white/[0.06] border border-gray-200/50 dark:border-white/[0.05]">
<Search size={14} className="text-gray-400 shrink-0" />
<input
ref={searchRef}
value={query}
onChange={(e) => { setQuery(e.target.value); setActiveIdx(0); }}
onKeyDown={handleKeyDown}
placeholder={t('cmd.search', 'Search commands...')}
className="flex-1 bg-transparent text-sm text-gray-900 dark:text-white placeholder:text-gray-400 outline-none"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-1.5 pb-2">
{grouped.map((g) => (
<div key={g.key} className="mb-1">
<div className="px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{t(g.labelKey)}
</div>
{g.items.map((cmd) => {
const idx = flatIdx++;
const Icon = cmd.icon;
return (
<button
key={cmd.cmd}
type="button"
onClick={() => onSelect(cmd)}
onMouseEnter={() => setActiveIdx(idx)}
className={cn(
'w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm transition-colors',
idx === activeIdx
? 'bg-accent/15 text-gray-900 dark:text-white'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100/80 dark:hover:bg-white/[0.06]',
)}
>
<Icon size={15} className={idx === activeIdx ? 'text-accent' : 'text-gray-400'} />
<span className="font-mono text-xs text-gray-500 dark:text-gray-400 w-24 text-left">{cmd.cmd}</span>
<span className="flex-1 text-left truncate">{t(cmd.labelKey)}</span>
</button>
);
})}
</div>
))}
{filtered.length === 0 && (
<div className="text-center text-sm text-gray-400 py-6">{t('common.noData')}</div>
)}
</div>
</div>
);
}
+233
View File
@@ -0,0 +1,233 @@
import { useTranslation } from 'react-i18next';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { slashCommands } from './CommandPalette';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
interface CommandResult {
command: string;
content: string;
format: 'text' | 'markdown' | 'card' | 'buttons';
card?: any;
buttons?: { text: string; data: string }[][];
}
interface Props {
result: CommandResult | null;
onClose: () => void;
onCardAction?: (value: string) => void;
}
/** Parse "**command** description" into { cmd, desc }. */
function parseListItemText(text: string): { cmd: string; desc: string } {
const m = text.match(/^\*\*(.+?)\*\*\s*(.*)/);
if (m) return { cmd: m[1], desc: m[2] };
const sp = text.indexOf(' ');
if (sp > 0) return { cmd: text.slice(0, sp), desc: text.slice(sp + 1) };
return { cmd: text, desc: '' };
}
/** Renders simple inline bold (**text**) without a full markdown parser. */
function InlineMd({ text }: { text: string }) {
const parts = text.split(/(\*\*[^*]+\*\*)/g);
return (
<>
{parts.map((p, i) =>
p.startsWith('**') && p.endsWith('**')
? <strong key={i} className="font-semibold text-gray-900 dark:text-white">{p.slice(2, -2)}</strong>
: <span key={i}>{p}</span>
)}
</>
);
}
function Prose({ children }: { children: string }) {
return (
<div className="prose prose-sm max-w-none dark:prose-invert prose-p:my-1.5 prose-p:leading-relaxed prose-headings:mt-3 prose-headings:mb-1.5 prose-headings:font-semibold prose-li:my-0.5 prose-ul:my-1 prose-ol:my-1 prose-a:text-accent prose-a:no-underline hover:prose-a:underline prose-strong:font-semibold prose-code:text-[0.85em] prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:bg-gray-100 prose-code:dark:bg-gray-800 prose-code:font-mono prose-blockquote:border-l-2 prose-blockquote:border-gray-300 prose-blockquote:dark:border-gray-600 prose-blockquote:pl-3 prose-blockquote:not-italic prose-blockquote:text-gray-500">
<Markdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
{children}
</Markdown>
</div>
);
}
function CardContent({ card, onAction }: { card: any; onAction?: (v: string) => void }) {
if (!card) return null;
return (
<div className="space-y-4">
{card.header?.title && (
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
{card.header.title}
</h3>
)}
{card.elements?.map((el: any, i: number) => (
<ElementRenderer key={i} el={el} onAction={onAction} />
))}
</div>
);
}
function ElementRenderer({ el, onAction }: { el: any; onAction?: (v: string) => void }) {
if (el.type === 'markdown') {
return <Prose>{el.content}</Prose>;
}
if (el.type === 'divider') {
return <div className="border-t border-gray-200/60 dark:border-gray-700/40" />;
}
if (el.type === 'note') {
return (
<p className="text-[11px] text-gray-400 dark:text-gray-500 leading-relaxed">
{el.text}
</p>
);
}
if (el.type === 'actions') {
return (
<div className="flex flex-wrap gap-2">
{el.buttons?.map((btn: any, j: number) => (
<button
key={j}
onClick={() => onAction?.(btn.value)}
className={cn(
'px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all duration-150',
btn.btn_type === 'primary'
? 'bg-accent text-black hover:bg-accent-dim shadow-sm'
: btn.btn_type === 'danger'
? 'bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700',
)}
>
{btn.text}
</button>
))}
</div>
);
}
if (el.type === 'list_item') {
const parsed = parseListItemText(el.text);
const isCommand = parsed.cmd.startsWith('/');
return (
<button
onClick={() => onAction?.(el.btn_value)}
className="w-full flex items-center gap-3 py-2.5 px-3 -mx-3 rounded-xl hover:bg-gray-50 dark:hover:bg-white/[0.04] transition-colors text-left group"
>
{isCommand ? (
<>
<code className="shrink-0 w-24 text-xs font-mono font-medium text-accent">{parsed.cmd}</code>
<span className="flex-1 text-sm text-gray-500 dark:text-gray-400 truncate">{parsed.desc}</span>
</>
) : (
<span className="flex-1 text-sm text-gray-700 dark:text-gray-300 truncate min-w-0">
<InlineMd text={el.text} />
</span>
)}
<span className={cn(
'shrink-0 px-2.5 py-1 rounded-lg text-[11px] font-medium transition-all',
el.btn_type === 'primary'
? 'bg-accent/15 text-accent group-hover:bg-accent/25'
: 'text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800 group-hover:bg-accent/15 group-hover:text-accent',
)}>
{el.btn_text}
</span>
</button>
);
}
if (el.type === 'select') {
return (
<select
defaultValue={el.init_value}
onChange={(e) => onAction?.(e.target.value)}
className="w-full px-3 py-2 text-sm rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/80 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-accent/40 focus:border-accent transition-colors"
>
{el.options?.map((opt: any, j: number) => (
<option key={j} value={opt.value}>{opt.text}</option>
))}
</select>
);
}
return null;
}
export default function CommandResultPanel({ result, onClose, onCardAction }: Props) {
const { t } = useTranslation();
if (!result) return null;
const cmdDef = slashCommands.find(c => c.cmd === result.command);
const Icon = cmdDef?.icon;
const label = cmdDef ? t(cmdDef.labelKey) : result.command;
return (
<>
<div
className="fixed inset-0 bg-black/15 dark:bg-black/30 z-40 transition-opacity"
onClick={onClose}
/>
<div className={cn(
'fixed top-0 right-0 h-full w-[400px] max-w-[90vw] z-50 flex flex-col',
'translate-x-0 transition-transform duration-300 ease-out',
'bg-white dark:bg-[#111] border-l border-gray-200/80 dark:border-white/[0.06]',
'shadow-xl shadow-black/8 dark:shadow-black/40',
)}>
{/* Header */}
<div className="flex items-center justify-between px-5 h-14 border-b border-gray-100 dark:border-white/[0.06] shrink-0">
<div className="flex items-center gap-2.5 min-w-0">
{Icon && (
<div className="w-7 h-7 rounded-lg bg-accent/10 flex items-center justify-center shrink-0">
<Icon size={14} className="text-accent" />
</div>
)}
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{label}</div>
<div className="font-mono text-[10px] text-gray-400">{result.command}</div>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/[0.06] transition-colors"
>
<X size={16} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-5 py-5">
{result.format === 'card' && result.card ? (
<CardContent card={result.card} onAction={onCardAction} />
) : result.format === 'buttons' && result.buttons ? (
<div className="space-y-4">
{result.content && <Prose>{result.content}</Prose>}
{result.buttons.map((row, i) => (
<div key={i} className="flex flex-wrap gap-2">
{row.map((btn, j) => (
<button
key={j}
onClick={() => onCardAction?.(btn.data)}
className="px-3.5 py-1.5 rounded-lg text-xs font-medium bg-accent text-black hover:bg-accent-dim shadow-sm transition-all duration-150"
>
{btn.text}
</button>
))}
</div>
))}
</div>
) : (
<Prose>{result.content}</Prose>
)}
</div>
</div>
</>
);
}
export type { CommandResult };
+130
View File
@@ -0,0 +1,130 @@
import { useTranslation } from 'react-i18next';
import {
X, MessageSquare, Circle, User, Bot, Plus,
} from 'lucide-react';
import { Badge } from '@/components/ui';
import type { Session } from '@/api/sessions';
import { cn } from '@/lib/utils';
function timeAgo(iso: string): string {
if (!iso) return '';
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return '<1m';
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
interface Props {
open: boolean;
onClose: () => void;
sessions: Session[];
currentSessionId: string;
onSelect: (session: Session) => void;
onNewSession?: () => void;
}
export default function SessionDrawer({ open, onClose, sessions, currentSessionId, onSelect, onNewSession }: Props) {
const { t } = useTranslation();
return (
<>
{/* Backdrop */}
{open && (
<div className="fixed inset-0 bg-black/20 dark:bg-black/40 z-40 transition-opacity" onClick={onClose} />
)}
{/* Drawer */}
<div
className={cn(
'fixed top-0 right-0 h-full w-80 z-50 flex flex-col transition-transform duration-300 ease-out',
'bg-white/95 backdrop-blur-xl border-l border-gray-200/80 shadow-2xl shadow-black/15',
'dark:bg-[rgba(15,15,15,0.97)] dark:border-white/[0.08] dark:shadow-black/50',
open ? 'translate-x-0' : 'translate-x-full',
)}
>
{/* Header */}
<div className="flex items-center justify-between px-4 h-14 border-b border-gray-200/80 dark:border-white/[0.08] shrink-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{t('chat.sessions')}</h3>
<div className="flex items-center gap-1">
{onNewSession && (
<button
type="button"
onClick={onNewSession}
className="p-1.5 rounded-lg text-gray-400 hover:text-accent hover:bg-accent/10 transition-colors"
title={t('cmd.new')}
>
<Plus size={16} />
</button>
)}
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/[0.06] transition-colors"
>
<X size={16} />
</button>
</div>
</div>
{/* Session list */}
<div className="flex-1 overflow-y-auto py-2 px-2">
{sessions.length === 0 ? (
<div className="text-center text-sm text-gray-400 py-8">{t('sessions.noSessions')}</div>
) : (
sessions.map((s) => {
const isCurrent = s.id === currentSessionId;
return (
<button
key={s.id}
type="button"
onClick={() => onSelect(s)}
className={cn(
'w-full text-left p-3 rounded-xl mb-1 transition-all duration-200',
isCurrent
? 'bg-accent/10 ring-1 ring-accent/30'
: 'hover:bg-gray-100/80 dark:hover:bg-white/[0.04]',
)}
>
<div className="flex items-start justify-between gap-2 mb-1">
<div className="flex items-center gap-1.5 min-w-0">
<MessageSquare
size={13}
className={cn(s.live ? 'text-accent' : 'text-gray-400', 'shrink-0')}
/>
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">
{s.name || s.user_name || s.id.slice(0, 8)}
</span>
{s.live && <Circle size={4} className="fill-emerald-500 text-emerald-500 shrink-0" />}
</div>
<span className="text-[10px] text-gray-400 shrink-0 mt-0.5">
{timeAgo(s.updated_at || s.created_at)}
</span>
</div>
{s.last_message && (
<p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-1 leading-relaxed mb-1.5 pl-5">
{s.last_message.role === 'user' ? (
<User size={9} className="inline mr-0.5 -mt-0.5 opacity-60" />
) : (
<Bot size={9} className="inline mr-0.5 -mt-0.5 opacity-60" />
)}
{s.last_message.content.replace(/\n/g, ' ').slice(0, 80)}
</p>
)}
<div className="flex items-center gap-1.5 pl-5">
{s.platform && <Badge variant="info" className="text-[9px] px-1 py-0">{s.platform}</Badge>}
<span className="text-[10px] text-gray-400 ml-auto">{s.history_count} msgs</span>
</div>
</button>
);
})
)}
</div>
</div>
</>
);
}
+523
View File
@@ -0,0 +1,523 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Clock, Plus, Trash2, Terminal, MessageSquare, Pencil, Power, X,
ChevronDown,
} from 'lucide-react';
import { Card, Button, Badge, Modal, Input, Textarea, EmptyState } from '@/components/ui';
import { listCronJobs, createCronJob, updateCronJob, deleteCronJob, type CronJob } from '@/api/cron';
import { listProjects, type ProjectSummary } from '@/api/projects';
import { listSessions, type Session } from '@/api/sessions';
import { formatTime, cn } from '@/lib/utils';
const MODE_OPTIONS = ['bypassPermissions', 'acceptEdits', 'auto', 'plan', 'dontAsk'] as const;
/* ── Cron presets ── */
interface CronPreset {
label: string;
labelZh: string;
expr: string;
}
const PRESETS: CronPreset[] = [
{ label: 'Every minute', labelZh: '每分钟', expr: '* * * * *' },
{ label: 'Every 5 min', labelZh: '每 5 分钟', expr: '*/5 * * * *' },
{ label: 'Every 15 min', labelZh: '每 15 分钟', expr: '*/15 * * * *' },
{ label: 'Every 30 min', labelZh: '每 30 分钟', expr: '*/30 * * * *' },
{ label: 'Every hour', labelZh: '每小时', expr: '0 * * * *' },
{ label: 'Every 2 hours', labelZh: '每 2 小时', expr: '0 */2 * * *' },
{ label: 'Every 6 hours', labelZh: '每 6 小时', expr: '0 */6 * * *' },
{ label: 'Daily 6:00', labelZh: '每天 6:00', expr: '0 6 * * *' },
{ label: 'Daily 9:00', labelZh: '每天 9:00', expr: '0 9 * * *' },
{ label: 'Daily 12:00', labelZh: '每天 12:00', expr: '0 12 * * *' },
{ label: 'Daily 18:00', labelZh: '每天 18:00', expr: '0 18 * * *' },
{ label: 'Daily 22:00', labelZh: '每天 22:00', expr: '0 22 * * *' },
{ label: 'Weekdays 9:00', labelZh: '工作日 9:00',expr: '0 9 * * 1-5' },
{ label: 'Weekly Mon 9AM',labelZh: '每周一 9:00',expr: '0 9 * * 1' },
{ label: 'Monthly 1st', labelZh: '每月 1 号', expr: '0 0 1 * *' },
];
function describeCron(expr: string): string {
const preset = PRESETS.find(p => p.expr === expr);
if (preset) return preset.labelZh;
return expr;
}
/* ── Cron Schedule Picker (dropdown + custom input) ── */
const CUSTOM_VALUE = '__custom__';
function CronPicker({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const { i18n } = useTranslation();
const isZh = i18n.language?.startsWith('zh');
const isPreset = PRESETS.some(p => p.expr === value);
const [custom, setCustom] = useState(!isPreset && !!value);
const selectValue = custom ? CUSTOM_VALUE : value;
const handleSelect = (v: string) => {
if (v === CUSTOM_VALUE) {
setCustom(true);
} else {
setCustom(false);
onChange(v);
}
};
return (
<div className="space-y-2">
<div className="relative">
<select
value={selectValue}
onChange={e => handleSelect(e.target.value)}
className={cn(
'w-full px-3 py-2 text-sm rounded-lg transition-all duration-200 appearance-none pr-8',
'border border-gray-300/90 dark:border-white/[0.1]',
'bg-white/90 backdrop-blur-sm dark:bg-[rgba(0,0,0,0.45)]',
'text-gray-900 dark:text-white',
'focus:outline-none focus:ring-2 focus:ring-accent/45 focus:border-accent',
)}
>
<option value="" disabled>{isZh ? '选择执行频率' : 'Select schedule'}</option>
{PRESETS.map(p => (
<option key={p.expr} value={p.expr}>
{isZh ? p.labelZh : p.label} ({p.expr})
</option>
))}
<option value={CUSTOM_VALUE}>{isZh ? '✏ 自定义表达式' : '✏ Custom expression'}</option>
</select>
<ChevronDown size={14} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" />
</div>
{custom && (
<Input
placeholder="0 9 * * 1-5"
value={value}
onChange={e => onChange(e.target.value)}
className="font-mono text-xs"
/>
)}
</div>
);
}
/* ── Select dropdown ── */
function Select({ label, value, onChange, options, placeholder }: {
label?: string;
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
placeholder?: string;
}) {
return (
<div className="space-y-1.5">
{label && <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{label}</label>}
<div className="relative">
<select
value={value}
onChange={e => onChange(e.target.value)}
className={cn(
'w-full px-3 py-2 text-sm rounded-lg transition-all duration-200 appearance-none pr-8',
'border border-gray-300/90 dark:border-white/[0.1]',
'bg-white/90 backdrop-blur-sm dark:bg-[rgba(0,0,0,0.45)]',
'text-gray-900 dark:text-white',
'focus:outline-none focus:ring-2 focus:ring-accent/45 focus:border-accent',
)}
>
{placeholder && <option value="">{placeholder}</option>}
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<ChevronDown size={14} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" />
</div>
</div>
);
}
/* ── Toggle ── */
function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: string }) {
return (
<label className="inline-flex items-center gap-2 cursor-pointer">
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
'relative w-9 h-5 rounded-full transition-colors duration-200 shrink-0',
checked ? 'bg-accent' : 'bg-gray-300 dark:bg-gray-600',
)}
>
<span className={cn(
'block w-3.5 h-3.5 rounded-full bg-white shadow-sm transition-transform duration-200',
checked ? 'translate-x-[18px]' : 'translate-x-[3px]',
'mt-[3px]',
)} />
</button>
{label && <span className="text-sm text-gray-700 dark:text-gray-300">{label}</span>}
</label>
);
}
/* ── Job form type ── */
interface JobForm {
project: string;
session_key: string;
cron_expr: string;
prompt: string;
exec: string;
description: string;
silent: boolean;
enabled: boolean;
mode: string;
_type: 'prompt' | 'exec';
}
const emptyForm: JobForm = {
project: '', session_key: '', cron_expr: '', prompt: '', exec: '',
description: '', silent: false, enabled: true, mode: '', _type: 'prompt',
};
/* ── Main page ── */
export default function CronList() {
const { t } = useTranslation();
const [jobs, setJobs] = useState<CronJob[]>([]);
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [loading, setLoading] = useState(true);
const [editJob, setEditJob] = useState<CronJob | null>(null);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<JobForm>({ ...emptyForm });
const [saving, setSaving] = useState(false);
const [sessionKeys, setSessionKeys] = useState<string[]>([]);
const isEdit = !!editJob;
const projectOptions = useMemo(
() => projects.map(p => ({ value: p.name, label: p.name })),
[projects],
);
useEffect(() => {
if (!form.project) { setSessionKeys([]); return; }
let cancelled = false;
listSessions(form.project).then(data => {
if (cancelled) return;
const keys = new Set<string>();
for (const s of data.sessions || []) {
if (s.session_key) keys.add(s.session_key);
}
setSessionKeys([...keys]);
}).catch(() => { if (!cancelled) setSessionKeys([]); });
return () => { cancelled = true; };
}, [form.project]);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [cronData, projData] = await Promise.all([listCronJobs(), listProjects()]);
setJobs(cronData.jobs || []);
setProjects(projData.projects || []);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
const handler = () => fetchData();
window.addEventListener('cc:refresh', handler);
return () => window.removeEventListener('cc:refresh', handler);
}, [fetchData]);
const openAdd = () => {
setEditJob(null);
setForm({ ...emptyForm });
setShowForm(true);
};
const openEdit = (job: CronJob) => {
setEditJob(job);
setForm({
project: job.project,
session_key: job.session_key,
cron_expr: job.cron_expr,
prompt: job.prompt,
exec: job.exec,
description: job.description,
silent: !!job.silent,
enabled: job.enabled,
mode: job.mode || '',
_type: job.exec ? 'exec' : 'prompt',
});
setShowForm(true);
};
const handleSave = async () => {
setSaving(true);
const activePrompt = form._type === 'prompt' ? form.prompt : '';
const activeExec = form._type === 'exec' ? form.exec : '';
try {
if (isEdit && editJob) {
const updates: Record<string, any> = {};
if (form.cron_expr !== editJob.cron_expr) updates.cron_expr = form.cron_expr;
if (form.description !== editJob.description) updates.description = form.description;
if (activePrompt !== editJob.prompt) updates.prompt = activePrompt;
if (activeExec !== editJob.exec) updates.exec = activeExec;
if (form.project !== editJob.project) updates.project = form.project;
if (form.session_key !== editJob.session_key) updates.session_key = form.session_key;
if (form.silent !== !!editJob.silent) updates.silent = form.silent;
if (form.enabled !== editJob.enabled) updates.enabled = form.enabled;
if ((form.mode || '') !== (editJob.mode || '')) updates.mode = form.mode;
if (Object.keys(updates).length > 0) {
await updateCronJob(editJob.id, updates);
}
} else {
const body: any = { ...form, prompt: activePrompt, exec: activeExec };
delete body._type;
if (!body.prompt) delete body.prompt;
if (!body.exec) delete body.exec;
await createCronJob(body);
}
setShowForm(false);
fetchData();
} catch (e: any) {
alert(e.message);
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm(t('common.confirmDelete'))) return;
await deleteCronJob(id);
fetchData();
};
const handleToggleEnabled = async (job: CronJob) => {
try {
await updateCronJob(job.id, { enabled: !job.enabled });
fetchData();
} catch (e: any) {
alert(e.message);
}
};
if (loading && jobs.length === 0) {
return <div className="flex items-center justify-center h-64 text-gray-400 animate-pulse">Loading...</div>;
}
return (
<div className="space-y-4 animate-fade-in ">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('cron.title')}</h2>
<Button onClick={openAdd}><Plus size={16} /> {t('cron.add')}</Button>
</div>
{jobs.length === 0 ? (
<EmptyState message={t('cron.noJobs')} icon={Clock} />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{jobs.map(job => (
<div
key={job.id}
onClick={() => openEdit(job)}
className={cn(
'relative p-4 rounded-xl border transition-all cursor-pointer group',
'bg-white dark:bg-white/[0.02]',
job.enabled
? 'border-gray-200/80 dark:border-white/[0.06] hover:border-accent/40 hover:shadow-md hover:shadow-accent/5'
: 'border-dashed border-gray-300/60 dark:border-white/[0.04] opacity-60 hover:opacity-80',
)}
>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
{job.prompt ? (
<MessageSquare size={14} className="text-blue-400 shrink-0" />
) : (
<Terminal size={14} className="text-amber-400 shrink-0" />
)}
<span className="font-medium text-sm text-gray-900 dark:text-white truncate">
{job.description || job.id}
</span>
</div>
{/* Schedule badge + mode badge */}
<div className="flex items-center gap-2 mb-3">
<span className="inline-flex items-center gap-1 text-[11px] font-mono bg-accent/10 text-accent px-2 py-0.5 rounded-md">
<Clock size={10} />
{describeCron(job.cron_expr)}
</span>
{job.silent && <Badge variant="default" className="text-[10px] px-1.5 py-0">silent</Badge>}
<Badge variant="default" className="text-[10px] px-1.5 py-0">{job.mode || t('cron.modeDefault')}</Badge>
</div>
{/* Info */}
<div className="space-y-1 text-xs text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-1.5">
<span className="font-medium w-12 shrink-0 text-gray-400">{t('cron.project')}</span>
<span className="truncate">{job.project}</span>
</div>
{job.prompt && (
<div className="flex items-start gap-1.5">
<span className="font-medium w-12 shrink-0 text-gray-400">{t('cron.prompt')}</span>
<span className="line-clamp-2">{job.prompt}</span>
</div>
)}
{job.exec && (
<div className="flex items-center gap-1.5">
<span className="font-medium w-12 shrink-0 text-gray-400">{t('cron.exec')}</span>
<code className="truncate text-[11px]">{job.exec}</code>
</div>
)}
{job.last_run && (
<div className="flex items-center gap-1.5 pt-1 border-t border-gray-100 dark:border-white/[0.04] mt-1">
<span className="font-medium w-12 shrink-0 text-gray-400">{t('cron.lastRun')}</span>
<span>{formatTime(job.last_run)}</span>
</div>
)}
</div>
{job.last_error && (
<p className="text-[11px] text-red-500 mt-2 line-clamp-1">{job.last_error}</p>
)}
{/* Action buttons (top right) */}
<div
className="absolute top-3 right-3 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={e => e.stopPropagation()}
>
<button
onClick={() => handleToggleEnabled(job)}
className={cn(
'p-1.5 rounded-lg transition-colors',
job.enabled
? 'text-emerald-500 hover:bg-emerald-50 dark:hover:bg-emerald-900/20'
: 'text-gray-400 hover:bg-gray-100 dark:hover:bg-white/[0.06]',
)}
title={job.enabled ? 'Disable' : 'Enable'}
>
<Power size={14} />
</button>
<button
onClick={() => handleDelete(job.id)}
className="p-1.5 rounded-lg text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
title={t('cron.delete')}
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
)}
{/* Add / Edit modal */}
<Modal
open={showForm}
onClose={() => setShowForm(false)}
title={isEdit ? t('cron.editJob') : t('cron.add')}
className="max-w-xl"
>
<div className="space-y-4">
<Select
label={t('cron.project')}
value={form.project}
onChange={v => setForm({ ...form, project: v })}
options={projectOptions}
placeholder={t('cron.selectProject')}
/>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
{t('cron.schedule')}
</label>
<CronPicker value={form.cron_expr} onChange={v => setForm({ ...form, cron_expr: v })} />
</div>
<Input
label={t('cron.description')}
value={form.description}
onChange={e => setForm({ ...form, description: e.target.value })}
placeholder={t('cron.descPlaceholder')}
/>
{/* Task type: prompt or exec, mutually exclusive */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
{t('cron.taskType')}
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setForm({ ...form, _type: 'prompt' as const })}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-sm font-medium transition-all',
form._type === 'prompt'
? 'bg-accent/15 text-accent ring-1 ring-accent/30'
: 'bg-gray-50 dark:bg-white/[0.04] text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/[0.08]',
)}
>
<MessageSquare size={14} /> {t('cron.prompt')}
</button>
<button
type="button"
onClick={() => setForm({ ...form, _type: 'exec' as const })}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-sm font-medium transition-all',
form._type === 'exec'
? 'bg-amber-500/15 text-amber-600 dark:text-amber-400 ring-1 ring-amber-500/30'
: 'bg-gray-50 dark:bg-white/[0.04] text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/[0.08]',
)}
>
<Terminal size={14} /> {t('cron.exec')}
</button>
</div>
</div>
{form._type === 'prompt' ? (
<Textarea
label={t('cron.prompt')}
value={form.prompt}
onChange={e => setForm({ ...form, prompt: e.target.value })}
rows={3}
placeholder={t('cron.promptPlaceholder')}
/>
) : (
<Input
label={t('cron.exec')}
value={form.exec}
onChange={e => setForm({ ...form, exec: e.target.value })}
placeholder="npm run report"
/>
)}
<Select
label={t('cron.sessionKey')}
value={form.session_key}
onChange={v => setForm({ ...form, session_key: v })}
options={sessionKeys.map(k => ({ value: k, label: k }))}
placeholder={t('cron.selectSessionKey')}
/>
<Select
label={t('cron.mode')}
value={form.mode}
onChange={v => setForm({ ...form, mode: v })}
options={MODE_OPTIONS.map(m => ({ value: m, label: m }))}
placeholder={t('cron.modeDefault')}
/>
<div className="flex items-center gap-6 pt-1">
<Toggle checked={form.enabled} onChange={v => setForm({ ...form, enabled: v })} label={t('cron.enabled')} />
<Toggle checked={form.silent} onChange={v => setForm({ ...form, silent: v })} label={t('cron.silent')} />
</div>
<div className="flex justify-end gap-2 pt-3 border-t border-gray-100 dark:border-white/[0.06]">
<Button variant="secondary" onClick={() => setShowForm(false)}>{t('common.cancel')}</Button>
<Button onClick={handleSave} loading={saving}>
{isEdit ? t('common.save') : t('cron.add')}
</Button>
</div>
</div>
</Modal>
</div>
);
}
+170
View File
@@ -0,0 +1,170 @@
import { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import {
Activity, Server, Layers, MessageSquare, Clock, ChevronRight,
} from 'lucide-react';
import { StatCard, Badge, EmptyState } from '@/components/ui';
import { getStatus, type SystemStatus } from '@/api/status';
import { listProjects, type ProjectSummary } from '@/api/projects';
import { listSessions, type Session } from '@/api/sessions';
import { formatUptime, formatTime } from '@/lib/utils';
const MAX_ITEMS = 4;
export default function Dashboard() {
const { t } = useTranslation();
const [status, setStatus] = useState<SystemStatus | null>(null);
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [recentSessions, setRecentSessions] = useState<(Session & { project: string })[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError('');
const [s, p] = await Promise.all([getStatus(), listProjects()]);
setStatus(s);
const projs = p.projects || [];
setProjects(projs);
const sessResults = await Promise.allSettled(
projs.map(proj => listSessions(proj.name).then(r => ({ project: proj.name, sessions: r.sessions || [] })))
);
const allSessions: (Session & { project: string })[] = [];
for (const r of sessResults) {
if (r.status === 'fulfilled') {
for (const sess of r.value.sessions) {
allSessions.push({ ...sess, project: r.value.project });
}
}
}
allSessions.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
setRecentSessions(allSessions.slice(0, MAX_ITEMS));
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
const handler = () => fetchData();
window.addEventListener('cc:refresh', handler);
return () => window.removeEventListener('cc:refresh', handler);
}, [fetchData]);
if (loading && !status) {
return <div className="flex items-center justify-center h-64 text-gray-400"><Activity className="animate-pulse" size={24} /></div>;
}
if (error) {
return <div className="text-center py-16 text-red-500">{error}</div>;
}
return (
<div className="space-y-8 animate-fade-in ">
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard label={t('dashboard.version')} value={status?.version || '-'} accent />
<StatCard label={t('dashboard.uptime')} value={status ? formatUptime(status.uptime_seconds) : '-'} />
<StatCard label={t('dashboard.platforms')} value={status?.connected_platforms?.length ?? 0} />
<StatCard label={t('dashboard.projects')} value={status?.projects_count ?? 0} />
</div>
{/* Projects */}
<section>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-1.5">
<Server size={14} className="text-gray-400" />
{t('nav.projects')}
</h3>
<Link to="/projects" className="text-xs text-accent hover:underline flex items-center gap-0.5">
{t('common.viewAll')} <ChevronRight size={12} />
</Link>
</div>
{projects.length === 0 ? (
<div className="rounded-xl border border-gray-200/80 dark:border-white/[0.06] bg-white dark:bg-white/[0.02] p-8">
<EmptyState message={t('projects.noProjects')} icon={Layers} />
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
{projects.slice(0, MAX_ITEMS).map((p) => (
<Link
key={p.name}
to={`/chat/${p.name}`}
className="block p-4 rounded-xl border border-gray-200/80 dark:border-white/[0.06] bg-white dark:bg-white/[0.02] hover:border-accent/40 hover:shadow-md hover:shadow-accent/5 transition-all"
>
<div className="flex items-center gap-2.5 mb-3">
<div className="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0">
<Server size={14} className="text-accent" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-gray-900 dark:text-white truncate">{p.name}</p>
<p className="text-xs text-gray-400 font-mono">{p.agent_type}</p>
</div>
</div>
<div className="flex flex-wrap gap-1 mb-2">
{p.platforms?.slice(0, 3).map((pl) => (
<Badge key={pl} className="text-xs">{pl}</Badge>
))}
{(p.platforms?.length ?? 0) > 3 && (
<Badge className="text-xs">+{p.platforms!.length - 3}</Badge>
)}
</div>
<p className="text-xs text-gray-400">{p.sessions_count} sessions</p>
</Link>
))}
</div>
)}
</section>
{/* Recent Sessions */}
<section>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-1.5">
<MessageSquare size={14} className="text-gray-400" />
{t('dashboard.recentSessions')}
</h3>
<Link to="/chat" className="text-xs text-accent hover:underline flex items-center gap-0.5">
{t('common.viewAll')} <ChevronRight size={12} />
</Link>
</div>
{recentSessions.length === 0 ? (
<div className="rounded-xl border border-gray-200/80 dark:border-white/[0.06] bg-white dark:bg-white/[0.02] p-8">
<p className="text-xs text-gray-400 text-center">{t('sessions.noSessions')}</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
{recentSessions.map((sess) => (
<Link
key={`${sess.project}-${sess.id}`}
to={`/chat/${sess.project}`}
className="block p-4 rounded-xl border border-gray-200/80 dark:border-white/[0.06] bg-white dark:bg-white/[0.02] hover:border-accent/40 hover:shadow-md hover:shadow-accent/5 transition-all"
>
<div className="flex items-center gap-2.5 mb-3">
<div className={`w-2 h-2 rounded-full shrink-0 ${sess.active ? 'bg-green-400' : 'bg-gray-300 dark:bg-gray-600'}`} />
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{sess.name || sess.id}
</p>
</div>
<Badge className="text-xs mb-2">{sess.project}</Badge>
{sess.last_message && (
<p className="text-xs text-gray-400 truncate mb-2">
{sess.last_message.content.slice(0, 60)}
</p>
)}
<div className="flex items-center gap-1 text-xs text-gray-400">
<Clock size={10} />
{formatTime(sess.updated_at)}
</div>
</Link>
))}
</div>
)}
</section>
</div>
);
}
+165
View File
@@ -0,0 +1,165 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Zap, AlertCircle, Languages, Sun, Moon, Monitor } from 'lucide-react';
import { useAuthStore } from '@/store/auth';
import { useThemeStore } from '@/store/theme';
import { api } from '@/api/client';
import { getStatus } from '@/api/status';
const languages = [
{ code: 'en', label: 'EN' },
{ code: 'zh', label: '中' },
{ code: 'zh-TW', label: '繁' },
{ code: 'ja', label: '日' },
{ code: 'es', label: 'ES' },
];
export default function Login() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const loginStore = useAuthStore((s) => s.login);
const { theme, setTheme } = useThemeStore();
const [token, setToken] = useState('');
const [serverUrl, setServerUrl] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const autoLoginAttempted = useRef(false);
useEffect(() => {
if (autoLoginAttempted.current) return;
const qToken = searchParams.get('token');
if (!qToken) return;
autoLoginAttempted.current = true;
(async () => {
setLoading(true);
try {
api.setToken(qToken);
await getStatus();
loginStore(qToken);
navigate('/', { replace: true });
} catch {
setToken(qToken);
setError(t('login.invalidToken'));
api.setToken('');
} finally {
setLoading(false);
}
})();
}, [searchParams, loginStore, navigate, t]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token.trim()) return;
setLoading(true);
setError('');
try {
api.setToken(token.trim());
await getStatus();
loginStore(token.trim(), serverUrl.trim());
navigate('/');
} catch {
setError(t('login.invalidToken'));
api.setToken('');
} finally {
setLoading(false);
}
};
const themeIcons = { light: Sun, dark: Moon, system: Monitor };
const nextTheme: Record<string, 'light' | 'dark' | 'system'> = { light: 'dark', dark: 'system', system: 'light' };
const ThemeIcon = themeIcons[theme];
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-gray-100 to-white dark:from-gray-950 dark:to-gray-900 p-4">
{/* Top right controls */}
<div className="fixed top-4 right-4 flex items-center gap-2">
<div className="flex bg-white/80 dark:bg-gray-800/80 backdrop-blur rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
{languages.map(l => (
<button
key={l.code}
onClick={() => { i18n.changeLanguage(l.code); localStorage.setItem('cc_lang', l.code); }}
className={`px-2.5 py-1.5 text-xs font-medium transition-colors ${
i18n.language === l.code
? 'bg-accent/20 text-accent'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
}`}
>
{l.label}
</button>
))}
</div>
<button
onClick={() => setTheme(nextTheme[theme])}
className="p-2 rounded-lg bg-white/80 dark:bg-gray-800/80 backdrop-blur border border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
<ThemeIcon size={16} />
</button>
</div>
<div className="w-full max-w-md animate-fade-in">
<div className="bg-white/90 dark:bg-[rgba(15,15,20,0.9)] backdrop-blur-xl border border-gray-200/50 dark:border-gray-800 rounded-2xl shadow-2xl shadow-black/10 dark:shadow-black/40 p-8">
{/* Logo */}
<div className="flex justify-center mb-6">
<div className="w-14 h-14 rounded-2xl bg-gray-900 dark:bg-white/5 flex items-center justify-center shadow-lg">
<div className="w-5 h-5 rounded-full bg-accent dark:shadow-[0_0_20px_rgba(66,255,156,0.4)]" />
</div>
</div>
<h1 className="text-2xl font-bold text-center text-gray-900 dark:text-white mb-1">{t('login.title')}</h1>
<p className="text-sm text-center text-gray-500 dark:text-gray-400 mb-8">{t('login.subtitle')}</p>
{error && (
<div className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50 rounded-lg px-4 py-3 mb-4">
<AlertCircle size={16} />
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('login.token')}</label>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="mgmt-secret-xxx"
className="w-full px-4 py-2.5 text-sm rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800/50 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent transition-colors placeholder:text-gray-400"
autoFocus
/>
</div>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{t('login.serverUrl')} <span className="text-gray-400 font-normal">({t('common.optional')})</span>
</label>
<input
type="text"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
placeholder="http://localhost:9820"
className="w-full px-4 py-2.5 text-sm rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800/50 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent transition-colors placeholder:text-gray-400"
/>
</div>
<button
type="submit"
disabled={loading || !token.trim()}
className="w-full py-2.5 rounded-xl bg-accent text-black font-semibold text-sm hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{loading ? (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
) : (
<Zap size={16} />
)}
{t('login.connect')}
</button>
</form>
</div>
</div>
</div>
);
}
@@ -0,0 +1,151 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Eye, EyeOff, ChevronDown, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui';
import { addPlatformToProject } from '@/api/projects';
import { platformMeta, type FieldDef } from '@/lib/platformMeta';
import { cn } from '@/lib/utils';
interface Props {
platformType: string;
projectName: string;
workDir?: string;
agentType?: string;
onComplete: () => void;
onCancel: () => void;
}
export default function PlatformManualForm({ platformType, projectName, workDir, agentType, onComplete, onCancel }: Props) {
const { t } = useTranslation();
const meta = platformMeta[platformType];
const [values, setValues] = useState<Record<string, any>>({});
const [showAdvanced, setShowAdvanced] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
if (!meta) {
return (
<div className="py-4 text-center text-sm text-gray-500">
{t('setup.unsupportedPlatform', 'Unsupported platform type: {{type}}', { type: platformType })}
</div>
);
}
const basicFields = meta.fields.filter(f => f.group !== 'advanced');
const advancedFields = meta.fields.filter(f => f.group === 'advanced');
const handleSave = async () => {
const missing = meta.fields.filter(f => f.required && !values[f.key]);
if (missing.length > 0) {
setError(missing.map(f => t(f.labelKey)).join(', ') + ' required');
return;
}
setSaving(true);
setError('');
try {
const opts: Record<string, any> = {};
for (const f of meta.fields) {
const v = values[f.key];
if (v !== undefined && v !== '' && v !== false) {
opts[f.key] = v;
}
}
await addPlatformToProject(projectName, { type: platformType, options: opts, work_dir: workDir, agent_type: agentType });
onComplete();
} catch (e: any) {
setError(e?.message || String(e));
} finally {
setSaving(false);
}
};
const set = (key: string, val: any) => setValues(prev => ({ ...prev, [key]: val }));
return (
<div className="space-y-4 py-2">
<p className="text-sm font-medium text-gray-900 dark:text-white">{meta.label}</p>
{basicFields.map(f => (
<FieldInput key={f.key} field={f} value={values[f.key]} onChange={v => set(f.key, v)} t={t} />
))}
{advancedFields.length > 0 && (
<>
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
<ChevronDown size={12} className={cn('transition-transform', showAdvanced && 'rotate-180')} />
{t('setup.advancedOptions', 'Advanced options')} ({advancedFields.length})
</button>
{showAdvanced && advancedFields.map(f => (
<FieldInput key={f.key} field={f} value={values[f.key]} onChange={v => set(f.key, v)} t={t} />
))}
</>
)}
{error && (
<div className="flex items-center gap-2 text-sm text-red-500 bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
<AlertCircle size={14} className="shrink-0" /> {error}
</div>
)}
<div className="flex justify-between pt-2">
<Button variant="secondary" size="sm" onClick={onCancel}>{t('common.back')}</Button>
<Button onClick={handleSave} loading={saving}>{t('setup.addPlatform', 'Add platform')}</Button>
</div>
</div>
);
}
function FieldInput({ field, value, onChange, t }: { field: FieldDef; value: any; onChange: (v: any) => void; t: (key: string) => string }) {
const [showPwd, setShowPwd] = useState(false);
const label = t(field.labelKey);
const hint = field.hintKey ? t(field.hintKey) : undefined;
if (field.type === 'boolean') {
return (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={!!value}
onChange={e => onChange(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-accent focus:ring-accent"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">{label}</span>
{hint && <span className="text-[11px] text-gray-400">({hint})</span>}
</label>
);
}
const isPassword = field.type === 'password';
return (
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
{label} {field.required && <span className="text-red-400">*</span>}
</label>
<div className="relative">
<input
type={isPassword && !showPwd ? 'password' : field.type === 'number' ? 'number' : 'text'}
value={value || ''}
onChange={e => onChange(field.type === 'number' ? (e.target.value ? Number(e.target.value) : '') : e.target.value)}
placeholder={field.placeholder}
className="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-accent/50 placeholder:text-gray-400"
/>
{isPassword && (
<button
type="button"
onClick={() => setShowPwd(!showPwd)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600"
>
{showPwd ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
)}
</div>
{hint && <p className="text-[11px] text-gray-400 mt-1">{hint}</p>}
</div>
);
}
+338
View File
@@ -0,0 +1,338 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { QRCodeSVG } from 'qrcode.react';
import { Loader2, CheckCircle2, XCircle, RefreshCw, Smartphone, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui';
import {
setupFeishuBegin, setupFeishuPoll, setupFeishuSave,
setupWeixinBegin, setupWeixinPoll, setupWeixinSave,
} from '@/api/setup';
import { restartSystem } from '@/api/status';
type PlatformKind = 'feishu' | 'lark' | 'weixin';
type Phase = 'idle' | 'loading' | 'scanning' | 'scanned' | 'completed' | 'expired' | 'denied' | 'error' | 'saving';
interface Props {
platformType: PlatformKind;
projectName: string;
workDir?: string;
agentType?: string;
onComplete: () => void;
onCancel: () => void;
}
export default function PlatformSetupQR({ platformType, projectName, workDir, agentType, onComplete, onCancel }: Props) {
const { t } = useTranslation();
const [phase, setPhase] = useState<Phase>('idle');
const [qrUrl, setQrUrl] = useState('');
const [error, setError] = useState('');
const cancelledRef = useRef(false);
const pollingRef = useRef(false);
// Feishu state
const feishuRef = useRef({ deviceCode: '', baseUrl: '', interval: 5 });
// Weixin state
const weixinRef = useRef({ qrKey: '' });
useEffect(() => {
return () => { cancelledRef.current = true; };
}, []);
const isFeishu = platformType === 'feishu' || platformType === 'lark';
const startFeishuFlow = useCallback(async () => {
setPhase('loading');
setError('');
cancelledRef.current = false;
pollingRef.current = false;
try {
const res = await setupFeishuBegin();
feishuRef.current = {
deviceCode: res.device_code,
baseUrl: '',
interval: res.interval || 5,
};
setQrUrl(res.qr_url);
setPhase('scanning');
pollFeishu();
} catch (e: any) {
setError(e?.message || String(e));
setPhase('error');
}
}, []);
const pollFeishu = useCallback(async () => {
if (pollingRef.current) return;
pollingRef.current = true;
const poll = async () => {
while (!cancelledRef.current) {
try {
const res = await setupFeishuPoll(feishuRef.current.deviceCode, feishuRef.current.baseUrl || undefined);
if (cancelledRef.current) break;
if (res.base_url) feishuRef.current.baseUrl = res.base_url;
if (res.slow_down) feishuRef.current.interval += 5;
switch (res.status) {
case 'completed':
setPhase('saving');
await setupFeishuSave({
project: projectName,
app_id: res.app_id!,
app_secret: res.app_secret!,
platform_type: res.platform || 'feishu',
owner_open_id: res.owner_open_id,
work_dir: workDir,
agent_type: agentType,
});
setPhase('completed');
pollingRef.current = false;
return;
case 'denied':
setPhase('denied');
pollingRef.current = false;
return;
case 'expired':
setPhase('expired');
pollingRef.current = false;
return;
case 'error':
setError(res.error || 'Unknown error');
setPhase('error');
pollingRef.current = false;
return;
}
} catch (e: any) {
if (cancelledRef.current) break;
setError(e?.message || String(e));
setPhase('error');
pollingRef.current = false;
return;
}
await sleep(feishuRef.current.interval * 1000);
}
pollingRef.current = false;
};
poll();
}, [projectName]);
const startWeixinFlow = useCallback(async () => {
setPhase('loading');
setError('');
cancelledRef.current = false;
pollingRef.current = false;
try {
const res = await setupWeixinBegin();
console.log('[weixin-setup] begin response:', { qr_key: res.qr_key, qr_url_len: res.qr_url?.length, qr_url_prefix: res.qr_url?.slice(0, 80) });
const qrKey = res.qr_key;
weixinRef.current.qrKey = qrKey;
setQrUrl(res.qr_url);
setPhase('scanning');
console.log('[weixin-setup] starting poll loop, qrKey=', qrKey, 'cancelledRef=', cancelledRef.current);
let consecutiveErrors = 0;
while (!cancelledRef.current) {
try {
console.log('[weixin-setup] sending poll request, qrKey=', qrKey);
const pollRes = await setupWeixinPoll(qrKey);
console.log('[weixin-setup] poll response:', pollRes);
consecutiveErrors = 0;
if (cancelledRef.current) break;
switch (pollRes.status) {
case 'scaned':
setPhase('scanned');
break;
case 'confirmed':
setPhase('saving');
await setupWeixinSave({
project: projectName,
token: pollRes.bot_token!,
base_url: pollRes.base_url,
ilink_bot_id: pollRes.ilink_bot_id,
ilink_user_id: pollRes.ilink_user_id,
work_dir: workDir,
agent_type: agentType,
});
setPhase('completed');
return;
case 'expired':
setPhase('expired');
return;
}
} catch (e: any) {
console.error('[weixin-setup] poll error:', e);
if (cancelledRef.current) break;
consecutiveErrors++;
if (consecutiveErrors >= 5) {
setError(e?.message || String(e));
setPhase('error');
return;
}
}
await sleep(500);
}
} catch (e: any) {
console.error('[weixin-setup] begin error:', e);
setError(e?.message || String(e));
setPhase('error');
}
}, [projectName]);
const startFlow = isFeishu ? startFeishuFlow : startWeixinFlow;
const handleRetry = () => {
cancelledRef.current = false;
pollingRef.current = false;
startFlow();
};
const platformLabel = isFeishu
? t('setup.feishuLabel', 'Feishu / Lark')
: t('setup.weixinLabel', 'WeChat (ilink)');
const scanHint = isFeishu
? t('setup.scanFeishu', 'Open the Feishu / Lark app and scan the QR code')
: t('setup.scanWeixin', 'Open WeChat and scan the QR code');
return (
<div className="flex flex-col items-center gap-4 py-4">
{phase === 'idle' && (
<>
<Smartphone size={48} className="text-gray-400" />
<p className="text-sm text-gray-600 dark:text-gray-400 text-center">
{t('setup.qrDescription', 'Scan a QR code with your phone to quickly connect {{platform}}.', { platform: platformLabel })}
</p>
<Button onClick={startFlow}>
{t('setup.startQR', 'Start QR Setup')}
</Button>
</>
)}
{phase === 'loading' && (
<div className="flex flex-col items-center gap-3 py-8">
<Loader2 size={32} className="animate-spin text-accent" />
<p className="text-sm text-gray-500">{t('setup.generating', 'Generating QR code...')}</p>
</div>
)}
{(phase === 'scanning' || phase === 'scanned' || phase === 'saving') && (
<>
<div className="p-4 bg-white rounded-xl shadow-sm border border-gray-200">
<QRCodeSVG value={qrUrl} size={200} level="M" />
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 text-center max-w-xs">
{phase === 'scanned'
? t('setup.scannedConfirm', 'Scanned! Please confirm on your phone...')
: phase === 'saving'
? t('setup.savingConfig', 'Saving configuration...')
: scanHint}
</p>
{phase === 'scanning' && (
<div className="flex items-center gap-2 text-xs text-gray-400">
<Loader2 size={12} className="animate-spin" />
{t('setup.waitingScan', 'Waiting for scan...')}
</div>
)}
{phase === 'scanned' && (
<div className="flex items-center gap-2 text-xs text-accent">
<Loader2 size={12} className="animate-spin" />
{t('setup.waitingConfirm', 'Waiting for confirmation...')}
</div>
)}
{phase === 'saving' && (
<div className="flex items-center gap-2 text-xs text-accent">
<Loader2 size={12} className="animate-spin" />
{t('setup.savingConfig', 'Saving configuration...')}
</div>
)}
</>
)}
{phase === 'completed' && (
<div className="flex flex-col items-center gap-3 py-4">
<CheckCircle2 size={48} className="text-green-500" />
<p className="text-sm font-medium text-green-700 dark:text-green-400">
{t('setup.completed', 'Platform connected successfully!')}
</p>
<p className="text-xs text-gray-500 text-center">
{t('setup.restartHint', 'Restart the service for the new platform to take effect.')}
</p>
<div className="flex gap-2">
<Button
variant="secondary"
onClick={async () => {
try {
await restartSystem();
setPhase('restarting' as Phase);
setTimeout(() => onComplete(), 3000);
} catch (e: any) {
setError(e?.message || String(e));
}
}}
>
<RotateCcw size={14} /> {t('setup.restartNow', 'Restart Now')}
</Button>
<Button onClick={onComplete}>{t('setup.later', 'Later')}</Button>
</div>
</div>
)}
{phase === ('restarting' as Phase) && (
<div className="flex flex-col items-center gap-3 py-4">
<Loader2 size={32} className="animate-spin text-accent" />
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('setup.restarting', 'Restarting service...')}
</p>
</div>
)}
{phase === 'expired' && (
<div className="flex flex-col items-center gap-3 py-4">
<XCircle size={48} className="text-amber-500" />
<p className="text-sm text-amber-700 dark:text-amber-400">
{t('setup.expired', 'QR code expired.')}
</p>
<Button onClick={handleRetry}>
<RefreshCw size={14} /> {t('setup.retry', 'Retry')}
</Button>
</div>
)}
{phase === 'denied' && (
<div className="flex flex-col items-center gap-3 py-4">
<XCircle size={48} className="text-red-500" />
<p className="text-sm text-red-700 dark:text-red-400">
{t('setup.denied', 'Authorization was denied.')}
</p>
<Button onClick={handleRetry}>
<RefreshCw size={14} /> {t('setup.retry', 'Retry')}
</Button>
</div>
)}
{phase === 'error' && (
<div className="flex flex-col items-center gap-3 py-4">
<XCircle size={48} className="text-red-500" />
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
<Button onClick={handleRetry}>
<RefreshCw size={14} /> {t('setup.retry', 'Retry')}
</Button>
</div>
)}
{phase !== 'completed' && (
<button
onClick={onCancel}
className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 mt-2"
>
{t('common.cancel')}
</button>
)}
</div>
);
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
+701
View File
@@ -0,0 +1,701 @@
import { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams, Link, useNavigate } from 'react-router-dom';
import {
ArrowLeft, Plug, Heart, Settings, Layers, Zap, Pause, Play,
Trash2, Plus, Check, Clock, ExternalLink, Link2,
} from 'lucide-react';
import { Card, Badge, Button, Input, Modal, EmptyState } from '@/components/ui';
import { getProject, updateProject, deleteProject, listAgentTypes, type ProjectDetail as ProjectDetailType } from '@/api/projects';
import { listProviders, addProvider, removeProvider, activateProvider, type Provider, listGlobalProviders, type GlobalProvider, saveProviderRefs } from '@/api/providers';
import { getHeartbeat, pauseHeartbeat, resumeHeartbeat, triggerHeartbeat, setHeartbeatInterval, type HeartbeatStatus } from '@/api/heartbeat';
import { restartSystem } from '@/api/status';
import { formatTime, cn } from '@/lib/utils';
import PlatformSetupQR from './PlatformSetupQR';
import PlatformManualForm from './PlatformManualForm';
import { platformMeta } from '@/lib/platformMeta';
const PLATFORM_OPTIONS: { key: string; label: string; color: string; abbr: string; qr?: boolean }[] = [
{ key: 'feishu', label: 'Feishu / Lark', abbr: 'FS', color: 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400', qr: true },
{ key: 'weixin', label: 'WeChat', abbr: 'WX', color: 'bg-green-50 dark:bg-green-900/30 text-green-600 dark:text-green-400', qr: true },
{ key: 'telegram', label: 'Telegram', abbr: 'TG', color: 'bg-sky-50 dark:bg-sky-900/30 text-sky-600 dark:text-sky-400' },
{ key: 'discord', label: 'Discord', abbr: 'DC', color: 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400' },
{ key: 'slack', label: 'Slack', abbr: 'SK', color: 'bg-purple-50 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400' },
{ key: 'dingtalk', label: 'DingTalk', abbr: 'DT', color: 'bg-orange-50 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400' },
{ key: 'wecom', label: 'WeChat Work', abbr: 'WC', color: 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400' },
{ key: 'qq', label: 'QQ (OneBot)', abbr: 'QQ', color: 'bg-cyan-50 dark:bg-cyan-900/30 text-cyan-600 dark:text-cyan-400' },
{ key: 'qqbot', label: 'QQ Bot (Official)', abbr: 'QB', color: 'bg-cyan-50 dark:bg-cyan-900/30 text-cyan-600 dark:text-cyan-400' },
{ key: 'line', label: 'LINE', abbr: 'LN', color: 'bg-lime-50 dark:bg-lime-900/30 text-lime-600 dark:text-lime-400' },
{ key: 'weibo', label: 'Weibo (微博)', abbr: 'WB', color: 'bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400' },
];
const isQRPlatform = (type: string) => type === 'feishu' || type === 'lark' || type === 'weixin';
type Tab = 'overview' | 'providers' | 'heartbeat' | 'settings';
export default function ProjectDetail() {
const { t } = useTranslation();
const { name } = useParams<{ name: string }>();
const [tab, setTab] = useState<Tab>('overview');
const [project, setProject] = useState<ProjectDetailType | null>(null);
const [providers, setProviders] = useState<Provider[]>([]);
const [activeProvider, setActiveProvider] = useState('');
const [heartbeat, setHeartbeatState] = useState<HeartbeatStatus | null>(null);
const [loading, setLoading] = useState(true);
// Settings form
const [language, setLanguage] = useState('');
const [adminFrom, setAdminFrom] = useState('');
const [disabledCmds, setDisabledCmds] = useState('');
const [workDir, setWorkDir] = useState('');
const [agentMode, setAgentMode] = useState('');
const [showCtxIndicator, setShowCtxIndicator] = useState(true);
const [replyFooter, setReplyFooter] = useState(true);
const [injectSender, setInjectSender] = useState(false);
const [platformAllowFrom, setPlatformAllowFrom] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
// Agent type
const [agentTypes, setAgentTypes] = useState<string[]>([]);
const [selectedAgentType, setSelectedAgentType] = useState('');
// Global providers & refs
const [globalProviders, setGlobalProviders] = useState<GlobalProvider[]>([]);
const [providerRefs, setProviderRefs] = useState<string[]>([]);
const [savingRefs, setSavingRefs] = useState(false);
// Add provider modal
const [showAddProvider, setShowAddProvider] = useState(false);
const [addMode, setAddMode] = useState<'pick' | 'custom'>('pick');
const [newProvider, setNewProvider] = useState({ name: '', api_key: '', base_url: '', model: '' });
// Interval modal
const [showInterval, setShowInterval] = useState(false);
const [newInterval, setNewInterval] = useState('30');
// Add platform
const [showAddPlatform, setShowAddPlatform] = useState(false);
const [addPlatType, setAddPlatType] = useState('');
const [showRestartModal, setShowRestartModal] = useState(false);
// Delete project
const navigate = useNavigate();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const handleDeleteProject = async () => {
if (!name) return;
setDeleting(true);
try {
const res = await deleteProject(name);
setShowDeleteConfirm(false);
if (res.restart_required && window.confirm(t('setup.restartAfterDelete'))) {
await restartSystem();
// Wait for service to come back up before navigating
await waitForService(8000);
}
navigate('/projects');
} catch (e: any) {
alert(e?.message || String(e));
} finally {
setDeleting(false);
}
};
const waitForService = (maxMs: number) =>
new Promise<void>((resolve) => {
const start = Date.now();
const poll = () => {
fetch('/api/v1/status')
.then((r) => { if (r.ok) resolve(); else throw new Error(); })
.catch(() => {
if (Date.now() - start > maxMs) { resolve(); return; }
setTimeout(poll, 500);
});
};
setTimeout(poll, 1500);
});
const fetchAll = useCallback(async () => {
if (!name) return;
try {
setLoading(true);
const [proj, provs, hb, gp, at] = await Promise.allSettled([
getProject(name),
listProviders(name),
getHeartbeat(name),
listGlobalProviders(),
listAgentTypes(),
]);
if (proj.status === 'fulfilled') {
setProject(proj.value);
setLanguage(proj.value.settings?.language || '');
setAdminFrom(proj.value.settings?.admin_from || '');
setDisabledCmds(proj.value.settings?.disabled_commands?.join(', ') || '');
setWorkDir(proj.value.work_dir || '');
setAgentMode(proj.value.agent_mode || 'default');
setSelectedAgentType(proj.value.agent_type || '');
setShowCtxIndicator(proj.value.show_context_indicator !== false);
setReplyFooter(proj.value.reply_footer !== false);
setInjectSender(proj.value.inject_sender === true);
setProviderRefs(proj.value.provider_refs || []);
const afMap: Record<string, string> = {};
proj.value.platform_configs?.forEach(pc => {
if (pc.allow_from !== undefined) afMap[pc.type] = pc.allow_from;
});
setPlatformAllowFrom(afMap);
}
if (provs.status === 'fulfilled') {
setProviders(provs.value.providers || []);
setActiveProvider(provs.value.active_provider || '');
}
if (hb.status === 'fulfilled') {
const hbVal = hb.value;
setHeartbeatState(hbVal?.enabled ? hbVal : null);
}
if (gp.status === 'fulfilled') {
setGlobalProviders(gp.value.providers || []);
}
if (at.status === 'fulfilled') {
setAgentTypes((at.value.agents || []).sort());
}
} finally {
setLoading(false);
}
}, [name]);
useEffect(() => {
fetchAll();
const handler = () => fetchAll();
window.addEventListener('cc:refresh', handler);
return () => window.removeEventListener('cc:refresh', handler);
}, [fetchAll]);
const handleSaveSettings = async () => {
if (!name) return;
setSaving(true);
try {
const agentTypeChanged = project && selectedAgentType !== project.agent_type;
const res = await updateProject(name, {
language,
admin_from: adminFrom,
disabled_commands: disabledCmds.split(',').map(s => s.trim()).filter(Boolean),
work_dir: workDir,
mode: agentMode,
...(agentTypeChanged ? { agent_type: selectedAgentType } : {}),
show_context_indicator: showCtxIndicator,
reply_footer: replyFooter,
inject_sender: injectSender,
platform_allow_from: platformAllowFrom,
});
if (res && (res as any).restart_required) {
setShowRestartModal(true);
return;
}
await fetchAll();
} finally {
setSaving(false);
}
};
const handleAddProvider = async () => {
if (!name || !newProvider.name) return;
await addProvider(name, newProvider);
setShowAddProvider(false);
setNewProvider({ name: '', api_key: '', base_url: '', model: '' });
fetchAll();
};
const handleSetInterval = async () => {
if (!name) return;
await setHeartbeatInterval(name, parseInt(newInterval));
setShowInterval(false);
fetchAll();
};
const tabs: { key: Tab; icon: React.ElementType }[] = [
{ key: 'overview', icon: Layers },
{ key: 'providers', icon: Zap },
{ key: 'heartbeat', icon: Heart },
{ key: 'settings', icon: Settings },
];
if (loading && !project) {
return <div className="flex items-center justify-center h-64 text-gray-400 animate-pulse">Loading...</div>;
}
return (
<div className="space-y-6 animate-fade-in ">
{/* Back + title */}
<div className="flex items-center gap-3">
<Link to="/projects" className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
<ArrowLeft size={18} className="text-gray-400" />
</Link>
<h2 className="text-xl font-bold text-gray-900 dark:text-white">{name}</h2>
{project && <Badge variant="info">{project.agent_type}</Badge>}
</div>
{/* Tabs */}
<div className="flex gap-2">
{tabs.map(({ key, icon: Icon }) => (
<button
key={key}
onClick={() => setTab(key)}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all',
tab === key
? 'bg-gray-900 dark:bg-gray-700 text-white shadow-md'
: 'bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
)}
>
<Icon size={16} />
{t(`projects.tabs.${key}`)}
</button>
))}
</div>
{/* Tab content */}
{tab === 'overview' && project && (
<div className="space-y-4">
<Card>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{t('projects.platforms')}</h3>
<Button size="sm" onClick={() => { setShowAddPlatform(true); setAddPlatType(''); }}>
<Plus size={14} /> {t('setup.addPlatform', 'Add platform')}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{project.platforms?.map((p) => (
<Badge key={p.type} variant={p.connected ? 'success' : 'danger'}>
<Plug size={12} className="mr-1" /> {p.type} {p.connected ? '✓' : '✗'}
</Badge>
))}
</div>
</Card>
<Card>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">{t('sessions.title')}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{project.sessions_count} {t('nav.sessions').toLowerCase()}
</p>
{project.active_session_keys?.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{project.active_session_keys.map((k) => (
<Badge key={k} variant="default">{k}</Badge>
))}
</div>
)}
</Card>
</div>
)}
{tab === 'providers' && (() => {
const globalNames = new Set(globalProviders.map(g => g.name));
const isGlobal = (pName: string) => globalNames.has(pName) && providerRefs.includes(pName);
const currentAgentType = project?.agent_type || selectedAgentType || '';
const unlinkedGlobals = globalProviders.filter(g =>
!providerRefs.includes(g.name) &&
(!g.agent_types?.length || g.agent_types.includes(currentAgentType))
);
return (
<div className="space-y-4">
{/* Header */}
<div className="flex justify-between items-center">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{t('providers.title')}</h3>
<Button size="sm" onClick={() => { setAddMode('pick'); setShowAddProvider(true); }}><Plus size={14} /> {t('providers.add')}</Button>
</div>
{/* Unified provider list */}
{providers.length === 0 ? (
<Card>
<div className="py-6 text-center">
<Plug size={32} className="mx-auto text-gray-300 dark:text-gray-600 mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">{t('providers.emptyProject', 'No providers configured for this project.')}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">{t('providers.emptyProjectHint', 'Link a global provider or add a custom one.')}</p>
</div>
</Card>
) : (
<div className="space-y-2">
{providers.map((p) => (
<div
key={p.name}
className={cn(
'flex items-center justify-between px-4 py-3 rounded-xl border transition-all',
p.active
? 'border-emerald-200 dark:border-emerald-500/20 bg-emerald-50/50 dark:bg-emerald-900/10'
: 'border-gray-200 dark:border-gray-700/60 bg-white dark:bg-gray-800/40',
)}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-white">{p.name}</span>
{p.active && <Badge variant="success">{t('providers.active')}</Badge>}
{isGlobal(p.name) && (
<Link to="/providers" className="inline-flex items-center gap-0.5 text-[10px] text-gray-400 hover:text-accent transition-colors">
<Link2 size={10} /> {t('providers.global', 'global')}
</Link>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{p.model}{p.base_url ? ` · ${p.base_url}` : ''}
</p>
</div>
<div className="flex items-center gap-1.5 shrink-0 ml-3">
{!p.active && (
<Button size="sm" variant="ghost" onClick={() => { activateProvider(name!, p.name).then(fetchAll); }}>
<Zap size={14} /> {t('providers.activate')}
</Button>
)}
{!p.active && (
isGlobal(p.name) ? (
<Button
size="sm"
variant="ghost"
className="text-gray-400 hover:text-red-500"
onClick={async () => {
const next = providerRefs.filter(r => r !== p.name);
setSavingRefs(true);
try {
await saveProviderRefs(name!, next);
await fetchAll();
} finally { setSavingRefs(false); }
}}
>
<Trash2 size={14} />
</Button>
) : (
<Button size="sm" variant="ghost" className="text-gray-400 hover:text-red-500" onClick={() => { removeProvider(name!, p.name).then(fetchAll); }}>
<Trash2 size={14} />
</Button>
)
)}
</div>
</div>
))}
</div>
)}
{/* Add Provider Modal */}
<Modal open={showAddProvider} onClose={() => setShowAddProvider(false)} title={t('providers.add')}>
<div className="space-y-4">
{/* Toggle */}
<div className="flex rounded-lg bg-gray-100 dark:bg-gray-800 p-0.5">
<button
className={cn('flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-all', addMode === 'pick' ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm' : 'text-gray-500')}
onClick={() => setAddMode('pick')}
>{t('providers.linkGlobal', 'Link global')}</button>
<button
className={cn('flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-all', addMode === 'custom' ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm' : 'text-gray-500')}
onClick={() => setAddMode('custom')}
>{t('providers.addCustom', 'Add custom')}</button>
</div>
{addMode === 'pick' ? (
unlinkedGlobals.length === 0 ? (
<div className="py-4 text-center">
<p className="text-sm text-gray-500 dark:text-gray-400">{t('providers.allLinked', 'All global providers are already linked.')}</p>
<Link to="/providers" className="inline-flex items-center gap-1 mt-2 text-xs text-accent hover:underline">
{t('providers.manageGlobal', 'Manage global providers')} <ExternalLink size={11} />
</Link>
</div>
) : (
<div className="space-y-1.5 max-h-64 overflow-y-auto">
{unlinkedGlobals.map(gp => (
<button
key={gp.name}
className="flex items-center justify-between w-full px-3 py-2.5 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-accent/40 hover:bg-accent/5 transition-all text-left"
onClick={async () => {
const next = [...providerRefs, gp.name];
setSavingRefs(true);
try {
await saveProviderRefs(name!, next);
await fetchAll();
} finally { setSavingRefs(false); }
setShowAddProvider(false);
}}
>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white">{gp.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{gp.model}{gp.base_url ? ` · ${gp.base_url}` : ''}</div>
</div>
<Plus size={16} className="shrink-0 text-gray-400" />
</button>
))}
</div>
)
) : (
<div className="space-y-3">
<Input label={t('providers.name')} value={newProvider.name} onChange={(e) => setNewProvider({...newProvider, name: e.target.value})} />
<Input label="API Key" type="password" value={newProvider.api_key} onChange={(e) => setNewProvider({...newProvider, api_key: e.target.value})} />
<Input label={t('providers.baseUrl')} value={newProvider.base_url} onChange={(e) => setNewProvider({...newProvider, base_url: e.target.value})} placeholder="https://api.example.com" />
<Input label={t('providers.model')} value={newProvider.model} onChange={(e) => setNewProvider({...newProvider, model: e.target.value})} />
<div className="flex justify-end gap-2 pt-1">
<Button variant="secondary" onClick={() => setShowAddProvider(false)}>{t('common.cancel')}</Button>
<Button onClick={handleAddProvider}>{t('providers.add')}</Button>
</div>
</div>
)}
</div>
</Modal>
</div>
);
})()}
{tab === 'heartbeat' && (
<div className="space-y-4">
{!heartbeat ? (
<EmptyState message={t('heartbeat.notEnabled', 'Heartbeat is not configured for this project. Add [heartbeat] section in config.toml to enable.')} />
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card><p className="text-xs text-gray-500">{t('heartbeat.status')}</p><p className="text-lg font-bold text-gray-900 dark:text-white mt-1">{heartbeat.paused ? t('heartbeat.paused') : t('heartbeat.running')}</p></Card>
<Card><p className="text-xs text-gray-500">{t('heartbeat.interval')}</p><p className="text-lg font-bold text-gray-900 dark:text-white mt-1">{heartbeat.interval_mins}m</p></Card>
<Card><p className="text-xs text-gray-500">{t('heartbeat.runCount')}</p><p className="text-lg font-bold text-gray-900 dark:text-white mt-1">{heartbeat.run_count}</p></Card>
<Card><p className="text-xs text-gray-500">{t('heartbeat.errorCount')}</p><p className="text-lg font-bold text-gray-900 dark:text-white mt-1">{heartbeat.error_count}</p></Card>
</div>
<Card>
<div className="space-y-2 text-sm">
<p className="text-gray-500">{t('heartbeat.lastRun')}: <span className="text-gray-900 dark:text-white">{formatTime(heartbeat.last_run)}</span></p>
<p className="text-gray-500">{t('heartbeat.skippedBusy')}: <span className="text-gray-900 dark:text-white">{heartbeat.skipped_busy}</span></p>
{heartbeat.last_error && <p className="text-red-500">{heartbeat.last_error}</p>}
</div>
</Card>
<div className="flex gap-2">
{heartbeat.paused ? (
<Button onClick={() => { resumeHeartbeat(name!).then(fetchAll); }}><Play size={14} /> {t('heartbeat.resume')}</Button>
) : (
<Button variant="secondary" onClick={() => { pauseHeartbeat(name!).then(fetchAll); }}><Pause size={14} /> {t('heartbeat.pause')}</Button>
)}
<Button variant="secondary" onClick={() => { triggerHeartbeat(name!).then(fetchAll); }}><Heart size={14} /> {t('heartbeat.trigger')}</Button>
<Button variant="secondary" onClick={() => setShowInterval(true)}><Clock size={14} /> {t('heartbeat.setInterval')}</Button>
</div>
</>
)}
<Modal open={showInterval} onClose={() => setShowInterval(false)} title={t('heartbeat.setInterval')}>
<div className="space-y-3">
<Input label={`${t('heartbeat.interval')} (min)`} type="number" value={newInterval} onChange={(e) => setNewInterval(e.target.value)} />
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowInterval(false)}>{t('common.cancel')}</Button>
<Button onClick={handleSetInterval}>{t('common.save')}</Button>
</div>
</div>
</Modal>
</div>
)}
{tab === 'settings' && project && (
<div className="space-y-4">
{/* Agent settings */}
<Card>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">{t('projects.agentSettings', 'Agent')}</h3>
<div className="space-y-4 max-w-lg">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
{t('projects.agentType', 'Agent type')}
</label>
<select
value={selectedAgentType}
onChange={(e) => setSelectedAgentType(e.target.value)}
className="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-accent/50"
>
{agentTypes.map(a => <option key={a} value={a}>{a}</option>)}
{selectedAgentType && !agentTypes.includes(selectedAgentType) && (
<option value={selectedAgentType}>{selectedAgentType}</option>
)}
</select>
{selectedAgentType !== project.agent_type && (
<p className="text-[11px] text-amber-500 mt-1">{t('projects.agentTypeChangeHint', 'Changing agent type requires restart. Incompatible providers will be removed.')}</p>
)}
</div>
<Input label={t('projects.workDir', 'Working directory')} value={workDir} onChange={(e) => setWorkDir(e.target.value)} placeholder="/path/to/project" />
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
{t('projects.agentMode', 'Permission mode')}
</label>
<select
value={agentMode}
onChange={(e) => setAgentMode(e.target.value)}
className="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-accent/50"
>
<option value="default">default</option>
<option value="acceptEdits">acceptEdits (edit)</option>
<option value="plan">plan</option>
<option value="bypassPermissions">bypassPermissions (yolo)</option>
<option value="dontAsk">dontAsk</option>
</select>
</div>
</div>
</Card>
{/* General settings */}
<Card>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">{t('projects.generalSettings', 'General')}</h3>
<div className="space-y-4 max-w-lg">
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">{t('projects.showCtxIndicator', 'Context indicator')}</label>
<p className="text-[11px] text-gray-400 mt-0.5">{t('projects.showCtxIndicatorHint', 'Show [ctx: ~N%] suffix on replies')}</p>
</div>
<button
onClick={() => setShowCtxIndicator(!showCtxIndicator)}
className={cn('w-10 h-6 rounded-full transition-colors', showCtxIndicator ? 'bg-accent' : 'bg-gray-300 dark:bg-gray-700')}
>
<div className={cn('w-4 h-4 bg-white rounded-full transition-transform mx-1', showCtxIndicator ? 'translate-x-4' : 'translate-x-0')} />
</button>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">{t('projects.replyFooter', 'Reply footer')}</label>
<p className="text-[11px] text-gray-400 mt-0.5">{t('projects.replyFooterHint', 'Append model/usage metadata to replies')}</p>
</div>
<button
onClick={() => setReplyFooter(!replyFooter)}
className={cn('w-10 h-6 rounded-full transition-colors', replyFooter ? 'bg-accent' : 'bg-gray-300 dark:bg-gray-700')}
>
<div className={cn('w-4 h-4 bg-white rounded-full transition-transform mx-1', replyFooter ? 'translate-x-4' : 'translate-x-0')} />
</button>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">{t('projects.injectSender', 'Inject sender')}</label>
<p className="text-[11px] text-gray-400 mt-0.5">{t('projects.injectSenderHint', 'Prepend sender identity to messages sent to agent')}</p>
</div>
<button
onClick={() => setInjectSender(!injectSender)}
className={cn('w-10 h-6 rounded-full transition-colors', injectSender ? 'bg-accent' : 'bg-gray-300 dark:bg-gray-700')}
>
<div className={cn('w-4 h-4 bg-white rounded-full transition-transform mx-1', injectSender ? 'translate-x-4' : 'translate-x-0')} />
</button>
</div>
<Input label={t('projects.language')} value={language} onChange={(e) => setLanguage(e.target.value)} placeholder="en, zh, ja..." />
<Input label={t('projects.adminFrom')} value={adminFrom} onChange={(e) => setAdminFrom(e.target.value)} placeholder="user1,user2 or *" />
<Input label={t('projects.disabledCommands')} value={disabledCmds} onChange={(e) => setDisabledCmds(e.target.value)} placeholder="restart, upgrade, cron" />
</div>
</Card>
{/* Per-platform allow_from */}
{project.platform_configs && project.platform_configs.length > 0 && (
<Card>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">{t('projects.platformAccess', 'Platform access control')}</h3>
<div className="space-y-3 max-w-lg">
{project.platform_configs.map(pc => (
<Input
key={pc.type}
label={`${pc.type}${t('fields.allowFrom')}`}
value={platformAllowFrom[pc.type] ?? pc.allow_from ?? ''}
onChange={(e) => setPlatformAllowFrom(prev => ({ ...prev, [pc.type]: e.target.value }))}
placeholder='user1,user2 or *'
/>
))}
</div>
</Card>
)}
<div className="max-w-lg">
<Button loading={saving} onClick={handleSaveSettings}>{t('common.save')}</Button>
</div>
<Card>
<h3 className="text-sm font-semibold text-red-600 dark:text-red-400 mb-3">{t('projects.dangerZone', 'Danger Zone')}</h3>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-700 dark:text-gray-300">{t('projects.deleteTitle')}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t('projects.deleteHint', 'Remove this project from config. Requires restart.')}</p>
</div>
<Button variant="danger" size="sm" onClick={() => setShowDeleteConfirm(true)}>
<Trash2 size={14} /> {t('common.delete')}
</Button>
</div>
</Card>
</div>
)}
{/* Delete confirmation */}
<Modal open={showDeleteConfirm} onClose={() => setShowDeleteConfirm(false)} title={t('projects.deleteTitle')}>
<div className="space-y-4 py-2">
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('projects.deleteConfirm', { name })}
</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setShowDeleteConfirm(false)}>{t('common.cancel')}</Button>
<Button variant="danger" onClick={handleDeleteProject} disabled={deleting}>
{deleting ? t('common.deleting', 'Deleting...') : t('common.delete')}
</Button>
</div>
</div>
</Modal>
{/* Add Platform Modal */}
<Modal open={showAddPlatform} onClose={() => setShowAddPlatform(false)} title={t('setup.addPlatform', 'Add platform')}>
{!addPlatType ? (
<div className="space-y-3 py-2">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
{t('setup.choosePlatform', 'Choose a platform to connect:')}
</p>
<div className="grid grid-cols-2 gap-2 max-h-80 overflow-y-auto">
{PLATFORM_OPTIONS.map(({ key, label, color, qr, abbr }) => (
<button
key={key}
onClick={() => setAddPlatType(key)}
className="flex items-center gap-2.5 p-3 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-accent/50 hover:bg-accent/5 transition-all text-left"
>
<div className={`w-9 h-9 rounded-lg ${color} flex items-center justify-center shrink-0 font-bold text-xs`}>
{abbr}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{label}</div>
<div className="text-[11px] text-gray-400">
{qr ? t('setup.scanToConnect', 'Scan QR code') : t('setup.manualSetup', 'Manual setup')}
</div>
</div>
</button>
))}
</div>
</div>
) : isQRPlatform(addPlatType) ? (
<PlatformSetupQR
platformType={addPlatType as 'feishu' | 'weixin'}
projectName={name!}
onComplete={() => {
setShowAddPlatform(false);
setShowRestartModal(true);
}}
onCancel={() => setAddPlatType('')}
/>
) : platformMeta[addPlatType] ? (
<PlatformManualForm
platformType={addPlatType}
projectName={name!}
onComplete={() => {
setShowAddPlatform(false);
setShowRestartModal(true);
}}
onCancel={() => setAddPlatType('')}
/>
) : (
<div className="space-y-4 py-4 text-center">
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('setup.manualHint', 'For {{platform}}, please configure credentials in config.toml and restart the service.', { platform: PLATFORM_OPTIONS.find(o => o.key === addPlatType)?.label || addPlatType })}
</p>
<Button variant="secondary" onClick={() => setAddPlatType('')}>{t('common.back')}</Button>
</div>
)}
</Modal>
{/* Restart Required Modal */}
<Modal open={showRestartModal} onClose={() => setShowRestartModal(false)} title={t('setup.restartRequired', 'Restart required')}>
<div className="space-y-4 py-2">
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('setup.restartHint', 'Restart the service for the new platform to take effect.')}
</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => { setShowRestartModal(false); setTimeout(fetchAll, 300); }}>
{t('setup.later', 'Later')}
</Button>
<Button onClick={async () => { await restartSystem(); setShowRestartModal(false); await waitForService(8000); await fetchAll(); }}>
{t('setup.restartNow', 'Restart now')}
</Button>
</div>
</div>
</Modal>
</div>
);
}
+267
View File
@@ -0,0 +1,267 @@
import { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { Server, Heart, ArrowRight, FolderKanban, Plus, Smartphone, Settings2 } from 'lucide-react';
import { Card, Badge, Button, Input, Modal, EmptyState } from '@/components/ui';
import { listProjects, type ProjectSummary } from '@/api/projects';
import PlatformSetupQR from './PlatformSetupQR';
import PlatformManualForm from './PlatformManualForm';
import { platformMeta } from '@/lib/platformMeta';
const AGENT_OPTIONS = [
{ key: 'claudecode', label: 'Claude Code' },
{ key: 'codex', label: 'Codex' },
{ key: 'gemini', label: 'Gemini CLI' },
{ key: 'antigravity', label: 'Antigravity CLI' },
{ key: 'cursor', label: 'Cursor' },
{ key: 'devin', label: 'Devin' },
{ key: 'copilot', label: 'Copilot (GitHub)' },
{ key: 'acp', label: 'ACP (Generic)' },
{ key: 'acp:openclaw', label: 'OpenClaw (ACP)' },
{ key: 'opencode', label: 'OpenCode' },
{ key: 'qoder', label: 'Qoder' },
];
const PLATFORM_OPTIONS: { key: string; label: string; color: string; qr?: boolean }[] = [
{ key: 'feishu', label: 'Feishu / Lark', color: 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400', qr: true },
{ key: 'weixin', label: 'WeChat', color: 'bg-green-50 dark:bg-green-900/30 text-green-600 dark:text-green-400', qr: true },
{ key: 'telegram', label: 'Telegram', color: 'bg-sky-50 dark:bg-sky-900/30 text-sky-600 dark:text-sky-400' },
{ key: 'discord', label: 'Discord', color: 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400' },
{ key: 'slack', label: 'Slack', color: 'bg-purple-50 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400' },
{ key: 'dingtalk', label: 'DingTalk', color: 'bg-orange-50 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400' },
{ key: 'wecom', label: 'WeChat Work', color: 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400' },
{ key: 'qq', label: 'QQ (OneBot)', color: 'bg-cyan-50 dark:bg-cyan-900/30 text-cyan-600 dark:text-cyan-400' },
{ key: 'qqbot', label: 'QQ Bot (Official)', color: 'bg-cyan-50 dark:bg-cyan-900/30 text-cyan-600 dark:text-cyan-400' },
{ key: 'line', label: 'LINE', color: 'bg-lime-50 dark:bg-lime-900/30 text-lime-600 dark:text-lime-400' },
{ key: 'weibo', label: 'Weibo (微博)', color: 'bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400' },
];
export default function ProjectList() {
const { t } = useTranslation();
const navigate = useNavigate();
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [loading, setLoading] = useState(true);
// Add project wizard state
const [showWizard, setShowWizard] = useState(false);
const [wizStep, setWizStep] = useState<'name' | 'platform' | 'qr' | 'form' | 'done'>('name');
const [newProjName, setNewProjName] = useState('');
const [newWorkDir, setNewWorkDir] = useState('');
const [newAgentType, setNewAgentType] = useState('claudecode');
const [selectedPlat, setSelectedPlat] = useState('');
const fetch = useCallback(async () => {
try {
setLoading(true);
const data = await listProjects();
setProjects(data.projects || []);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetch();
const handler = () => fetch();
window.addEventListener('cc:refresh', handler);
return () => window.removeEventListener('cc:refresh', handler);
}, [fetch]);
const openWizard = () => {
setShowWizard(true);
setWizStep('name');
setNewProjName('');
setNewWorkDir('');
setNewAgentType('claudecode');
setSelectedPlat('');
};
const isQRPlatform = (type: string) => type === 'feishu' || type === 'lark' || type === 'weixin';
const handlePlatformSelect = (key: string) => {
setSelectedPlat(key);
if (isQRPlatform(key)) {
setWizStep('qr');
} else if (platformMeta[key]) {
setWizStep('form');
} else {
setWizStep('done');
}
};
const handleQRComplete = () => {
setShowWizard(false);
fetch();
};
const handleManualDone = async () => {
// For non-QR platforms, use feishu EnsureProject to create the project skeleton,
// then the user configures platform details from the project detail page.
// We use the feishu save endpoint with empty credentials just to create the project.
// Actually, let's guide the user to the project detail page to configure.
setShowWizard(false);
fetch();
navigate(`/projects/${newProjName}`);
};
if (loading && projects.length === 0) {
return <div className="flex items-center justify-center h-64 text-gray-400 animate-pulse">Loading...</div>;
}
return (
<div className="animate-fade-in space-y-4 ">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-gray-900 dark:text-white">{t('projects.title')}</h2>
<Button size="sm" onClick={openWizard}>
<Plus size={14} /> {t('setup.addProject', 'Add project')}
</Button>
</div>
{projects.length === 0 ? (
<EmptyState message={t('projects.noProjects')} icon={FolderKanban} />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{projects.map((p) => (
<Link key={p.name} to={`/projects/${p.name}`}>
<Card hover className="h-full flex flex-col">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<Server size={18} className="text-gray-400" />
<h3 className="font-semibold text-gray-900 dark:text-white">{p.name}</h3>
</div>
<ArrowRight size={16} className="text-gray-300 dark:text-gray-600" />
</div>
<div className="flex flex-wrap gap-1.5 mb-3">
<Badge variant="info">{p.agent_type}</Badge>
{p.platforms?.slice(0, 3).map((pl) => <Badge key={pl}>{pl}</Badge>)}
{(p.platforms?.length ?? 0) > 3 && (
<Badge>+{p.platforms!.length - 3}</Badge>
)}
</div>
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mt-auto pt-3 border-t border-gray-100 dark:border-gray-800">
<span>{p.sessions_count} {t('nav.sessions').toLowerCase()}</span>
{p.heartbeat_enabled && (
<span className="flex items-center gap-1 text-emerald-500"><Heart size={12} /> {t('heartbeat.title')}</span>
)}
</div>
</Card>
</Link>
))}
</div>
)}
{/* Add Project Wizard Modal */}
<Modal
open={showWizard}
onClose={() => setShowWizard(false)}
title={t('setup.addProject', 'Add project')}
>
{wizStep === 'name' && (
<div className="space-y-4 py-2">
<Input
label={t('setup.projectName', 'Project name')}
value={newProjName}
onChange={(e) => setNewProjName(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ''))}
placeholder="my-project"
autoFocus
/>
<Input
label={t('setup.workDir', 'Working directory')}
value={newWorkDir}
onChange={(e) => setNewWorkDir(e.target.value)}
placeholder="/path/to/project"
/>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
{t('setup.agentType', 'Agent type')}
</label>
<select
value={newAgentType}
onChange={(e) => setNewAgentType(e.target.value)}
className="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-accent/50"
>
{AGENT_OPTIONS.map(a => (
<option key={a.key} value={a.key}>{a.label}</option>
))}
</select>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowWizard(false)}>{t('common.cancel')}</Button>
<Button disabled={!newProjName.trim() || !newWorkDir.trim()} onClick={() => setWizStep('platform')}>
{t('setup.next', 'Next')}
</Button>
</div>
</div>
)}
{wizStep === 'platform' && (
<div className="space-y-3 py-2">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
{t('setup.choosePlatform', 'Choose a platform to connect:')}
</p>
<div className="grid grid-cols-2 gap-2 max-h-80 overflow-y-auto">
{PLATFORM_OPTIONS.map(({ key, label, color, qr }) => (
<button
key={key}
onClick={() => handlePlatformSelect(key)}
className="flex items-center gap-2.5 p-3 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-accent/50 hover:bg-accent/5 transition-all text-left"
>
<div className={`w-9 h-9 rounded-lg ${color} flex items-center justify-center shrink-0`}>
{qr ? <Smartphone size={16} /> : <Settings2 size={16} />}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{label}</div>
<div className="text-[11px] text-gray-400">
{qr ? t('setup.scanToConnect', 'Scan QR code') : t('setup.manualSetup', 'Manual setup')}
</div>
</div>
</button>
))}
</div>
<div className="flex justify-start pt-2">
<Button variant="secondary" size="sm" onClick={() => setWizStep('name')}>{t('common.back')}</Button>
</div>
</div>
)}
{wizStep === 'qr' && isQRPlatform(selectedPlat) && (
<PlatformSetupQR
platformType={selectedPlat as 'feishu' | 'weixin'}
projectName={newProjName}
workDir={newWorkDir}
agentType={newAgentType}
onComplete={handleQRComplete}
onCancel={() => setWizStep('platform')}
/>
)}
{wizStep === 'form' && platformMeta[selectedPlat] && (
<PlatformManualForm
platformType={selectedPlat}
projectName={newProjName}
workDir={newWorkDir}
agentType={newAgentType}
onComplete={() => {
setShowWizard(false);
fetch();
}}
onCancel={() => setWizStep('platform')}
/>
)}
{wizStep === 'done' && !isQRPlatform(selectedPlat) && (
<div className="space-y-4 py-4 text-center">
<Settings2 size={40} className="mx-auto text-gray-400" />
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('setup.manualHint', 'For {{platform}}, please configure credentials in config.toml or via the project detail page after creating the project.', { platform: PLATFORM_OPTIONS.find(o => o.key === selectedPlat)?.label || selectedPlat })}
</p>
<div className="flex justify-center gap-2">
<Button variant="secondary" onClick={() => setWizStep('platform')}>{t('common.back')}</Button>
</div>
</div>
)}
</Modal>
</div>
);
}
+987
View File
@@ -0,0 +1,987 @@
import { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
Plug, Plus, Trash2, Pencil, ExternalLink, Star, Sparkles, X, Eye, EyeOff, Check,
Download,
} from 'lucide-react';
import { Card, Button, Badge, Modal, Input } from '@/components/ui';
import {
listGlobalProviders, addGlobalProvider, updateGlobalProvider, removeGlobalProvider,
fetchProviderPresets, listCCSwitchProviders, importCCSwitchProviders,
type GlobalProvider, type ProviderPreset, type ProviderModel, type CCSwitchProvider,
} from '@/api/providers';
import { cn } from '@/lib/utils';
type Tab = 'providers' | 'presets';
export default function ProviderList() {
const { t, i18n } = useTranslation();
const [tab, setTab] = useState<Tab>('providers');
const [providers, setProviders] = useState<GlobalProvider[]>([]);
const [presets, setPresets] = useState<ProviderPreset[]>([]);
const [loading, setLoading] = useState(true);
const [presetsLoading, setPresetsLoading] = useState(false);
const [showAddModal, setShowAddModal] = useState(false);
const [editProvider, setEditProvider] = useState<GlobalProvider | null>(null);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [showCCSwitchModal, setShowCCSwitchModal] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const data = await listGlobalProviders();
setProviders(data.providers || []);
} catch { /* empty */ }
setLoading(false);
}, []);
const loadPresets = useCallback(async () => {
setPresetsLoading(true);
try {
const data = await fetchProviderPresets();
setPresets(data.providers || []);
} catch { /* empty */ }
setPresetsLoading(false);
}, []);
useEffect(() => { refresh(); }, [refresh]);
useEffect(() => {
if (tab === 'presets' && presets.length === 0) loadPresets();
}, [tab, presets.length, loadPresets]);
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await removeGlobalProvider(deleteTarget);
await refresh();
} catch { /* empty */ }
setDeleteTarget(null);
};
const handleAddFromPreset = (preset: ProviderPreset) => {
const agentTypes = Object.keys(preset.agents || {});
const firstAt = agentTypes[0] || 'claudecode';
const firstAc = preset.agents?.[firstAt];
const endpoints: Record<string, string> = {};
const agentModels: Record<string, string> = {};
const agentModelLists: Record<string, ProviderModel[]> = {};
let codex: GlobalProvider['codex'];
for (const [at, cfg] of Object.entries(preset.agents || {})) {
if (at !== firstAt && cfg.base_url) endpoints[at] = cfg.base_url;
if (at !== firstAt && cfg.model) agentModels[at] = cfg.model;
const models = cfg.models?.map(m => ({ model: m }));
if (models?.length && at !== firstAt) agentModelLists[at] = models;
if (at === firstAt && models?.length) { /* stored in top-level */ }
if (at === 'codex' && cfg.codex_config?.wire_api) {
codex = { wire_api: cfg.codex_config.wire_api, http_headers: cfg.codex_config.http_headers };
}
}
setEditProvider({
name: preset.name,
base_url: firstAc?.base_url || '',
model: firstAc?.model || '',
thinking: preset.thinking || '',
models: firstAc?.models?.map(m => ({ model: m })),
agent_types: agentTypes,
endpoints: Object.keys(endpoints).length ? endpoints : undefined,
agent_models: Object.keys(agentModels).length ? agentModels : undefined,
agent_model_lists: Object.keys(agentModelLists).length ? agentModelLists : undefined,
codex,
_preset: preset,
} as any);
setShowAddModal(true);
};
return (
<div className="space-y-6 ">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
{t('globalProviders.title')}
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t('globalProviders.subtitle')}
</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setShowCCSwitchModal(true)}>
<Download size={16} className="mr-1.5" /> {t('globalProviders.importCCSwitch')}
</Button>
<Button onClick={() => { setEditProvider(null); setShowAddModal(true); }}>
<Plus size={16} className="mr-1.5" /> {t('globalProviders.add')}
</Button>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 p-1 rounded-xl bg-gray-100 dark:bg-white/[0.06] w-fit">
{(['providers', 'presets'] as const).map(key => (
<button
key={key}
onClick={() => setTab(key)}
className={cn(
'px-4 py-1.5 rounded-lg text-sm font-medium transition-all',
tab === key
? 'bg-white dark:bg-white/10 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300',
)}
>
{t(`globalProviders.tab.${key}`)}
</button>
))}
</div>
{/* Content */}
{tab === 'providers' && (
<ProviderGrid
providers={providers}
loading={loading}
onEdit={p => { setEditProvider(p); setShowAddModal(true); }}
onDelete={name => setDeleteTarget(name)}
t={t}
/>
)}
{tab === 'presets' && (
<PresetGrid
presets={presets}
loading={presetsLoading}
existingNames={new Set(providers.map(p => p.name))}
onAdd={handleAddFromPreset}
onRefresh={loadPresets}
t={t}
lang={i18n.language || 'en'}
/>
)}
{/* Add/Edit Modal */}
{showAddModal && (
<ProviderFormModal
provider={editProvider}
onClose={() => setShowAddModal(false)}
onSave={async (p) => {
if (editProvider?.name && providers.some(x => x.name === editProvider.name)) {
await updateGlobalProvider(editProvider.name, p);
} else {
await addGlobalProvider(p);
}
setShowAddModal(false);
await refresh();
}}
t={t}
/>
)}
{/* Delete confirm */}
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title={t('common.confirmDelete')}>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
{t('globalProviders.deleteHint', { name: deleteTarget })}
</p>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setDeleteTarget(null)}>{t('common.cancel')}</Button>
<Button variant="danger" onClick={handleDelete}>{t('common.delete')}</Button>
</div>
</Modal>
{showCCSwitchModal && (
<CCSwitchImportModal
existingNames={new Set(providers.map(p => p.name))}
onClose={() => setShowCCSwitchModal(false)}
onImported={refresh}
t={t}
/>
)}
</div>
);
}
/* ── Provider Grid ── */
function ProviderGrid({
providers, loading, onEdit, onDelete, t,
}: {
providers: GlobalProvider[];
loading: boolean;
onEdit: (p: GlobalProvider) => void;
onDelete: (name: string) => void;
t: (k: string) => string;
}) {
if (loading) return <p className="text-sm text-gray-400">{t('common.loading')}</p>;
if (providers.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Plug size={40} className="text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{t('globalProviders.empty')}</p>
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">{t('globalProviders.emptyHint')}</p>
</div>
);
}
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{providers.map(p => (
<Card key={p.name} className="group relative">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Plug size={16} className="text-accent shrink-0" />
<h3 className="font-medium text-gray-900 dark:text-white truncate">{p.name}</h3>
</div>
{p.base_url && (
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500 truncate">{p.base_url}</p>
)}
{p.model && (
<Badge className="mt-2">{p.model}</Badge>
)}
{p.models && p.models.length > 0 && (
<ModelBadges models={p.models.map(m => m.alias || m.model)} limit={3} />
)}
{p.agent_types && p.agent_types.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{p.agent_types.map(a => (
<Badge key={a} variant="info" className="text-xs">{a}</Badge>
))}
</div>
)}
{p.thinking && (
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">thinking: {p.thinking}</p>
)}
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => onEdit(p)}
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-white/[0.06] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<Pencil size={14} />
</button>
<button
onClick={() => onDelete(p.name)}
className="p-1.5 rounded-lg hover:bg-red-50 dark:hover:bg-red-500/10 text-gray-400 hover:text-red-500"
>
<Trash2 size={14} />
</button>
</div>
</div>
</Card>
))}
</div>
);
}
/* ── Presets Grid ── */
function PresetGrid({
presets, loading, existingNames, onAdd, onRefresh, t, lang,
}: {
presets: ProviderPreset[];
loading: boolean;
existingNames: Set<string>;
onAdd: (p: ProviderPreset) => void;
onRefresh: () => void;
t: (k: string, opts?: any) => string;
lang: string;
}) {
if (loading) return <p className="text-sm text-gray-400">{t('common.loading')}</p>;
if (presets.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Sparkles size={40} className="text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{t('globalProviders.noPresets')}</p>
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">{t('globalProviders.noPresetsHint')}</p>
<Button variant="ghost" onClick={onRefresh} className="mt-3">{t('common.refresh')}</Button>
</div>
);
}
const sorted = [...presets].sort((a, b) => a.tier - b.tier);
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{sorted.map(p => {
const added = existingNames.has(p.name);
return (
<Card key={p.name} className="relative overflow-hidden flex flex-col">
{p.featured && (
<div className="absolute top-0 right-0 bg-amber-400/90 text-white text-[10px] font-bold px-2 py-0.5 rounded-bl-lg">
<Star size={10} className="inline mr-0.5 -mt-0.5" />
</div>
)}
<div className="space-y-3 flex-1">
<div>
<h3 className="font-medium text-gray-900 dark:text-white">{p.display_name || p.name}</h3>
{(p.description || p.description_zh) && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{lang.startsWith('zh') && p.description_zh ? p.description_zh : p.description}
</p>
)}
</div>
{p.agents && Object.keys(p.agents).length > 0 && (
<div className="flex flex-wrap gap-1">
{Object.keys(p.agents).map(a => (
<Badge key={a} variant="info" className="text-xs">{a}</Badge>
))}
</div>
)}
{p.features && p.features.length > 0 && (
<div className="flex flex-wrap gap-1">
{p.features.map(f => (
<Badge key={f} variant="outline" className="text-xs">{f}</Badge>
))}
</div>
)}
{(() => {
const firstAc = p.agents?.[Object.keys(p.agents || {})[0]];
return firstAc?.models && firstAc.models.length > 0 ? (
<ModelBadges models={firstAc.models} limit={5} />
) : null;
})()}
</div>
<div className="flex items-center justify-between mt-4 pt-3 border-t border-gray-100 dark:border-white/[0.06]">
{p.invite_url ? (
<a
href={p.invite_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-accent hover:underline inline-flex items-center gap-1"
>
{t('globalProviders.register')} <ExternalLink size={11} />
</a>
) : <span />}
<Button
size="sm"
variant={added ? 'ghost' : 'primary'}
disabled={added}
onClick={() => onAdd(p)}
>
{added ? t('globalProviders.added') : t('globalProviders.addPreset')}
</Button>
</div>
</Card>
);
})}
</div>
);
}
/* ── Model Badges (collapsible) ── */
function ModelBadges({ models, limit = 3 }: { models: string[]; limit?: number }) {
const [expanded, setExpanded] = useState(false);
const visible = expanded ? models : models.slice(0, limit);
const remaining = models.length - limit;
return (
<div className="mt-2 flex flex-wrap gap-1 items-center">
{visible.map(m => (
<Badge key={m} variant="outline" className="text-xs">{m}</Badge>
))}
{remaining > 0 && !expanded && (
<button
onClick={() => setExpanded(true)}
className="text-[11px] text-accent hover:underline"
>
+{remaining} more
</button>
)}
{expanded && remaining > 0 && (
<button
onClick={() => setExpanded(false)}
className="text-[11px] text-gray-400 hover:text-gray-600 hover:underline"
>
less
</button>
)}
</div>
);
}
/* ── Model List Editor ── */
function ModelListEditor({
models, onChange, defaultModel, onSetDefault,
}: {
models: ProviderModel[];
onChange: (models: ProviderModel[]) => void;
defaultModel?: string;
onSetDefault?: (model: string) => void;
}) {
const [input, setInput] = useState('');
const addModel = () => {
const name = input.trim();
if (!name || models.some(m => m.model === name)) return;
onChange([...models, { model: name }]);
setInput('');
};
const removeModel = (model: string) => {
onChange(models.filter(m => m.model !== model));
};
return (
<div className="space-y-2">
{models.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{models.map(m => {
const isDefault = defaultModel === m.model;
return (
<span
key={m.model}
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-xs transition-colors',
isDefault
? 'bg-accent/15 text-accent border border-accent/30'
: 'bg-gray-100 dark:bg-white/[0.06] text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-white/10',
)}
>
{onSetDefault && !isDefault && (
<button
type="button"
onClick={() => onSetDefault(m.model)}
className="text-gray-400 hover:text-accent transition-colors"
title="Set as default"
>
<Check size={12} />
</button>
)}
{isDefault && <Check size={12} className="text-accent" />}
{m.model}
<button
type="button"
onClick={() => removeModel(m.model)}
className="text-gray-400 hover:text-red-500 transition-colors"
>
<X size={12} />
</button>
</span>
);
})}
</div>
)}
<div className="flex gap-2">
<Input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addModel(); } }}
placeholder="model-name"
className="flex-1"
/>
<Button type="button" variant="ghost" size="sm" onClick={addModel} disabled={!input.trim()}>
<Plus size={14} />
</Button>
</div>
</div>
);
}
/* ── Per-agent config type (internal form state) ── */
type AgentConfigEntry = { base_url: string; model: string; models: ProviderModel[]; wire_api?: string };
function buildPerAgentConfigs(form: GlobalProvider): Record<string, AgentConfigEntry> {
const agents = form.agent_types || [];
const result: Record<string, AgentConfigEntry> = {};
for (const at of agents) {
result[at] = {
base_url: form.endpoints?.[at] || form.base_url || '',
model: form.agent_models?.[at] || form.model || '',
models: form.agent_model_lists?.[at] || form.models || [],
wire_api: at === 'codex' ? form.codex?.wire_api || '' : undefined,
};
}
return result;
}
function mergePerAgentToForm(form: GlobalProvider, configs: Record<string, AgentConfigEntry>): GlobalProvider {
const agents = Object.keys(configs);
if (agents.length === 0) return form;
const first = agents[0];
const base = configs[first];
const endpoints: Record<string, string> = {};
const agentModels: Record<string, string> = {};
const agentModelLists: Record<string, ProviderModel[]> = {};
let codex: GlobalProvider['codex'];
for (const at of agents) {
const cfg = configs[at];
if (at !== first) {
if (cfg.base_url && cfg.base_url !== base.base_url) endpoints[at] = cfg.base_url;
if (cfg.model && cfg.model !== base.model) agentModels[at] = cfg.model;
const modelsStr = JSON.stringify(cfg.models);
const baseModelsStr = JSON.stringify(base.models);
if (cfg.models.length > 0 && modelsStr !== baseModelsStr) agentModelLists[at] = cfg.models;
}
if (at === 'codex' && cfg.wire_api) {
codex = { wire_api: cfg.wire_api };
}
}
return {
...form,
base_url: base.base_url,
model: base.model,
models: base.models.length > 0 ? base.models : undefined,
endpoints: Object.keys(endpoints).length ? endpoints : undefined,
agent_models: Object.keys(agentModels).length ? agentModels : undefined,
agent_model_lists: Object.keys(agentModelLists).length ? agentModelLists : undefined,
codex: codex || undefined,
};
}
/* ── Per-agent config editor ── */
function AgentConfigEditor({
agentType, config, onChange, t,
}: {
agentType: string;
config: AgentConfigEntry;
onChange: (cfg: AgentConfigEntry) => void;
t: (k: string) => string;
}) {
return (
<div className="space-y-3 pt-2">
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{t('globalProviders.form.baseUrl')}
</label>
<Input
value={config.base_url}
onChange={e => onChange({ ...config, base_url: e.target.value })}
placeholder="https://api.example.com/v1"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{t('globalProviders.form.model')}
</label>
<Input
value={config.model}
onChange={e => onChange({ ...config, model: e.target.value })}
placeholder="model-name"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{t('globalProviders.form.models')}
</label>
<ModelListEditor
models={config.models}
onChange={models => onChange({ ...config, models })}
defaultModel={config.model}
onSetDefault={model => onChange({ ...config, model })}
/>
</div>
{agentType === 'codex' && (
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{t('globalProviders.form.codexWireApi')}
</label>
<select
value={config.wire_api || ''}
onChange={e => onChange({ ...config, wire_api: e.target.value || undefined })}
className={cn(
'w-full rounded-xl border px-3 py-2 text-sm outline-none transition-colors',
'border-gray-200 bg-white text-gray-900',
'dark:border-white/10 dark:bg-white/[0.04] dark:text-white',
'focus:border-accent focus:ring-1 focus:ring-accent/30',
)}
>
<option value="">default</option>
<option value="responses">responses</option>
<option value="chat">chat</option>
</select>
</div>
)}
</div>
);
}
/* ── Add/Edit Form Modal ── */
function ProviderFormModal({
provider, onClose, onSave, t,
}: {
provider: GlobalProvider | null;
onClose: () => void;
onSave: (p: GlobalProvider) => Promise<void>;
t: (k: string) => string;
}) {
const isEdit = !!provider?.name;
const [form, setForm] = useState<GlobalProvider>(provider || { name: '' });
const [saving, setSaving] = useState(false);
const [showKey, setShowKey] = useState(false);
const [perAgent, setPerAgent] = useState<Record<string, AgentConfigEntry>>(() =>
provider ? buildPerAgentConfigs(provider) : {},
);
const [activeAgentTab, setActiveAgentTab] = useState<string>('');
const agents = form.agent_types || [];
const multiAgent = agents.length >= 2;
const updatePerAgent = (at: string, cfg: AgentConfigEntry) => {
setPerAgent(prev => ({ ...prev, [at]: cfg }));
};
const set = (key: keyof GlobalProvider, value: any) => {
setForm(f => {
const next = { ...f, [key]: value };
if (key === 'agent_types') {
const newAgents = value as string[];
setPerAgent(prev => {
const updated = { ...prev };
for (const at of newAgents) {
if (!updated[at]) {
updated[at] = { base_url: f.base_url || '', model: f.model || '', models: [...(f.models || [])] };
if (at === 'codex') updated[at].wire_api = f.codex?.wire_api || '';
}
}
for (const at of Object.keys(updated)) {
if (!newAgents.includes(at)) delete updated[at];
}
return updated;
});
if (newAgents.length >= 2 && !newAgents.includes(activeAgentTab)) {
setActiveAgentTab(newAgents[0]);
}
}
return next;
});
};
const handleSubmit = async () => {
if (!form.name) return;
setSaving(true);
try {
const final = multiAgent ? mergePerAgentToForm(form, perAgent) : form;
await onSave(final);
} catch { /* empty */ }
setSaving(false);
};
return (
<Modal open onClose={onClose} title={isEdit ? t('globalProviders.edit') : t('globalProviders.add')}>
<div className="space-y-5">
<div className="space-y-4">
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('globalProviders.form.name')} *
</label>
<Input
value={form.name}
onChange={e => set('name', e.target.value)}
placeholder="e.g. minimaxi"
disabled={isEdit}
/>
</div>
{/* API Key */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
API Key
</label>
<div className="relative">
<Input
type={showKey ? 'text' : 'password'}
value={form.api_key || ''}
onChange={e => set('api_key', e.target.value)}
placeholder="sk-..."
className="pr-10"
/>
<button
type="button"
onClick={() => setShowKey(!showKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
{/* Agent Types */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('globalProviders.form.agentTypes')}
</label>
<div className="flex flex-wrap gap-2">
{['claudecode', 'codex', 'gemini', 'opencode', 'cursor', 'kimi', 'qoder', 'acp'].map(at => {
const selected = agents.includes(at);
return (
<button
key={at}
type="button"
onClick={() => {
set('agent_types', selected ? agents.filter(x => x !== at) : [...agents, at]);
}}
className={cn(
'px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors',
selected
? 'bg-accent/10 text-accent border-accent/30'
: 'bg-transparent text-gray-400 border-gray-200 dark:border-white/10 hover:border-gray-300',
)}
>
{at}
</button>
);
})}
</div>
<p className="mt-1 text-xs text-gray-400">{t('globalProviders.form.agentTypesHint')}</p>
</div>
{/* Base URL / Model / Models — flat when <= 1 agent, tabbed when >= 2 */}
{!multiAgent ? (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('globalProviders.form.baseUrl')}
</label>
<Input
value={form.base_url || ''}
onChange={e => set('base_url', e.target.value)}
placeholder="https://api.example.com/v1"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('globalProviders.form.model')}
</label>
<Input
value={form.model || ''}
onChange={e => set('model', e.target.value)}
placeholder="claude-sonnet-4-20250514"
/>
<p className="mt-1 text-xs text-gray-400">{t('globalProviders.form.modelHint')}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('globalProviders.form.models')}
</label>
<ModelListEditor
models={form.models || []}
onChange={models => set('models', models)}
defaultModel={form.model}
onSetDefault={model => set('model', model)}
/>
<p className="mt-1 text-xs text-gray-400">{t('globalProviders.form.modelsHint')}</p>
</div>
</>
) : (
<div className="rounded-xl border border-gray-200 dark:border-white/10 overflow-hidden">
<p className="px-3 pt-3 text-xs text-gray-400">{t('globalProviders.form.perAgentHint')}</p>
<div className="flex gap-1 px-3 pt-2 pb-0">
{agents.map(at => (
<button
key={at}
type="button"
onClick={() => setActiveAgentTab(at)}
className={cn(
'px-3 py-1.5 rounded-t-lg text-xs font-medium transition-colors',
(activeAgentTab || agents[0]) === at
? 'bg-white dark:bg-white/10 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300',
)}
>
{at}
</button>
))}
</div>
<div className="px-3 pb-3 bg-white dark:bg-white/[0.02]">
{agents.map(at => {
if ((activeAgentTab || agents[0]) !== at) return null;
return (
<AgentConfigEditor
key={at}
agentType={at}
config={perAgent[at] || { base_url: '', model: '', models: [] }}
onChange={cfg => updatePerAgent(at, cfg)}
t={t}
/>
);
})}
</div>
</div>
)}
{/* Thinking */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Thinking
</label>
<select
value={form.thinking || ''}
onChange={e => set('thinking', e.target.value)}
className={cn(
'w-full rounded-xl border px-3 py-2 text-sm outline-none transition-colors',
'border-gray-200 bg-white text-gray-900',
'dark:border-white/10 dark:bg-white/[0.04] dark:text-white',
'focus:border-accent focus:ring-1 focus:ring-accent/30',
)}
>
<option value="">{t('globalProviders.form.thinkingDefault')}</option>
<option value="enabled">enabled</option>
<option value="disabled">disabled</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={handleSubmit} disabled={!form.name || saving}>
{saving ? t('common.loading') : t('common.save')}
</Button>
</div>
</div>
</Modal>
);
}
/* ── CC-Switch Import Modal ── */
function CCSwitchImportModal({
existingNames,
onClose,
onImported,
t,
}: {
existingNames: Set<string>;
onClose: () => void;
onImported: () => void;
t: (key: string, opts?: any) => string;
}) {
const [providers, setProviders] = useState<CCSwitchProvider[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<{ imported: string[]; skipped: string[] } | null>(null);
useEffect(() => {
(async () => {
try {
const data = await listCCSwitchProviders();
if (!data.available) {
setError(data.error || t('globalProviders.ccSwitch.notFound'));
} else {
setProviders(data.providers || []);
const selectable = (data.providers || []).filter(p => !existingNames.has(p.name));
setSelected(new Set(selectable.map(p => p.name)));
}
} catch {
setError(t('globalProviders.ccSwitch.notFound'));
}
setLoading(false);
})();
}, []);
const toggle = (name: string) => {
setSelected(prev => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
};
const handleImport = async () => {
setImporting(true);
try {
const res = await importCCSwitchProviders([...selected]);
setResult(res);
onImported();
} catch { /* empty */ }
setImporting(false);
};
return (
<Modal open onClose={onClose} title={t('globalProviders.ccSwitch.title')}>
<div className="space-y-4">
{loading && (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent border-t-transparent" />
</div>
)}
{error && (
<div className="rounded-xl border border-amber-200 dark:border-amber-500/20 bg-amber-50 dark:bg-amber-900/10 p-4 text-sm text-amber-700 dark:text-amber-400">
{error}
</div>
)}
{!loading && !error && providers.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
{t('globalProviders.ccSwitch.empty')}
</p>
)}
{!loading && !error && providers.length > 0 && !result && (
<>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('globalProviders.ccSwitch.hint', { count: providers.length })}
</p>
<div className="max-h-72 overflow-y-auto space-y-1">
{providers.map(p => {
const exists = existingNames.has(p.name);
return (
<label
key={p.name}
className={cn(
'flex items-center gap-3 rounded-xl px-3 py-2.5 transition-colors cursor-pointer',
exists
? 'opacity-50 cursor-not-allowed'
: selected.has(p.name)
? 'bg-accent/10 dark:bg-accent/5'
: 'hover:bg-gray-50 dark:hover:bg-white/[0.04]',
)}
>
<input
type="checkbox"
checked={selected.has(p.name)}
disabled={exists}
onChange={() => !exists && toggle(p.name)}
className="rounded border-gray-300 dark:border-white/20 text-accent focus:ring-accent/30"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">
{p.name}
</span>
<Badge variant={p.app_type === 'claude' ? 'default' : 'info'}>
{p.app_type}
</Badge>
{p.is_current && <Badge variant="success">{t('globalProviders.ccSwitch.active')}</Badge>}
{exists && <Badge variant="warning">{t('globalProviders.ccSwitch.exists')}</Badge>}
</div>
<div className="text-xs text-gray-400 mt-0.5 truncate">
{p.model && <span>{p.model}</span>}
{p.model && p.base_url && <span> · </span>}
{p.base_url && <span>{p.base_url}</span>}
</div>
</div>
</label>
);
})}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={handleImport} disabled={selected.size === 0 || importing}>
{importing ? t('common.saving') : t('globalProviders.ccSwitch.import', { count: selected.size })}
</Button>
</div>
</>
)}
{result && (
<>
<div className="rounded-xl border border-green-200 dark:border-green-500/20 bg-green-50 dark:bg-green-900/10 p-4 text-sm text-green-700 dark:text-green-400">
{t('globalProviders.ccSwitch.result', { imported: result.imported?.length || 0, skipped: result.skipped?.length || 0 })}
</div>
<div className="flex justify-end">
<Button onClick={onClose}>{t('common.close')}</Button>
</div>
</>
)}
</div>
</Modal>
);
}
+582
View File
@@ -0,0 +1,582 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams, Link } from 'react-router-dom';
import {
ArrowLeft, Send, User, Bot, RotateCw, Circle, WifiOff,
Copy, Check, FileText, Image as ImageIcon, Loader2,
} from 'lucide-react';
import { Badge, Button } from '@/components/ui';
import { getSession, type SessionDetail } from '@/api/sessions';
import { useBridgeSocket, fetchBridgeConfig, type BridgeConfig, type BridgeIncoming, type BridgeStatus } from '@/hooks/useBridgeSocket';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import { cn } from '@/lib/utils';
// ── Markdown renderers ───────────────────────────────────────
function CopyButton({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-md bg-gray-200/80 dark:bg-gray-700/80 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-500 dark:text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity z-10"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
);
}
function PreBlock({ children, ...props }: React.HTMLAttributes<HTMLPreElement>) {
const codeEl = (children as any)?.props;
const lang = codeEl?.className?.replace(/^language-/, '') || '';
const code = typeof codeEl?.children === 'string' ? codeEl.children.replace(/\n$/, '') : '';
return (
<div className="not-prose relative group my-4">
{lang && (
<div className="absolute top-0 left-0 px-2.5 py-1 text-[10px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800 rounded-tl-lg rounded-br-lg border-b border-r border-gray-200 dark:border-gray-700 font-mono">
{lang}
</div>
)}
<CopyButton code={code} />
<pre className="overflow-x-auto rounded-lg bg-[#fafafa] dark:bg-[#0d1117] border border-gray-200 dark:border-gray-700/60 p-4 pt-8 text-[13px] leading-[1.6] font-mono" {...props}>
{children}
</pre>
</div>
);
}
function InlineCode({ children, className, ...props }: React.HTMLAttributes<HTMLElement>) {
if (className) return <code className={className} {...props}>{children}</code>;
return (
<code className="px-1.5 py-0.5 rounded-md bg-gray-100 dark:bg-gray-800 text-pink-600 dark:text-pink-400 text-[0.875em] font-mono border border-gray-200/60 dark:border-gray-700/40" {...props}>
{children}
</code>
);
}
function RenderMarkdown({ content }: { content: string }) {
return (
<div className={cn(
'prose max-w-none dark:prose-invert',
'prose-headings:font-semibold prose-headings:tracking-tight',
'prose-h1:text-xl prose-h1:mt-5 prose-h1:mb-3 prose-h1:pb-1.5 prose-h1:border-b prose-h1:border-gray-200 dark:prose-h1:border-gray-700',
'prose-h2:text-lg prose-h2:mt-5 prose-h2:mb-2',
'prose-h3:text-base prose-h3:mt-4 prose-h3:mb-2',
'prose-p:my-2.5 prose-p:leading-relaxed',
'prose-li:my-0.5', 'prose-ul:my-2 prose-ol:my-2',
'prose-a:text-accent prose-a:no-underline hover:prose-a:underline',
'prose-strong:text-gray-900 dark:prose-strong:text-white prose-strong:font-semibold',
'prose-blockquote:border-l-[3px] prose-blockquote:border-accent/40 prose-blockquote:bg-accent/[0.03] prose-blockquote:rounded-r-lg prose-blockquote:py-0.5 prose-blockquote:px-4 prose-blockquote:my-3 prose-blockquote:not-italic prose-blockquote:text-gray-600 dark:prose-blockquote:text-gray-300',
'prose-hr:my-5 prose-hr:border-gray-200 dark:prose-hr:border-gray-700',
'prose-table:text-sm prose-th:bg-gray-50 dark:prose-th:bg-gray-800 prose-th:px-3 prose-th:py-2 prose-td:px-3 prose-td:py-2',
'prose-img:rounded-lg prose-img:shadow-sm',
)}>
<Markdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]} components={{ pre: PreBlock as any, code: InlineCode as any }}>
{content}
</Markdown>
</div>
);
}
// ── Chat message types ───────────────────────────────────────
interface ChatMsg {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
format?: 'text' | 'markdown' | 'card' | 'buttons' | 'image' | 'file';
card?: any;
buttons?: { text: string; data: string }[][];
imageUrl?: string;
fileName?: string;
fileSize?: number;
streaming?: boolean;
timestamp?: string;
}
// ── Card renderer ────────────────────────────────────────────
function CardBlock({ card, onAction }: { card: any; onAction: (v: string) => void }) {
if (!card) return null;
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{card.header && (
<div className={cn('px-4 py-2.5 font-semibold text-sm text-white', colorToBg(card.header.color))}>
{card.header.title}
</div>
)}
<div className="p-4 space-y-3">
{card.elements?.map((el: any, i: number) => (
<CardElement key={i} el={el} onAction={onAction} />
))}
</div>
</div>
);
}
function CardElement({ el, onAction }: { el: any; onAction: (v: string) => void }) {
if (el.type === 'markdown') return <RenderMarkdown content={el.content} />;
if (el.type === 'divider') return <hr className="border-gray-200 dark:border-gray-700" />;
if (el.type === 'note') return <p className="text-xs text-gray-400">{el.text}</p>;
if (el.type === 'actions') {
return (
<div className="flex flex-wrap gap-2">
{el.buttons?.map((btn: any, j: number) => (
<button key={j} onClick={() => onAction(btn.value)} className={cn(
'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
btn.btn_type === 'primary' ? 'bg-accent text-black hover:bg-accent-dim' :
btn.btn_type === 'danger' ? 'bg-red-500 text-white hover:bg-red-600' :
'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700',
)}>
{btn.text}
</button>
))}
</div>
);
}
if (el.type === 'list_item') {
return (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-700 dark:text-gray-300">{el.text}</span>
<button onClick={() => onAction(el.btn_value)} className="px-2.5 py-1 rounded-lg text-xs font-medium bg-accent text-black hover:bg-accent-dim">
{el.btn_text}
</button>
</div>
);
}
if (el.type === 'select') {
return (
<select
defaultValue={el.init_value}
onChange={(e) => onAction(e.target.value)}
className="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
{el.options?.map((opt: any, j: number) => (
<option key={j} value={opt.value}>{opt.text}</option>
))}
</select>
);
}
return null;
}
function colorToBg(c?: string) {
const map: Record<string, string> = {
blue: 'bg-blue-600', green: 'bg-green-600', red: 'bg-red-600', orange: 'bg-orange-500',
purple: 'bg-purple-600', grey: 'bg-gray-600', turquoise: 'bg-teal-600', violet: 'bg-violet-600',
indigo: 'bg-indigo-600', wathet: 'bg-sky-500', yellow: 'bg-yellow-500', carmine: 'bg-rose-600',
};
return map[c || ''] || 'bg-gray-800';
}
// ── Buttons renderer ─────────────────────────────────────────
function ButtonsBlock({ content, buttons, onAction }: { content: string; buttons: { text: string; data: string }[][]; onAction: (v: string) => void }) {
return (
<div className="space-y-3">
<RenderMarkdown content={content} />
{buttons.map((row, i) => (
<div key={i} className="flex flex-wrap gap-2">
{row.map((btn, j) => (
<button key={j} onClick={() => onAction(btn.data)} className="px-3 py-1.5 rounded-lg text-xs font-medium bg-accent text-black hover:bg-accent-dim transition-colors">
{btn.text}
</button>
))}
</div>
))}
</div>
);
}
// ── File/Image attachments ───────────────────────────────────
function FileBlock({ name, size }: { name: string; size?: number }) {
return (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<FileText size={16} className="text-gray-400 shrink-0" />
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{name}</div>
{size !== undefined && <div className="text-xs text-gray-400">{(size / 1024).toFixed(1)} KB</div>}
</div>
</div>
);
}
function ImageBlock({ url }: { url: string }) {
return <img src={url} alt="" className="max-w-sm rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm" />;
}
// ── Connection status badge ──────────────────────────────────
function StatusBadge({ status }: { status: BridgeStatus }) {
const { t } = useTranslation();
if (status === 'connected') {
return (
<span className="flex items-center gap-1 text-[10px] text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 px-1.5 py-0.5 rounded-full">
<Circle size={5} className="fill-current" /> {t('sessions.bridgeConnected', 'connected')}
</span>
);
}
if (status === 'connecting' || status === 'registering') {
return (
<span className="flex items-center gap-1 text-[10px] text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 px-1.5 py-0.5 rounded-full">
<Loader2 size={9} className="animate-spin" /> {t('sessions.bridgeConnecting', 'connecting...')}
</span>
);
}
return (
<span className="flex items-center gap-1 text-[10px] text-gray-400 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded-full">
<WifiOff size={9} /> {t('sessions.bridgeDisconnected', 'disconnected')}
</span>
);
}
function SessionMsgCopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
onClick={handleCopy}
className="absolute -bottom-3 right-2 p-1 rounded-md bg-gray-100/90 dark:bg-gray-700/90 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 opacity-0 group-hover/msg:opacity-100 transition-opacity shadow-sm"
title="Copy"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
);
}
// ── Main component ───────────────────────────────────────────
export default function SessionChat() {
const { t } = useTranslation();
const { project, id } = useParams<{ project: string; id: string }>();
const [session, setSession] = useState<SessionDetail | null>(null);
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [loading, setLoading] = useState(true);
const [typing, setTyping] = useState(false);
const [bridgeCfg, setBridgeCfg] = useState<BridgeConfig | null>(null);
const messagesEnd = useRef<HTMLDivElement>(null);
const streamBufRef = useRef<Map<string, string>>(new Map());
const previewHandleCounter = useRef(0);
const sessionKey = session?.session_key || '';
// Load session data + bridge config
const fetchSession = useCallback(async () => {
if (!project || !id) return;
try {
setLoading(true);
const [data, cfg] = await Promise.all([
getSession(project, id, 200),
fetchBridgeConfig(),
]);
setSession(data);
setBridgeCfg(cfg);
if (data.history) {
setMessages(data.history.map((h, i) => ({
id: `hist-${i}`,
role: h.role as 'user' | 'assistant',
content: h.content,
format: 'markdown',
timestamp: h.timestamp,
})));
}
} finally {
setLoading(false);
}
}, [project, id]);
useEffect(() => { fetchSession(); }, [fetchSession]);
// Handle bridge incoming messages
const handleBridgeMessage = useCallback((msg: BridgeIncoming) => {
if (msg.type === 'reply') {
setMessages(prev => {
const streamIdx = prev.findIndex(m => m.streaming && m.role === 'assistant');
if (streamIdx >= 0) {
const updated = [...prev];
updated[streamIdx] = { ...updated[streamIdx], content: msg.content, format: (msg as any).format === 'markdown' ? 'markdown' : 'text', streaming: false };
return updated;
}
return [...prev, {
id: `reply-${Date.now()}`,
role: 'assistant',
content: msg.content,
format: (msg as any).format === 'markdown' ? 'markdown' : 'text',
}];
});
setTyping(false);
} else if (msg.type === 'reply_stream') {
const stream = msg as Extract<BridgeIncoming, { type: 'reply_stream' }>;
if (stream.done) {
setMessages(prev => {
const idx = prev.findIndex(m => m.streaming);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...updated[idx], content: stream.full_text, streaming: false };
return updated;
}
return [...prev, { id: `stream-done-${Date.now()}`, role: 'assistant', content: stream.full_text, format: 'markdown' }];
});
setTyping(false);
} else {
setMessages(prev => {
const idx = prev.findIndex(m => m.streaming);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...updated[idx], content: stream.full_text };
return updated;
}
return [...prev, { id: `stream-${Date.now()}`, role: 'assistant', content: stream.full_text, format: 'markdown', streaming: true }];
});
}
} else if (msg.type === 'card') {
const card = msg as Extract<BridgeIncoming, { type: 'card' }>;
setMessages(prev => [...prev, {
id: `card-${Date.now()}`,
role: 'assistant',
content: '',
format: 'card',
card: card.card,
}]);
setTyping(false);
} else if (msg.type === 'buttons') {
const btns = msg as Extract<BridgeIncoming, { type: 'buttons' }>;
setMessages(prev => [...prev, {
id: `btn-${Date.now()}`,
role: 'assistant',
content: btns.content,
format: 'buttons',
buttons: btns.buttons,
}]);
setTyping(false);
} else if (msg.type === 'typing_start') {
setTyping(true);
} else if (msg.type === 'typing_stop') {
setTyping(false);
} else if (msg.type === 'preview_start') {
const ps = msg as Extract<BridgeIncoming, { type: 'preview_start' }>;
const handle = `web-preview-${++previewHandleCounter.current}`;
sendPreviewAck(ps.ref_id, handle);
setMessages(prev => [...prev, {
id: `stream-${handle}`,
role: 'assistant',
content: ps.content,
format: 'markdown',
streaming: true,
}]);
} else if (msg.type === 'update_message') {
const um = msg as Extract<BridgeIncoming, { type: 'update_message' }>;
setMessages(prev => {
const idx = prev.findIndex(m => m.streaming);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...updated[idx], content: um.content };
return updated;
}
return prev;
});
} else if (msg.type === 'delete_message') {
setMessages(prev => {
const idx = prev.findIndex(m => m.streaming);
if (idx >= 0) {
return prev.filter((_, i) => i !== idx);
}
return prev;
});
}
}, []);
const { status: bridgeStatus, sendMessage: bridgeSend, sendCardAction, sendPreviewAck } = useBridgeSocket({
bridgeCfg,
sessionKey,
onMessage: handleBridgeMessage,
});
// Scroll to bottom on new messages
useEffect(() => {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, typing]);
const handleSend = useCallback(() => {
if (!input.trim() || bridgeStatus !== 'connected') return;
const content = input.trim();
setInput('');
setSending(true);
setMessages(prev => [...prev, {
id: `user-${Date.now()}`,
role: 'user',
content,
}]);
bridgeSend(content);
setTimeout(() => setSending(false), 300);
}, [input, bridgeStatus, bridgeSend]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleCardAction = useCallback((value: string) => {
if (bridgeStatus !== 'connected') return;
sendCardAction(value);
}, [bridgeStatus, sendCardAction]);
if (loading && !session) {
return <div className="flex items-center justify-center h-64 text-gray-400 animate-pulse">Loading...</div>;
}
const canSend = bridgeStatus === 'connected';
return (
<div className="flex flex-col flex-1 min-h-0 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-gray-200 dark:border-gray-800">
<div className="flex items-center gap-3">
<Link to="/sessions" className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
<ArrowLeft size={18} className="text-gray-400" />
</Link>
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{session?.name || id}</h2>
<StatusBadge status={bridgeStatus} />
</div>
<div className="flex items-center gap-2 mt-0.5">
<Badge>{project}</Badge>
{session?.platform && <Badge variant="info">{session.platform}</Badge>}
<span className="text-xs text-gray-500">{session?.session_key}</span>
</div>
</div>
</div>
<Button size="sm" variant="ghost" onClick={fetchSession}>
<RotateCw size={14} /> {t('common.refresh')}
</Button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto py-6 space-y-5">
{messages.length === 0 && !loading && (
<p className="text-center text-sm text-gray-400 py-12">{t('sessions.noMessages')}</p>
)}
{messages.map((msg) => {
const isUser = msg.role === 'user';
const isEmpty = !msg.content && !msg.card && !msg.buttons && !msg.imageUrl && !msg.fileName;
return (
<div key={msg.id} className={cn('flex gap-3', isUser ? 'justify-end' : 'justify-start')}>
{!isUser && (
<div className="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 mt-1">
<Bot size={16} className="text-accent" />
</div>
)}
<div className={cn(
'group/msg relative rounded-2xl px-5 py-3.5 text-sm',
isUser
? 'max-w-[70%] bg-accent text-black rounded-br-md'
: 'max-w-[85%] bg-white dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700/60 text-gray-900 dark:text-gray-100 rounded-bl-md shadow-sm',
msg.streaming && 'animate-pulse-subtle',
)}>
{isEmpty ? (
<p className="text-xs text-gray-400 dark:text-gray-500 italic">{t('chat.unsupportedMessage', '[Unsupported message]')}</p>
) : msg.format === 'card' ? (
<CardBlock card={msg.card} onAction={handleCardAction} />
) : msg.format === 'buttons' && msg.buttons ? (
<ButtonsBlock content={msg.content} buttons={msg.buttons} onAction={handleCardAction} />
) : msg.format === 'image' && msg.imageUrl ? (
<ImageBlock url={msg.imageUrl} />
) : msg.format === 'file' && msg.fileName ? (
<FileBlock name={msg.fileName} size={msg.fileSize} />
) : isUser ? (
<div className="whitespace-pre-wrap">{msg.content}</div>
) : (
<RenderMarkdown content={msg.content} />
)}
{msg.streaming && (
<span className="inline-block w-1.5 h-4 bg-accent/60 rounded-sm ml-0.5 animate-pulse" />
)}
{!isUser && !msg.streaming && msg.content && (
<SessionMsgCopyButton text={msg.content} />
)}
</div>
{isUser && (
<div className="w-8 h-8 rounded-lg bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0 mt-1">
<User size={16} className="text-gray-500" />
</div>
)}
</div>
);
})}
{typing && !messages.some(m => m.streaming) && (
<div className="flex gap-3 justify-start">
<div className="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 mt-1">
<Bot size={16} className="text-accent" />
</div>
<div className="rounded-2xl px-5 py-3.5 text-sm bg-white dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700/60 rounded-bl-md shadow-sm">
<div className="flex gap-1.5">
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
</div>
)}
<div ref={messagesEnd} />
</div>
{/* Input */}
<div className="border-t border-gray-200 dark:border-gray-800 pt-3 pb-1 shrink-0">
{canSend ? (
<div className="flex gap-3">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('sessions.messageInput')}
className="flex-1 px-4 py-3 text-sm rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent transition-colors placeholder:text-gray-400"
disabled={sending}
/>
<button
onClick={handleSend}
disabled={sending || !input.trim()}
className="px-4 py-3 rounded-xl bg-accent text-black hover:bg-accent-dim transition-colors disabled:opacity-50 flex items-center gap-2"
>
{sending ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Send size={18} />
)}
</button>
</div>
) : !bridgeCfg ? (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-xl">
<WifiOff size={14} />
<span>{t('sessions.bridgeNotAvailable', 'Bridge not available. Enable [bridge] in config.toml to chat from web.')}</span>
</div>
) : bridgeStatus === 'disconnected' || bridgeStatus === 'error' ? (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-xl">
<WifiOff size={14} />
<span>{t('sessions.bridgeDisconnected', 'Bridge disconnected.')}</span>
</div>
) : (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded-xl">
<Loader2 size={14} className="animate-spin" />
<span>{t('sessions.bridgeConnecting', 'Connecting to bridge...')}</span>
</div>
)}
</div>
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { MessageSquare, Circle, Filter, User, Bot } from 'lucide-react';
import { Badge, EmptyState } from '@/components/ui';
import { listProjects, type ProjectSummary } from '@/api/projects';
import { listSessions, type Session } from '@/api/sessions';
import { cn } from '@/lib/utils';
interface FlatSession extends Session {
_project: string;
}
function timeAgo(iso: string, t: (k: string) => string): string {
if (!iso) return '';
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return t('sessions.justNow');
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
return `${days}d`;
}
export default function SessionList() {
const { t } = useTranslation();
const [allData, setAllData] = useState<{ project: string; sessions: Session[] }[]>([]);
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [selectedProject, setSelectedProject] = useState<string>('');
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const { projects: projs } = await listProjects();
setProjects(projs || []);
const results = await Promise.all(
(projs || []).map(async (p) => {
try {
const { sessions } = await listSessions(p.name);
return { project: p.name, sessions: sessions || [] };
} catch {
return { project: p.name, sessions: [] };
}
})
);
setAllData(results);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
const handler = () => fetchData();
window.addEventListener('cc:refresh', handler);
return () => window.removeEventListener('cc:refresh', handler);
}, [fetchData]);
const filtered = useMemo<FlatSession[]>(() => {
const src = selectedProject
? allData.filter((d) => d.project === selectedProject)
: allData;
return src
.flatMap((d) => d.sessions.map((s) => ({ ...s, _project: d.project })))
.sort((a, b) => (b.updated_at || b.created_at || '').localeCompare(a.updated_at || a.created_at || ''));
}, [allData, selectedProject]);
if (loading && allData.length === 0) {
return <div className="flex items-center justify-center h-64 text-gray-400 animate-pulse">Loading...</div>;
}
return (
<div className="space-y-4 animate-fade-in ">
{/* Filter bar */}
<div className="flex items-center gap-3">
<Filter size={16} className="text-gray-400" />
<select
value={selectedProject}
onChange={(e) => setSelectedProject(e.target.value)}
className="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-accent/50"
>
<option value="">{t('sessions.allProjects')}</option>
{projects.map((p) => (
<option key={p.name} value={p.name}>{p.name}</option>
))}
</select>
<span className="text-xs text-gray-400">
{filtered.length} {t('nav.sessions').toLowerCase()}
</span>
</div>
{filtered.length === 0 ? (
<EmptyState message={t('sessions.noSessions')} icon={MessageSquare} />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-3">
{filtered.map((s) => (
<Link key={`${s._project}-${s.id}`} to={`/sessions/${s._project}/${s.id}`}>
<div className={cn(
'group relative rounded-xl border p-4 transition-all duration-200 cursor-pointer h-full',
'bg-white/60 dark:bg-white/[0.03] backdrop-blur-sm',
'border-gray-200/80 dark:border-white/[0.06]',
'hover:border-accent/40 hover:shadow-md hover:shadow-accent/5 hover:-translate-y-0.5',
)}>
{/* Top: name + time */}
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-1.5 min-w-0">
<MessageSquare size={14} className={s.live ? 'text-accent shrink-0' : 'text-gray-400 shrink-0'} />
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">
{s.name || s.user_name || s.id.slice(0, 8)}
</span>
{s.live && <Circle size={5} className="fill-emerald-500 text-emerald-500 shrink-0" />}
</div>
<span className="text-[10px] text-gray-400 shrink-0 mt-0.5">
{timeAgo(s.updated_at || s.created_at, t)}
</span>
</div>
{/* Last message preview */}
<div className="mb-2.5 min-h-[2.5rem]">
{s.last_message ? (
<p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 leading-relaxed">
{s.last_message.role === 'user' ? (
<User size={10} className="inline mr-1 -mt-0.5 opacity-60" />
) : (
<Bot size={10} className="inline mr-1 -mt-0.5 opacity-60" />
)}
{s.last_message.content.replace(/\n/g, ' ').slice(0, 100)}
</p>
) : (
<p className="text-xs text-gray-400 dark:text-gray-500 italic">{t('sessions.noMessages')}</p>
)}
</div>
{/* Bottom: badges + count */}
<div className="flex items-center gap-1.5 flex-wrap">
<Badge>{s._project}</Badge>
{s.platform && <Badge variant="info">{s.platform}</Badge>}
<span className="text-[10px] text-gray-400 ml-auto">{s.history_count} msgs</span>
</div>
</div>
</Link>
))}
</div>
)}
</div>
);
}
+403
View File
@@ -0,0 +1,403 @@
import { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
Sparkles, Star, ExternalLink, FolderOpen, Puzzle, RefreshCw,
} from 'lucide-react';
import { Card, Badge, Button } from '@/components/ui';
import {
listSkills, fetchSkillPresets,
type ProjectSkills, type SkillPreset,
} from '@/api/skills';
import { cn } from '@/lib/utils';
type Tab = 'local' | 'recommended';
export default function SkillList() {
const { t, i18n } = useTranslation();
const [tab, setTab] = useState<Tab>('local');
const [projects, setProjects] = useState<ProjectSkills[]>([]);
const [presets, setPresets] = useState<SkillPreset[]>([]);
const [loading, setLoading] = useState(true);
const [presetsLoading, setPresetsLoading] = useState(false);
const [activeProject, setActiveProject] = useState('');
const refresh = useCallback(async () => {
setLoading(true);
try {
const data = await listSkills();
const ps = data.projects || [];
setProjects(ps);
if (ps.length > 0 && !activeProject) setActiveProject(ps[0].project);
} catch { /* empty */ }
setLoading(false);
}, [activeProject]);
const loadPresets = useCallback(async () => {
setPresetsLoading(true);
try {
const data = await fetchSkillPresets();
setPresets(data.skills || []);
} catch { /* empty */ }
setPresetsLoading(false);
}, []);
useEffect(() => { refresh(); }, []);
useEffect(() => {
if (tab === 'recommended' && presets.length === 0) loadPresets();
}, [tab, presets.length, loadPresets]);
const current = projects.find(p => p.project === activeProject);
const lang = i18n.language || 'en';
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
{t('skills.title')}
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t('skills.subtitle')}
</p>
</div>
{/* Tabs + refresh on the same row */}
<div className="flex items-center justify-between">
<div className="flex gap-1 p-1 rounded-xl bg-gray-100 dark:bg-white/[0.06] w-fit">
{(['local', 'recommended'] as const).map(key => (
<button
key={key}
onClick={() => setTab(key)}
className={cn(
'px-4 py-1.5 rounded-lg text-sm font-medium transition-all',
tab === key
? 'bg-white dark:bg-white/10 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300',
)}
>
{t(`skills.tab.${key}`)}
</button>
))}
</div>
{tab === 'recommended' && (
<Button variant="ghost" size="sm" onClick={loadPresets}>
<RefreshCw size={14} className="mr-1.5" />
{t('common.refresh')}
</Button>
)}
</div>
{tab === 'local' && (
<LocalSkills
projects={projects}
current={current}
activeProject={activeProject}
onSelectProject={setActiveProject}
loading={loading}
t={t}
/>
)}
{tab === 'recommended' && (
<RecommendedSkills
presets={presets}
loading={presetsLoading}
onRefresh={loadPresets}
t={t}
lang={lang}
/>
)}
</div>
);
}
/* ── Local Skills ── */
function LocalSkills({
projects, current, activeProject, onSelectProject, loading, t,
}: {
projects: ProjectSkills[];
current?: ProjectSkills;
activeProject: string;
onSelectProject: (name: string) => void;
loading: boolean;
t: (k: string, opts?: any) => string;
}) {
if (loading) {
return (
<div className="flex justify-center py-16">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent border-t-transparent" />
</div>
);
}
if (projects.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Puzzle size={40} className="text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{t('skills.noSkills')}</p>
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">{t('skills.noSkillsHint')}</p>
</div>
);
}
return (
<div className="flex gap-6 items-start">
<div className="w-48 shrink-0 sticky top-4 max-h-[calc(100vh-8rem)] overflow-y-auto">
<p className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2 px-2">
{t('skills.projects')}
</p>
<div className="space-y-0.5">
{projects.map(p => (
<button
key={p.project}
onClick={() => onSelectProject(p.project)}
className={cn(
'w-full flex items-center gap-2.5 px-3 py-2 rounded-xl text-sm transition-all text-left',
activeProject === p.project
? 'bg-accent/10 text-accent font-medium'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/[0.06]',
)}
>
<FolderOpen size={15} className="shrink-0" />
<div className="min-w-0 flex-1">
<div className="truncate">{p.project}</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">{p.agent_type}</div>
</div>
<Badge variant="outline" className="text-[10px] shrink-0">{p.skills?.length || 0}</Badge>
</button>
))}
</div>
</div>
<div className="flex-1 min-w-0">
{current && (
<>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-sm font-medium text-gray-900 dark:text-white">
{current.project}
</h2>
<Badge variant="info">{current.agent_type}</Badge>
<span className="text-xs text-gray-400">
{t('skills.skillCount', { count: current.skills?.length || 0 })}
</span>
</div>
{current.dirs && current.dirs.length > 0 && (
<div className="mb-4 rounded-xl bg-gray-50 dark:bg-white/[0.03] border border-gray-100 dark:border-white/[0.06] px-3 py-2">
<p className="text-[11px] font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">
{t('skills.scanDirs')}
</p>
{current.dirs.map(d => (
<p key={d} className="text-xs text-gray-500 dark:text-gray-400 font-mono truncate">{d}</p>
))}
</div>
)}
{(!current.skills || current.skills.length === 0) ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Puzzle size={36} className="text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-sm text-gray-500 dark:text-gray-400">{t('skills.emptyProject')}</p>
</div>
) : (
<div className="space-y-2">
{current.skills.map(s => (
<Card key={s.name + s.source} className="!py-3">
<div className="flex items-start gap-3">
<div
className={cn(
'mt-0.5 w-8 h-8 rounded-lg flex items-center justify-center shrink-0',
'bg-purple-50 dark:bg-purple-500/10 text-purple-500 dark:text-purple-400',
)}
>
<Puzzle size={16} />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
{s.display_name || s.name}
</h3>
{s.description && (
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{s.description}
</p>
)}
<p className="mt-1 text-[11px] text-gray-400 dark:text-gray-500 font-mono truncate">
{s.source}
</p>
</div>
</div>
</Card>
))}
</div>
)}
</>
)}
</div>
</div>
);
}
/* ── Recommended Skills ── */
function RecommendedSkills({
presets, loading, onRefresh, t, lang,
}: {
presets: SkillPreset[];
loading: boolean;
onRefresh: () => void;
t: (k: string, opts?: any) => string;
lang: string;
}) {
if (loading) {
return (
<div className="flex justify-center py-16">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent border-t-transparent" />
</div>
);
}
if (presets.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Sparkles size={40} className="text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{t('skills.noPresets')}</p>
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">{t('skills.noPresetsHint')}</p>
<Button variant="ghost" onClick={onRefresh} className="mt-3">
<RefreshCw size={14} className="mr-1.5" />
{t('common.refresh')}
</Button>
</div>
);
}
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{presets.map(s => (
<SkillPresetCard key={s.name} skill={s} t={t} lang={lang} />
))}
</div>
);
}
/* ── Pricing Badge ── */
function PricingBadge({ pricing, t }: { pricing: SkillPreset['pricing']; t: (k: string) => string }) {
if (!pricing) return null;
if (pricing.type === 'free') {
return (
<span className={cn(
'inline-flex items-center px-2 py-0.5 rounded-md text-xs font-semibold',
'bg-green-50 text-green-600 dark:bg-green-500/10 dark:text-green-400',
)}>
{t('skills.free')}
</span>
);
}
if (pricing.type === 'freemium') {
return (
<span className={cn(
'inline-flex items-center px-2 py-0.5 rounded-md text-xs font-semibold',
'bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400',
)}>
{t('skills.freemium')}
</span>
);
}
const price = pricing.price ?? 0;
const currency = pricing.currency || 'USD';
const symbol = currency === 'CNY' ? '¥' : currency === 'EUR' ? '€' : '$';
return (
<span className={cn(
'inline-flex items-center px-2 py-0.5 rounded-md text-xs font-semibold',
'bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400',
)}>
{symbol}{price}
</span>
);
}
/* ── Skill Card ── */
function SkillPresetCard({
skill: s, t, lang,
}: {
skill: SkillPreset;
t: (k: string) => string;
lang: string;
}) {
return (
<Card className="relative overflow-hidden flex flex-col">
{s.featured && (
<div className="absolute top-0 right-0 bg-amber-400/90 text-white text-[10px] font-bold px-2 py-0.5 rounded-bl-lg">
<Star size={10} className="inline mr-0.5 -mt-0.5" />
</div>
)}
{/* Body */}
<div className="space-y-3 flex-1">
<div>
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-medium text-gray-900 dark:text-white">
{s.display_name || s.name}
</h3>
{s.version && (
<Badge variant="outline" className="text-[10px]">{s.version}</Badge>
)}
<PricingBadge pricing={s.pricing} t={t} />
</div>
{(s.description || s.description_zh) && (
<p className="mt-1.5 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{lang.startsWith('zh') && s.description_zh ? s.description_zh : s.description}
</p>
)}
</div>
{s.tags && s.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{s.tags.map(tag => (
<Badge key={tag} variant="success" className="text-xs">{tag}</Badge>
))}
</div>
)}
</div>
{/* Footer: source + author left, download right */}
<div className="flex items-center justify-between mt-4 pt-3 border-t border-gray-100 dark:border-white/[0.06]">
<div className="flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500 min-w-0">
{s.source && (
<span className="inline-flex items-center gap-1 shrink-0">
{t('skills.source')}:
{s.source.url ? (
<a
href={s.source.url}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
{s.source.name || s.source.provider}
</a>
) : (
<span>{s.source.name || s.source.provider}</span>
)}
</span>
)}
{s.author && (
<span className="truncate">{t('skills.author')}: {s.author}</span>
)}
</div>
{s.url && (
<a
href={s.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-accent hover:bg-accent/10 transition-colors shrink-0"
>
{t('skills.download')} <ExternalLink size={12} />
</a>
)}
</div>
</Card>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { FileCode, RefreshCw, RotateCcw, Settings2, ChevronDown, ChevronRight } from 'lucide-react';
import { Card, Button } from '@/components/ui';
import { restartSystem, reloadConfig } from '@/api/status';
import api from '@/api/client';
import GlobalSettings from './GlobalSettings';
export default function SystemConfig() {
const { t } = useTranslation();
const [content, setContent] = useState('');
const [format, setFormat] = useState<'toml' | 'json'>('toml');
const [loading, setLoading] = useState(true);
const [actionMsg, setActionMsg] = useState('');
const [showRaw, setShowRaw] = useState(false);
const fetchConfig = useCallback(async () => {
setLoading(true);
try {
const text = await api.raw('/config');
const trimmed = text.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
const obj = JSON.parse(trimmed);
setContent(JSON.stringify(obj, null, 2));
setFormat('json');
} catch {
setContent(text);
setFormat('toml');
}
} else {
setContent(text);
setFormat('toml');
}
} catch {
setContent('');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchConfig();
const handler = () => fetchConfig();
window.addEventListener('cc:refresh', handler);
return () => window.removeEventListener('cc:refresh', handler);
}, [fetchConfig]);
const handleRestart = async () => {
if (!confirm(t('system.restartConfirm'))) return;
try {
await restartSystem();
setActionMsg(t('common.success'));
} catch (e: any) {
setActionMsg(e.message);
}
};
const handleReload = async () => {
if (!confirm(t('system.reloadConfirm'))) return;
try {
await reloadConfig();
setActionMsg(t('common.success'));
fetchConfig();
} catch (e: any) {
setActionMsg(e.message);
}
};
return (
<div className="space-y-6 animate-fade-in ">
{/* Actions */}
<div className="flex flex-wrap gap-3">
<Button variant="secondary" onClick={handleReload}><RefreshCw size={16} /> {t('system.reload')}</Button>
<Button variant="danger" onClick={handleRestart}><RotateCcw size={16} /> {t('system.restart')}</Button>
</div>
{actionMsg && (
<div className="text-sm text-accent bg-accent/10 border border-accent/20 rounded-lg px-4 py-2">{actionMsg}</div>
)}
{/* Global Settings */}
<div>
<div className="flex items-center gap-2 mb-4">
<Settings2 size={16} className="text-gray-400" />
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">{t('settings.title', 'Global Settings')}</h2>
</div>
<GlobalSettings />
</div>
{/* Raw Config (collapsible) */}
<Card>
<button
type="button"
onClick={() => setShowRaw(!showRaw)}
className="flex items-center gap-2 w-full text-left"
>
{showRaw ? <ChevronDown size={16} className="text-gray-400" /> : <ChevronRight size={16} className="text-gray-400" />}
<FileCode size={16} className="text-gray-400" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{t('system.rawConfig', 'Raw Config')}</h3>
<span className="text-[10px] font-mono text-gray-400 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded uppercase">
{format}
</span>
</button>
{showRaw && (
<div className="mt-3">
{loading ? (
<div className="text-gray-400 animate-pulse text-sm">Loading...</div>
) : (
<pre className="text-xs text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 overflow-auto max-h-[65vh] font-mono leading-relaxed whitespace-pre">
{content || t('common.noData')}
</pre>
)}
</div>
)}
</Card>
</div>
);
}
+271
View File
@@ -0,0 +1,271 @@
import { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Save, Loader2 } from 'lucide-react';
import { Card, Button, Input } from '@/components/ui';
import { getGlobalSettings, updateGlobalSettings, type GlobalSettings as GS } from '@/api/settings';
import { cn } from '@/lib/utils';
const LOG_LEVELS = ['debug', 'info', 'warn', 'error'];
const ATTACHMENT_OPTS = ['', 'on', 'off'];
const LANGUAGES = ['en', 'zh', 'zh-TW', 'ja', 'es'];
function Toggle({ value, onChange, label, hint }: { value: boolean; onChange: (v: boolean) => void; label: string; hint?: string }) {
return (
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">{label}</label>
{hint && <p className="text-[11px] text-gray-400 mt-0.5">{hint}</p>}
</div>
<button
type="button"
onClick={() => onChange(!value)}
className={cn(
'w-10 h-6 rounded-full transition-colors shrink-0',
value ? 'bg-accent' : 'bg-gray-300 dark:bg-gray-700',
)}
>
<div className={cn('w-4 h-4 bg-white rounded-full transition-transform mx-1', value ? 'translate-x-4' : 'translate-x-0')} />
</button>
</div>
);
}
function Select({ value, onChange, label, options, hint }: { value: string; onChange: (v: string) => void; label: string; options: { value: string; label: string }[]; hint?: string }) {
return (
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">{label}</label>
{hint && <p className="text-[11px] text-gray-400 mb-1.5">{hint}</p>}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-accent/50"
>
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
);
}
function NumberInput({ value, onChange, label, hint, min, max }: { value: number; onChange: (v: number) => void; label: string; hint?: string; min?: number; max?: number }) {
return (
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">{label}</label>
{hint && <p className="text-[11px] text-gray-400 mb-1.5">{hint}</p>}
<input
type="number"
value={value}
min={min}
max={max}
onChange={(e) => onChange(parseInt(e.target.value, 10) || 0)}
className="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-accent/50"
/>
</div>
);
}
export default function GlobalSettings() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
const [language, setLanguage] = useState('en');
const [attachmentSend, setAttachmentSend] = useState('');
const [logLevel, setLogLevel] = useState('info');
const [idleTimeout, setIdleTimeout] = useState(120);
const [thinkingMessages, setThinkingMessages] = useState(true);
const [thinkingMaxLen, setThinkingMaxLen] = useState(300);
const [toolMessages, setToolMessages] = useState(true);
const [toolMaxLen, setToolMaxLen] = useState(500);
const [spEnabled, setSpEnabled] = useState(true);
const [spInterval, setSpInterval] = useState(1500);
const [rlMax, setRlMax] = useState(20);
const [rlWindow, setRlWindow] = useState(60);
const load = useCallback(async () => {
setLoading(true);
try {
const s = await getGlobalSettings();
setLanguage(s.language || 'en');
setAttachmentSend(s.attachment_send || '');
setLogLevel(s.log_level || 'info');
setIdleTimeout(s.idle_timeout_mins ?? 120);
setThinkingMessages(s.thinking_messages ?? true);
setThinkingMaxLen(s.thinking_max_len ?? 300);
setToolMessages(s.tool_messages ?? true);
setToolMaxLen(s.tool_max_len ?? 500);
setSpEnabled(s.stream_preview_enabled ?? true);
setSpInterval(s.stream_preview_interval_ms ?? 1500);
setRlMax(s.rate_limit_max_messages ?? 20);
setRlWindow(s.rate_limit_window_secs ?? 60);
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const handleSave = async () => {
setSaving(true);
setMsg('');
try {
await updateGlobalSettings({
language,
attachment_send: attachmentSend,
log_level: logLevel,
idle_timeout_mins: idleTimeout,
thinking_messages: thinkingMessages,
thinking_max_len: thinkingMaxLen,
tool_messages: toolMessages,
tool_max_len: toolMaxLen,
stream_preview_enabled: spEnabled,
stream_preview_interval_ms: spInterval,
rate_limit_max_messages: rlMax,
rate_limit_window_secs: rlWindow,
});
setMsg(t('common.success'));
setTimeout(() => setMsg(''), 3000);
} catch (e: any) {
setMsg(e.message || 'Error');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<Card>
<div className="flex items-center gap-2 text-gray-400 animate-pulse py-8 justify-center">
<Loader2 size={16} className="animate-spin" /> {t('common.loading', 'Loading...')}
</div>
</Card>
);
}
return (
<div className="space-y-5">
{/* General */}
<Card>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">{t('settings.general', 'General')}</h3>
<div className="space-y-4 max-w-lg">
<Select
label={t('settings.language', 'Language')}
value={language}
onChange={setLanguage}
options={LANGUAGES.map((l) => ({ value: l, label: l }))}
/>
<Select
label={t('settings.attachmentSend', 'Attachment send')}
value={attachmentSend}
onChange={setAttachmentSend}
hint={t('settings.attachmentSendHint', 'Send file/image attachments back to platform')}
options={ATTACHMENT_OPTS.map((v) => ({ value: v, label: v || t('settings.default', 'default') }))}
/>
<NumberInput
label={t('settings.idleTimeout', 'Idle timeout (min)')}
value={idleTimeout}
onChange={setIdleTimeout}
min={0}
hint={t('settings.idleTimeoutHint', 'Auto-stop agent after N minutes of inactivity; 0 = disabled')}
/>
</div>
</Card>
{/* Display */}
<Card>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">{t('settings.display', 'Display')}</h3>
<div className="space-y-4 max-w-lg">
<Toggle
label={t('settings.thinkingMessages', 'Thinking messages')}
value={thinkingMessages}
onChange={setThinkingMessages}
hint={t('settings.thinkingMessagesHint', 'Show or hide intermediate thinking messages')}
/>
<NumberInput
label={t('settings.thinkingMaxLen', 'Thinking max length')}
value={thinkingMaxLen}
onChange={setThinkingMaxLen}
min={0}
hint={t('settings.thinkingMaxLenHint', 'Max characters for thinking messages; 0 = no truncation')}
/>
<Toggle
label={t('settings.toolMessages', 'Tool progress')}
value={toolMessages}
onChange={setToolMessages}
hint={t('settings.toolMessagesHint', 'Show or hide tool progress messages')}
/>
<NumberInput
label={t('settings.toolMaxLen', 'Tool max length')}
value={toolMaxLen}
onChange={setToolMaxLen}
min={0}
hint={t('settings.toolMaxLenHint', 'Max characters for tool use messages; 0 = no truncation')}
/>
</div>
</Card>
{/* Stream preview */}
<Card>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">{t('settings.streamPreview', 'Stream preview')}</h3>
<div className="space-y-4 max-w-lg">
<Toggle label={t('settings.streamPreviewEnabled', 'Enable')} value={spEnabled} onChange={setSpEnabled} hint={t('settings.streamPreviewEnabledHint', 'Show real-time streaming updates in IM')} />
<NumberInput
label={t('settings.streamPreviewInterval', 'Interval (ms)')}
value={spInterval}
onChange={setSpInterval}
min={100}
hint={t('settings.streamPreviewIntervalHint', 'Minimum milliseconds between preview updates')}
/>
</div>
</Card>
{/* Rate limit */}
<Card>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">{t('settings.rateLimit', 'Rate limit')}</h3>
<div className="space-y-4 max-w-lg">
<NumberInput
label={t('settings.rlMaxMessages', 'Max messages')}
value={rlMax}
onChange={setRlMax}
min={0}
hint={t('settings.rlMaxMessagesHint', 'Max messages per window; 0 = disabled')}
/>
<NumberInput
label={t('settings.rlWindowSecs', 'Window (sec)')}
value={rlWindow}
onChange={setRlWindow}
min={1}
hint={t('settings.rlWindowSecsHint', 'Time window in seconds')}
/>
</div>
</Card>
{/* Log */}
<Card>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">{t('settings.log', 'Log')}</h3>
<div className="space-y-4 max-w-lg">
<Select
label={t('settings.logLevel', 'Log level')}
value={logLevel}
onChange={setLogLevel}
options={LOG_LEVELS.map((l) => ({ value: l, label: l }))}
/>
</div>
</Card>
{/* Save */}
<div className="max-w-lg">
<Button loading={saving} onClick={handleSave}>
<Save size={16} /> {t('common.save')}
</Button>
{msg && (
<p className={cn('text-sm mt-2', msg === t('common.success') ? 'text-accent' : 'text-red-500')}>{msg}</p>
)}
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { create } from 'zustand';
import { api } from '@/api/client';
interface AuthState {
token: string;
serverUrl: string;
isAuthenticated: boolean;
login: (token: string, serverUrl?: string) => void;
logout: () => void;
init: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
token: '',
serverUrl: '',
isAuthenticated: false,
login: (token: string, serverUrl?: string) => {
api.setToken(token);
localStorage.setItem('cc_token', token);
if (serverUrl) localStorage.setItem('cc_server_url', serverUrl);
set({ token, serverUrl: serverUrl || '', isAuthenticated: true });
},
logout: () => {
api.setToken('');
localStorage.removeItem('cc_token');
localStorage.removeItem('cc_server_url');
set({ token: '', serverUrl: '', isAuthenticated: false });
},
init: () => {
const token = localStorage.getItem('cc_token') || '';
const serverUrl = localStorage.getItem('cc_server_url') || '';
if (token) {
api.setToken(token);
set({ token, serverUrl, isAuthenticated: true });
}
},
}));
+38
View File
@@ -0,0 +1,38 @@
import { create } from 'zustand';
type Theme = 'light' | 'dark' | 'system';
interface ThemeState {
theme: Theme;
resolved: 'light' | 'dark';
setTheme: (t: Theme) => void;
init: () => void;
}
function resolveTheme(theme: Theme): 'light' | 'dark' {
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return theme;
}
function applyTheme(resolved: 'light' | 'dark') {
document.documentElement.classList.toggle('dark', resolved === 'dark');
}
export const useThemeStore = create<ThemeState>((set) => ({
theme: 'dark',
resolved: 'dark',
setTheme: (theme: Theme) => {
const resolved = resolveTheme(theme);
localStorage.setItem('cc_theme', theme);
applyTheme(resolved);
set({ theme, resolved });
},
init: () => {
const saved = (localStorage.getItem('cc_theme') as Theme) || 'dark';
const resolved = resolveTheme(saved);
applyTheme(resolved);
set({ theme: saved, resolved });
},
}));
+36
View File
@@ -0,0 +1,36 @@
import type { Config } from 'tailwindcss'
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
accent: {
DEFAULT: 'rgb(var(--color-accent) / <alpha-value>)',
dim: 'rgb(var(--color-accent-dim) / <alpha-value>)',
},
},
animation: {
'fade-in': 'fadeIn 0.4s ease',
'slide-up': 'slideUp 0.4s ease',
'float-in': 'floatIn 0.4s ease',
},
keyframes: {
fadeIn: {
from: { opacity: '0', transform: 'scale(0.96) translateY(20px)' },
to: { opacity: '1', transform: 'scale(1)' },
},
slideUp: {
from: { opacity: '0', transform: 'translateY(10px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
floatIn: {
from: { opacity: '0', transform: 'translateY(10px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
},
},
},
plugins: [require('@tailwindcss/typography')],
} satisfies Config
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src", "vite-env.d.ts"]
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 9821,
proxy: {
'/api': {
target: 'http://localhost:9820',
changeOrigin: true,
timeout: 45000,
},
'/bridge': {
target: 'http://localhost:9810',
changeOrigin: true,
ws: true,
},
},
},
})