初始化仓库
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{"onlyBuiltDependencies":["esbuild"]}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
//go:build no_web
|
||||
|
||||
package web
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+2773
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
onlyBuiltDependencies: esbuild
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
@@ -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}`);
|
||||
@@ -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 });
|
||||
@@ -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';
|
||||
@@ -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}`);
|
||||
@@ -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 });
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "システムに合わせる"
|
||||
}
|
||||
}
|
||||
@@ -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": "跟隨系統"
|
||||
}
|
||||
}
|
||||
@@ -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": "跟随系统"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user