Initial commit: MaskanX frontend
Independent React, TypeScript and Vite web application for MaskanX. Includes the chat console, agent and persona configuration, MCP client management, model and provider settings, cron jobs, sessions, and diagnostics. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.vite
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.production.example
|
||||
*.log
|
||||
@@ -0,0 +1,2 @@
|
||||
VITE_MASKANX_API_URL=https://api-dev.example.com
|
||||
VITE_MASKANX_PROXY_TARGET=http://127.0.0.1:8088
|
||||
@@ -0,0 +1,2 @@
|
||||
VITE_MASKANX_API_URL=http://127.0.0.1:8088
|
||||
VITE_MASKANX_PROXY_TARGET=http://127.0.0.1:8088
|
||||
@@ -0,0 +1,3 @@
|
||||
# Leave the public URL empty to use the Vite /api proxy locally.
|
||||
VITE_MASKANX_API_URL=
|
||||
VITE_MASKANX_PROXY_TARGET=http://127.0.0.1:8088
|
||||
@@ -0,0 +1 @@
|
||||
VITE_MASKANX_API_URL=https://api.maskanx.example.com
|
||||
@@ -0,0 +1,2 @@
|
||||
VITE_MASKANX_API_URL=http://127.0.0.1:8089
|
||||
VITE_MASKANX_PROXY_TARGET=http://127.0.0.1:8089
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.vite/
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.local.example
|
||||
!.env.development.example
|
||||
!.env.testing.example
|
||||
!.env.production.example
|
||||
|
||||
*.log
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
*.tsbuildinfo
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
ARG VITE_MASKANX_API_URL
|
||||
ENV VITE_MASKANX_API_URL=$VITE_MASKANX_API_URL
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build:prod
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,20 @@
|
||||
MaskanX Proprietary License
|
||||
|
||||
Copyright (c) 2026 MaskanX. All rights reserved.
|
||||
|
||||
This software and associated documentation files (the "Software") are
|
||||
proprietary to MaskanX.
|
||||
|
||||
You may use, copy, modify, deploy, and distribute the Software only with prior
|
||||
written permission from MaskanX or under a separate written agreement signed
|
||||
by MaskanX.
|
||||
|
||||
No rights are granted to sublicense, sell, lease, rent, publish, host for third
|
||||
parties, or otherwise make the Software available to others except where
|
||||
expressly permitted in writing by MaskanX.
|
||||
|
||||
The Software is provided "as is", without warranty of any kind, express or
|
||||
implied, including but not limited to the warranties of merchantability,
|
||||
fitness for a particular purpose, and noninfringement. In no event shall
|
||||
MaskanX be liable for any claim, damages, or other liability arising from use
|
||||
of the Software.
|
||||
@@ -0,0 +1,48 @@
|
||||
# MaskanX Frontend
|
||||
|
||||
Independent React, TypeScript, and Vite frontend for MaskanX.
|
||||
|
||||
## Service Contract
|
||||
|
||||
- Default development URL: `http://127.0.0.1:5173`
|
||||
- Backend API: configured with `VITE_MASKANX_API_URL`
|
||||
- Local proxy target: configured with `VITE_MASKANX_PROXY_TARGET`
|
||||
- Database access: none; this service never receives PostgreSQL credentials
|
||||
|
||||
## Local Setup
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
Copy-Item .env.local.example .env.local
|
||||
npm run local
|
||||
```
|
||||
|
||||
Start `maskanx-backend` on port `8088` before using the full UI.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `npm run start` | Preview the production build on port 5173 |
|
||||
| `npm run build` | Type-check and build `dist/` |
|
||||
| `npm run local` | Local Vite server using `.env.local` |
|
||||
| `npm run dev` | Development server using `.env.development` |
|
||||
| `npm run test` | Test-mode type-check and production build |
|
||||
| `npm run lint` | ESLint checks |
|
||||
| `npm run prod` | Production-mode preview |
|
||||
|
||||
Database migration and seed commands intentionally live in
|
||||
`maskanx-backend`. A frontend must never own database credentials.
|
||||
|
||||
## Production
|
||||
|
||||
Build the container with the browser-visible backend URL:
|
||||
|
||||
```powershell
|
||||
docker build `
|
||||
--build-arg VITE_MASKANX_API_URL=https://api.example.com `
|
||||
-t maskanx-frontend .
|
||||
```
|
||||
|
||||
The backend must allow the deployed frontend origin through
|
||||
`MASKANX_CORS_ORIGINS`.
|
||||
@@ -0,0 +1,28 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/maskan-logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MaskanX Console</title>
|
||||
<script
|
||||
defer
|
||||
src="https://unpkg.com/react-grab@0.1.33/dist/index.global.js"
|
||||
integrity="sha384-VdP7BGZoc/91DU2gxSRyFzN0IN9jJuTsv4o0p0LyeiRb4ZvAq03xPlqV9jNDtAzY"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|svg|webp|ico|woff2?)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
Generated
+11563
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"name": "maskanx-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"npm": ">=8.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prestart": "node scripts/patch-mermaid-dagre-self-loop.mjs",
|
||||
"start": "dotenv -e .env.production -- vite preview --host 0.0.0.0 --port 5173",
|
||||
"prelocal": "node scripts/patch-mermaid-dagre-self-loop.mjs",
|
||||
"local": "dotenv -e .env.local -- vite --host 127.0.0.1 --port 5173",
|
||||
"predev": "node scripts/patch-mermaid-dagre-self-loop.mjs",
|
||||
"dev": "dotenv -e .env.development -- vite --host 0.0.0.0 --port 5173",
|
||||
"prebuild": "node scripts/patch-mermaid-dagre-self-loop.mjs",
|
||||
"build": "tsc -b && vite build && node scripts/patch-mermaid-dagre-self-loop.mjs --built",
|
||||
"build:prod": "node scripts/patch-mermaid-dagre-self-loop.mjs && tsc -b && vite build --mode production && node scripts/patch-mermaid-dagre-self-loop.mjs --built",
|
||||
"build:test": "node scripts/patch-mermaid-dagre-self-loop.mjs && tsc -b && vite build --mode test && node scripts/patch-mermaid-dagre-self-loop.mjs --built",
|
||||
"test": "npm run build:test",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"lint": "eslint .",
|
||||
"verify:maskanx-ai-models-ui": "node scripts/verify-maskanx-ai-models-ui.mjs",
|
||||
"preview": "vite preview",
|
||||
"preview:prod": "vite preview --mode production",
|
||||
"preview:test": "vite preview --mode test",
|
||||
"prod": "dotenv -e .env.production -- vite preview --host 0.0.0.0 --port 5173"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentscope-ai/chat": "^1.1.51",
|
||||
"@agentscope-ai/design": "^1.0.14",
|
||||
"@agentscope-ai/icons": "^1.0.60",
|
||||
"@ant-design/x-markdown": "^2.2.2",
|
||||
"ahooks": "^3.9.6",
|
||||
"antd": "^5.29.1",
|
||||
"antd-style": "^3.7.1",
|
||||
"i18next": "^25.8.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-grab": "^0.1.27",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-router-dom": "^7.13.0"
|
||||
},
|
||||
"overrides": {
|
||||
"dompurify": "3.4.2",
|
||||
"cosmiconfig": {
|
||||
"yaml": "1.10.3"
|
||||
},
|
||||
"@eslint/config-array": {
|
||||
"minimatch": {
|
||||
"brace-expansion": "1.1.13"
|
||||
}
|
||||
},
|
||||
"@eslint/eslintrc": {
|
||||
"minimatch": {
|
||||
"brace-expansion": "1.1.13"
|
||||
}
|
||||
},
|
||||
"eslint": {
|
||||
"minimatch": {
|
||||
"brace-expansion": "1.1.13"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/typescript-estree": {
|
||||
"minimatch": {
|
||||
"brace-expansion": "5.0.5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@types/i18next": "^12.1.0",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"less": "^4.5.1",
|
||||
"prettier": "3.0.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 174 184" width="174" height="184">
|
||||
<image href="maskan-logo.png" x="0" y="0" width="174" height="184" preserveAspectRatio="xMidYMid meet"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 201 B |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,47 @@
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const consoleRoot = join(__dirname, "..");
|
||||
const bad = "-cyc<lic-special-2";
|
||||
const good = "-cyclic-special-2";
|
||||
|
||||
const targets = [
|
||||
join(consoleRoot, "node_modules/mermaid/dist/mermaid.js"),
|
||||
join(
|
||||
consoleRoot,
|
||||
"node_modules/mermaid/dist/chunks/mermaid.esm/dagre-QRXUHWQO.mjs",
|
||||
),
|
||||
join(
|
||||
consoleRoot,
|
||||
"node_modules/mermaid/dist/chunks/mermaid.esm/dagre-ZXKKJJHT.mjs",
|
||||
),
|
||||
];
|
||||
|
||||
if (process.argv.includes("--built")) {
|
||||
const assetsDir = join(consoleRoot, "dist/assets");
|
||||
if (existsSync(assetsDir)) {
|
||||
for (const fileName of readdirSync(assetsDir)) {
|
||||
if (fileName.startsWith("dagre-") && fileName.endsWith(".js")) {
|
||||
targets.push(join(assetsDir, fileName));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let patched = 0;
|
||||
|
||||
for (const target of targets) {
|
||||
if (!existsSync(target)) continue;
|
||||
|
||||
const before = readFileSync(target, "utf8");
|
||||
if (!before.includes(bad)) continue;
|
||||
|
||||
writeFileSync(target, before.replaceAll(bad, good));
|
||||
patched += 1;
|
||||
}
|
||||
|
||||
if (patched > 0) {
|
||||
console.log(`[patch-mermaid-dagre] patched ${patched} file(s)`);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const read = (path) => readFileSync(resolve(root, path), "utf8");
|
||||
|
||||
const providerMeta = read("src/shared/providerMeta.ts");
|
||||
assert.match(providerMeta, /MASKANX_AI_PROVIDER_ID\s*=\s*"maskanx-host-ai"/);
|
||||
assert.match(providerMeta, /\[MASKANX_AI_PROVIDER_ID\]\s*:\s*0/);
|
||||
|
||||
const providerApi = read("src/api/modules/provider.ts");
|
||||
assert.match(providerApi, /getProviderUsage/);
|
||||
assert.match(
|
||||
providerApi,
|
||||
/\/models\/\$\{encodeURIComponent\(providerId\)\}\/usage/,
|
||||
);
|
||||
|
||||
const providerTypes = read("src/api/types/provider.ts");
|
||||
assert.match(providerTypes, /ProviderUsageInfo/);
|
||||
assert.match(providerTypes, /messages_remaining/);
|
||||
|
||||
const modelsSection = read(
|
||||
"src/pages/Settings/Models/components/sections/ModelsSection.tsx",
|
||||
);
|
||||
assert.match(modelsSection, /MASKANX_AI_PROVIDER_ID/);
|
||||
assert.match(modelsSection, /getProviderUsage/);
|
||||
assert.match(modelsSection, /models\.MaskanXAiMessagesRemaining/);
|
||||
assert.match(modelsSection, /selectedProviderId === MASKANX_AI_PROVIDER_ID/);
|
||||
|
||||
console.log("ok - MaskanX AI Models UI source contract verified");
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { createGlobalStyle } from "antd-style";
|
||||
import { ConfigProvider, bailianTheme } from "@agentscope-ai/design";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import MainLayout from "./layouts/MainLayout";
|
||||
import "./styles/layout.css";
|
||||
import "./styles/form-override.css";
|
||||
import "./styles/citedy-overrides.less";
|
||||
|
||||
const GlobalStyle = createGlobalStyle`
|
||||
* {
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`;
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<GlobalStyle />
|
||||
<ConfigProvider {...bailianTheme} prefix="MaskanX" prefixCls="MaskanX">
|
||||
<MainLayout />
|
||||
</ConfigProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,22 @@
|
||||
declare const BASE_URL: string;
|
||||
declare const TOKEN: string;
|
||||
|
||||
/**
|
||||
* Get the full API URL with /api prefix
|
||||
* @param path - API path (e.g., "/models", "/skills")
|
||||
* @returns Full API URL (e.g., "http://localhost:8088/api/models" or "/api/models")
|
||||
*/
|
||||
export function getApiUrl(path: string): string {
|
||||
const base = BASE_URL || "";
|
||||
const apiPrefix = "/api";
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${base}${apiPrefix}${normalizedPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the API token
|
||||
* @returns API token string or empty string
|
||||
*/
|
||||
export function getApiToken(): string {
|
||||
return typeof TOKEN !== "undefined" ? TOKEN : "";
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
export * from "./types";
|
||||
|
||||
export { request } from "./request";
|
||||
|
||||
export { getApiUrl, getApiToken } from "./config";
|
||||
|
||||
import { rootApi } from "./modules/root";
|
||||
import { channelApi } from "./modules/channel";
|
||||
import { heartbeatApi } from "./modules/heartbeat";
|
||||
import { cronJobApi } from "./modules/cronjob";
|
||||
import { chatApi, sessionApi } from "./modules/chat";
|
||||
import { companyApi } from "./modules/company";
|
||||
import { envApi } from "./modules/env";
|
||||
import { providerApi } from "./modules/provider";
|
||||
import { skillApi } from "./modules/skill";
|
||||
import { agentApi } from "./modules/agent";
|
||||
import { workspaceApi } from "./modules/workspace";
|
||||
import { localModelApi } from "./modules/localModel";
|
||||
import { ollamaModelApi } from "./modules/ollamaModel";
|
||||
import { mcpApi } from "./modules/mcp";
|
||||
import { diagnosticsApi } from "./modules/diagnostics";
|
||||
import { personaApi } from "./modules/persona";
|
||||
import { brandApi } from "./modules/brand";
|
||||
import { crmApi } from "./modules/crm";
|
||||
|
||||
export const api = {
|
||||
// Root
|
||||
...rootApi,
|
||||
|
||||
// Channels
|
||||
...channelApi,
|
||||
|
||||
// Heartbeat
|
||||
...heartbeatApi,
|
||||
|
||||
// Cron Jobs
|
||||
...cronJobApi,
|
||||
|
||||
// Chats
|
||||
...chatApi,
|
||||
|
||||
// Companies
|
||||
...companyApi,
|
||||
|
||||
// Sessions(Legacy aliases)
|
||||
...sessionApi,
|
||||
|
||||
// Environment Variables
|
||||
...envApi,
|
||||
|
||||
// Providers
|
||||
...providerApi,
|
||||
|
||||
// Agent
|
||||
...agentApi,
|
||||
|
||||
// Skills
|
||||
...skillApi,
|
||||
|
||||
// Workspace
|
||||
...workspaceApi,
|
||||
|
||||
// Local Models
|
||||
...localModelApi,
|
||||
|
||||
// Ollama Models
|
||||
...ollamaModelApi,
|
||||
|
||||
// MCP Clients
|
||||
...mcpApi,
|
||||
|
||||
// Diagnostics
|
||||
...diagnosticsApi,
|
||||
|
||||
// Personas
|
||||
...personaApi,
|
||||
|
||||
// Brand
|
||||
...brandApi,
|
||||
|
||||
// First-party CRM
|
||||
...crmApi,
|
||||
};
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { request } from "../request";
|
||||
import type { AgentRequest, AgentsRunningConfig } from "../types";
|
||||
|
||||
// Agent API
|
||||
export const agentApi = {
|
||||
agentRoot: () => request<unknown>("/agent/"),
|
||||
|
||||
healthCheck: () => request<unknown>("/agent/health"),
|
||||
|
||||
agentApi: (body: AgentRequest) =>
|
||||
request<unknown>("/agent/process", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
getProcessStatus: () => request<unknown>("/agent/admin/status"),
|
||||
|
||||
shutdownSimple: () =>
|
||||
request<void>("/agent/shutdown", {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
shutdown: () =>
|
||||
request<void>("/agent/admin/shutdown", {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
getAgentRunningConfig: () =>
|
||||
request<AgentsRunningConfig>("/agent/running-config"),
|
||||
|
||||
updateAgentRunningConfig: (config: AgentsRunningConfig) =>
|
||||
request<AgentsRunningConfig>("/agent/running-config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(config),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { getApiToken, getApiUrl } from "../config";
|
||||
import { request } from "../request";
|
||||
|
||||
export interface BrandLogoInfo {
|
||||
configured: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
filename: string;
|
||||
path: string;
|
||||
url: string;
|
||||
updated_at: number | null;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
function authHeaders(): HeadersInit {
|
||||
const token = getApiToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
export const brandApi = {
|
||||
getLogo: () => request<BrandLogoInfo>("/config/brand/logo"),
|
||||
|
||||
getLogoFileUrl: (updatedAt?: number | null) => {
|
||||
const suffix = updatedAt ? `?updated_at=${encodeURIComponent(updatedAt)}` : "";
|
||||
return `${getApiUrl("/config/brand/logo/file")}${suffix}`;
|
||||
},
|
||||
|
||||
uploadLogo: async (file: File): Promise<BrandLogoInfo> => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(getApiUrl("/config/brand/logo"), {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Logo upload failed: ${response.status} ${response.statusText}${
|
||||
text ? ` - ${text}` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
deleteLogo: () =>
|
||||
request<{ success: boolean }>("/config/brand/logo", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { request } from "../request";
|
||||
import type { ChannelConfig, SingleChannelConfig } from "../types";
|
||||
|
||||
export const channelApi = {
|
||||
listChannelTypes: () => request<string[]>("/config/channels/types"),
|
||||
|
||||
listChannels: () => request<ChannelConfig>("/config/channels"),
|
||||
|
||||
updateChannels: (body: ChannelConfig) =>
|
||||
request<ChannelConfig>("/config/channels", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
getChannelConfig: (channelName: string) =>
|
||||
request<SingleChannelConfig>(
|
||||
`/config/channels/${encodeURIComponent(channelName)}`,
|
||||
),
|
||||
|
||||
updateChannelConfig: (channelName: string, body: SingleChannelConfig) =>
|
||||
request<SingleChannelConfig>(
|
||||
`/config/channels/${encodeURIComponent(channelName)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { request } from "../request";
|
||||
import type {
|
||||
ChatSpec,
|
||||
ChatHistory,
|
||||
ChatDeleteResponse,
|
||||
Session,
|
||||
} from "../types";
|
||||
|
||||
export const chatApi = {
|
||||
listChats: (params?: { user_id?: string; channel?: string }) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.user_id) searchParams.append("user_id", params.user_id);
|
||||
if (params?.channel) searchParams.append("channel", params.channel);
|
||||
const query = searchParams.toString();
|
||||
return request<ChatSpec[]>(`/chats${query ? `?${query}` : ""}`);
|
||||
},
|
||||
|
||||
createChat: (chat: Partial<ChatSpec>) =>
|
||||
request<ChatSpec>("/chats", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(chat),
|
||||
}),
|
||||
|
||||
getChat: (chatId: string) =>
|
||||
request<ChatHistory>(`/chats/${encodeURIComponent(chatId)}`),
|
||||
|
||||
updateChat: (chatId: string, chat: Partial<ChatSpec>) =>
|
||||
request<ChatSpec>(`/chats/${encodeURIComponent(chatId)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(chat),
|
||||
}),
|
||||
|
||||
deleteChat: (chatId: string) =>
|
||||
request<ChatDeleteResponse>(`/chats/${encodeURIComponent(chatId)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
|
||||
batchDeleteChats: (chatIds: string[]) =>
|
||||
request<{ success: boolean; deleted_count: number }>(
|
||||
"/chats/batch-delete",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(chatIds),
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
export const sessionApi = {
|
||||
listSessions: (params?: { user_id?: string; channel?: string }) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.user_id) searchParams.append("user_id", params.user_id);
|
||||
if (params?.channel) searchParams.append("channel", params.channel);
|
||||
const query = searchParams.toString();
|
||||
return request<Session[]>(`/chats${query ? `?${query}` : ""}`);
|
||||
},
|
||||
|
||||
getSession: (sessionId: string) =>
|
||||
request<ChatHistory>(`/chats/${encodeURIComponent(sessionId)}`),
|
||||
|
||||
deleteSession: (sessionId: string) =>
|
||||
request<ChatDeleteResponse>(`/chats/${encodeURIComponent(sessionId)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
|
||||
createSession: (session: Partial<Session>) =>
|
||||
request<Session>("/chats", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(session),
|
||||
}),
|
||||
|
||||
updateSession: (sessionId: string, session: Partial<Session>) =>
|
||||
request<Session>(`/chats/${encodeURIComponent(sessionId)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(session),
|
||||
}),
|
||||
|
||||
batchDeleteSessions: (sessionIds: string[]) =>
|
||||
request<{ success: boolean; deleted_count: number }>(
|
||||
"/chats/batch-delete",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(sessionIds),
|
||||
},
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { request } from "../request";
|
||||
import type { CompanyState, CreateCompanyPayload } from "../types/company";
|
||||
|
||||
export const companyApi = {
|
||||
listCompanies: () => request<CompanyState>("/companies"),
|
||||
|
||||
createCompany: (payload: CreateCompanyPayload) =>
|
||||
request<CompanyState>("/companies", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
switchCompany: (companyId: string) =>
|
||||
request<CompanyState>(`/companies/${encodeURIComponent(companyId)}/switch`, {
|
||||
method: "POST",
|
||||
}),
|
||||
};
|
||||
|
||||
export type { CompanyInfo, CompanyState } from "../types/company";
|
||||
@@ -0,0 +1,11 @@
|
||||
import { request } from "../request";
|
||||
|
||||
export interface PushMessage {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const consoleApi = {
|
||||
getPushMessages: () =>
|
||||
request<{ messages: PushMessage[] }>("/console/push-messages"),
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { request } from "../request";
|
||||
|
||||
export interface CRMConfig {
|
||||
provider: "maskan_crm";
|
||||
api_url: string;
|
||||
web_url: string;
|
||||
key_configured: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CRMConfigUpdate {
|
||||
api_url: string;
|
||||
web_url: string;
|
||||
integration_key?: string | null;
|
||||
clear_key?: boolean;
|
||||
}
|
||||
|
||||
export interface CRMStatus {
|
||||
connected: boolean;
|
||||
provider: "maskan_crm";
|
||||
status: string;
|
||||
product: string;
|
||||
mode: string;
|
||||
workspace: string;
|
||||
tenant_name: string;
|
||||
credential_name: string;
|
||||
key_prefix: string;
|
||||
}
|
||||
|
||||
export interface CRMLeadInput {
|
||||
provider: string;
|
||||
external_id: string;
|
||||
first_name: string;
|
||||
last_name?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
company_name?: string;
|
||||
job_title?: string;
|
||||
lead_title?: string;
|
||||
source?: string;
|
||||
score?: number;
|
||||
campaign?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
idempotency_key?: string;
|
||||
}
|
||||
|
||||
export interface CRMLeadResult {
|
||||
contact_id: string;
|
||||
lead_id: string;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
export const crmApi = {
|
||||
getConfig: () => request<CRMConfig>("/crm/config"),
|
||||
|
||||
saveConfig: (payload: CRMConfigUpdate) =>
|
||||
request<CRMConfig>("/crm/config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
testConnection: () =>
|
||||
request<CRMStatus>("/crm/test", {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
sendLead: (payload: CRMLeadInput) =>
|
||||
request<CRMLeadResult>("/crm/leads", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { request } from "../request";
|
||||
import type {
|
||||
CronJobSpecInput,
|
||||
CronJobSpecOutput,
|
||||
CronJobView,
|
||||
} from "../types";
|
||||
|
||||
export const cronJobApi = {
|
||||
listCronJobs: () => request<CronJobSpecOutput[]>("/cron/jobs"),
|
||||
|
||||
createCronJob: (spec: CronJobSpecInput) =>
|
||||
request<CronJobSpecOutput>("/cron/jobs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(spec),
|
||||
}),
|
||||
|
||||
getCronJob: (jobId: string) =>
|
||||
request<CronJobView>(`/cron/jobs/${encodeURIComponent(jobId)}`),
|
||||
|
||||
replaceCronJob: (jobId: string, spec: CronJobSpecInput) =>
|
||||
request<CronJobSpecOutput>(`/cron/jobs/${encodeURIComponent(jobId)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(spec),
|
||||
}),
|
||||
|
||||
deleteCronJob: (jobId: string) =>
|
||||
request<void>(`/cron/jobs/${encodeURIComponent(jobId)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
|
||||
pauseCronJob: (jobId: string) =>
|
||||
request<void>(`/cron/jobs/${encodeURIComponent(jobId)}/pause`, {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
resumeCronJob: (jobId: string) =>
|
||||
request<void>(`/cron/jobs/${encodeURIComponent(jobId)}/resume`, {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
runCronJob: (jobId: string) =>
|
||||
request<void>(`/cron/jobs/${encodeURIComponent(jobId)}/run`, {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
triggerCronJob: (jobId: string) =>
|
||||
request<void>(`/cron/jobs/${encodeURIComponent(jobId)}/run`, {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
getCronJobState: (jobId: string) =>
|
||||
request<unknown>(`/cron/jobs/${encodeURIComponent(jobId)}/state`),
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { request } from "../request";
|
||||
|
||||
export interface SubsystemStatus {
|
||||
status: "ok" | "warning" | "error";
|
||||
detail: unknown;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: "healthy" | "degraded" | "unhealthy";
|
||||
uptime_seconds: number;
|
||||
subsystems: Record<string, SubsystemStatus>;
|
||||
}
|
||||
|
||||
export interface ErrorEntry {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
message: string;
|
||||
traceback: string;
|
||||
}
|
||||
|
||||
export interface ErrorsResponse {
|
||||
errors: ErrorEntry[];
|
||||
log_path: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const diagnosticsApi = {
|
||||
getHealth: () => request<HealthResponse>("/diagnostics/health"),
|
||||
getErrors: (limit = 50) =>
|
||||
request<ErrorsResponse>(`/diagnostics/errors?limit=${limit}`),
|
||||
restart: () =>
|
||||
request<{ restarted: boolean; error?: string }>("/diagnostics/restart", {
|
||||
method: "POST",
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { request } from "../request";
|
||||
import type { EnvVar } from "../types";
|
||||
|
||||
export interface EnvKeyRef {
|
||||
key: string;
|
||||
plugin: string;
|
||||
description: string;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
export const envApi = {
|
||||
listEnvs: () => request<EnvVar[]>("/envs"),
|
||||
|
||||
/** Batch save – full replacement of all env vars. */
|
||||
saveEnvs: (envs: Record<string, string>) =>
|
||||
request<EnvVar[]>("/envs", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(envs),
|
||||
}),
|
||||
|
||||
deleteEnv: (key: string) =>
|
||||
request<EnvVar[]>(`/envs/${encodeURIComponent(key)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
|
||||
/** List all known API keys with plugin info and configured status. */
|
||||
listKeyRefs: () => request<EnvKeyRef[]>("/envs/keys"),
|
||||
|
||||
/** Bulk import from .env text. */
|
||||
bulkImport: (text: string, merge = true) =>
|
||||
request<EnvVar[]>("/envs/bulk-import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text, merge }),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { request } from "../request";
|
||||
import type { HeartbeatConfig } from "../types/heartbeat";
|
||||
|
||||
export const heartbeatApi = {
|
||||
getHeartbeatConfig: () => request<HeartbeatConfig>("/config/heartbeat"),
|
||||
|
||||
updateHeartbeatConfig: (body: HeartbeatConfig) =>
|
||||
request<HeartbeatConfig>("/config/heartbeat", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { request } from "../request";
|
||||
import type {
|
||||
LocalModelResponse,
|
||||
DownloadModelRequest,
|
||||
DownloadTaskResponse,
|
||||
} from "../types";
|
||||
|
||||
export const localModelApi = {
|
||||
listLocalModels: (backend?: string) => {
|
||||
const params = backend ? `?backend=${encodeURIComponent(backend)}` : "";
|
||||
return request<LocalModelResponse[]>(`/local-models${params}`);
|
||||
},
|
||||
|
||||
downloadModel: (body: DownloadModelRequest) =>
|
||||
request<DownloadTaskResponse>("/local-models/download", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
getDownloadStatus: (backend?: string) => {
|
||||
const params = backend ? `?backend=${encodeURIComponent(backend)}` : "";
|
||||
return request<DownloadTaskResponse[]>(
|
||||
`/local-models/download-status${params}`,
|
||||
);
|
||||
},
|
||||
|
||||
cancelDownload: (taskId: string) =>
|
||||
request<{ status: string; task_id: string }>(
|
||||
`/local-models/cancel-download/${encodeURIComponent(taskId)}`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
|
||||
deleteLocalModel: (modelId: string) =>
|
||||
request<{ status: string; model_id: string }>(
|
||||
`/local-models/${encodeURIComponent(modelId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { request } from "../request";
|
||||
import type {
|
||||
MCPClientInfo,
|
||||
MCPClientCreateRequest,
|
||||
MCPClientUpdateRequest,
|
||||
LinkedInOAuthStartRequest,
|
||||
LinkedInOAuthStartResponse,
|
||||
LinkedInOAuthStatus,
|
||||
} from "../types";
|
||||
|
||||
export const mcpApi = {
|
||||
/**
|
||||
* List all MCP clients
|
||||
*/
|
||||
listMCPClients: () => request<MCPClientInfo[]>("/mcp"),
|
||||
|
||||
/**
|
||||
* Get details of a specific MCP client
|
||||
*/
|
||||
getMCPClient: (clientKey: string) =>
|
||||
request<MCPClientInfo>(`/mcp/${encodeURIComponent(clientKey)}`),
|
||||
|
||||
/**
|
||||
* Create a new MCP client
|
||||
*/
|
||||
createMCPClient: (body: MCPClientCreateRequest) =>
|
||||
request<MCPClientInfo>("/mcp", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update an existing MCP client
|
||||
*/
|
||||
updateMCPClient: (clientKey: string, body: MCPClientUpdateRequest) =>
|
||||
request<MCPClientInfo>(`/mcp/${encodeURIComponent(clientKey)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Toggle MCP client enabled status
|
||||
*/
|
||||
toggleMCPClient: (clientKey: string) =>
|
||||
request<MCPClientInfo>(`/mcp/${encodeURIComponent(clientKey)}/toggle`, {
|
||||
method: "PATCH",
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete an MCP client
|
||||
*/
|
||||
deleteMCPClient: (clientKey: string) =>
|
||||
request<{ message: string }>(`/mcp/${encodeURIComponent(clientKey)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
|
||||
/**
|
||||
* Check LinkedIn OAuth/token readiness without exposing secrets
|
||||
*/
|
||||
getLinkedInOAuthStatus: () =>
|
||||
request<LinkedInOAuthStatus>("/mcp/linkedin/oauth/status"),
|
||||
|
||||
/**
|
||||
* Start LinkedIn OAuth in the browser
|
||||
*/
|
||||
startLinkedInOAuth: (body: LinkedInOAuthStartRequest = {}) =>
|
||||
request<LinkedInOAuthStartResponse>("/mcp/linkedin/oauth/start", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { request } from "../request";
|
||||
import type {
|
||||
OllamaModelResponse,
|
||||
OllamaDownloadRequest,
|
||||
OllamaDownloadTaskResponse,
|
||||
} from "../types";
|
||||
|
||||
export const ollamaModelApi = {
|
||||
listOllamaModels: () => request<OllamaModelResponse[]>("/ollama-models"),
|
||||
|
||||
downloadOllamaModel: (body: OllamaDownloadRequest) =>
|
||||
request<OllamaDownloadTaskResponse>("/ollama-models/download", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
getOllamaDownloadStatus: () =>
|
||||
request<OllamaDownloadTaskResponse[]>("/ollama-models/download-status"),
|
||||
|
||||
cancelOllamaDownload: (taskId: string) =>
|
||||
request<{ status: string; task_id: string }>(
|
||||
`/ollama-models/download/${encodeURIComponent(taskId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
|
||||
deleteOllamaModel: (name: string) =>
|
||||
request<{ status: string; name: string }>(
|
||||
`/ollama-models/${encodeURIComponent(name)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { request } from "../request";
|
||||
import type { Persona, PersonaTemplate } from "../types";
|
||||
|
||||
export const personaApi = {
|
||||
listPersonas: () => request<Persona[]>("/agents/personas"),
|
||||
|
||||
getPersona: (id: string) =>
|
||||
request<Persona>(`/agents/personas/${encodeURIComponent(id)}`),
|
||||
|
||||
createPersona: (persona: Partial<Persona>) =>
|
||||
request<Persona>("/agents/personas", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(persona),
|
||||
}),
|
||||
|
||||
updatePersona: (id: string, persona: Partial<Persona>) =>
|
||||
request<Persona>(`/agents/personas/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(persona),
|
||||
}),
|
||||
|
||||
deletePersona: (id: string) =>
|
||||
request<{ deleted: boolean }>(
|
||||
`/agents/personas/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
),
|
||||
|
||||
listPersonaTemplates: () =>
|
||||
request<PersonaTemplate[]>("/agents/personas/templates"),
|
||||
|
||||
createPersonaFromTemplate: (templateId: string) =>
|
||||
request<Persona>(
|
||||
`/agents/personas/templates/${encodeURIComponent(templateId)}`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { request } from "../request";
|
||||
import type {
|
||||
ProviderInfo,
|
||||
ProviderConfigRequest,
|
||||
ActiveModelsInfo,
|
||||
ProviderUsageInfo,
|
||||
ModelSlotRequest,
|
||||
CreateCustomProviderRequest,
|
||||
AddModelRequest,
|
||||
TestConnectionResponse,
|
||||
TestModelRequest,
|
||||
FallbackConfig,
|
||||
} from "../types";
|
||||
|
||||
export const providerApi = {
|
||||
listProviders: () => request<ProviderInfo[]>("/models"),
|
||||
|
||||
configureProvider: (providerId: string, body: ProviderConfigRequest) =>
|
||||
request<ProviderInfo>(`/models/${encodeURIComponent(providerId)}/config`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
getActiveModels: () => request<ActiveModelsInfo>("/models/active"),
|
||||
|
||||
setActiveLlm: (body: ModelSlotRequest) =>
|
||||
request<ActiveModelsInfo>("/models/active", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
getProviderUsage: (providerId: string) =>
|
||||
request<ProviderUsageInfo>(
|
||||
`/models/${encodeURIComponent(providerId)}/usage`,
|
||||
),
|
||||
|
||||
/* ---- Custom provider CRUD ---- */
|
||||
|
||||
createCustomProvider: (body: CreateCustomProviderRequest) =>
|
||||
request<ProviderInfo>("/models/custom-providers", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
deleteCustomProvider: (providerId: string) =>
|
||||
request<ProviderInfo[]>(
|
||||
`/models/custom-providers/${encodeURIComponent(providerId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
|
||||
/* ---- Model CRUD (works for both built-in and custom providers) ---- */
|
||||
|
||||
addModel: (providerId: string, body: AddModelRequest) =>
|
||||
request<ProviderInfo>(`/models/${encodeURIComponent(providerId)}/models`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
removeModel: (providerId: string, modelId: string) =>
|
||||
request<ProviderInfo>(
|
||||
`/models/${encodeURIComponent(providerId)}/models/${encodeURIComponent(
|
||||
modelId,
|
||||
)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
|
||||
/* ---- Test Connection ---- */
|
||||
|
||||
testProviderConnection: (
|
||||
providerId: string,
|
||||
body?: { api_key?: string; base_url?: string },
|
||||
) =>
|
||||
request<TestConnectionResponse>(
|
||||
`/models/${encodeURIComponent(providerId)}/test`,
|
||||
{
|
||||
method: "POST",
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
},
|
||||
),
|
||||
|
||||
testModelConnection: (providerId: string, body: TestModelRequest) =>
|
||||
request<TestConnectionResponse>(
|
||||
`/models/${encodeURIComponent(providerId)}/models/test`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
),
|
||||
|
||||
/* ---- Fallback chain ---- */
|
||||
|
||||
getFallbackConfig: () => request<FallbackConfig>("/models/fallback"),
|
||||
|
||||
setFallbackConfig: (body: FallbackConfig) =>
|
||||
request<FallbackConfig>("/models/fallback", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { request } from "../request";
|
||||
|
||||
// Root API
|
||||
export const rootApi = {
|
||||
readRoot: () => request<unknown>("/"),
|
||||
getVersion: () => request<{ version: string }>("/version"),
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { request } from "../request";
|
||||
import type { HubSkillSpec, SkillSpec } from "../types";
|
||||
|
||||
export const skillApi = {
|
||||
listSkills: () => request<SkillSpec[]>("/skills"),
|
||||
|
||||
createSkill: (skillName: string, content: string) =>
|
||||
request<Record<string, unknown>>("/skills", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: skillName,
|
||||
content: content,
|
||||
}),
|
||||
}),
|
||||
|
||||
enableSkill: (skillName: string) =>
|
||||
request<void>(`/skills/${encodeURIComponent(skillName)}/enable`, {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
disableSkill: (skillName: string) =>
|
||||
request<void>(`/skills/${encodeURIComponent(skillName)}/disable`, {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
batchEnableSkills: (skillNames: string[]) =>
|
||||
request<void>("/skills/batch-enable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(skillNames),
|
||||
}),
|
||||
|
||||
deleteSkill: (skillName: string) =>
|
||||
request<{ deleted: boolean }>(`/skills/${encodeURIComponent(skillName)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
|
||||
searchHubSkills: (query: string, limit = 20) =>
|
||||
request<HubSkillSpec[]>(
|
||||
`/skills/hub/search?q=${encodeURIComponent(query)}&limit=${limit}`,
|
||||
),
|
||||
|
||||
installHubSkill: (payload: {
|
||||
bundle_url: string;
|
||||
version?: string;
|
||||
enable?: boolean;
|
||||
overwrite?: boolean;
|
||||
}) =>
|
||||
request<{
|
||||
installed: boolean;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
source_url: string;
|
||||
}>("/skills/hub/install", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { request } from "../request";
|
||||
import { getApiUrl } from "../config";
|
||||
import type { MdFileInfo, MdFileContent, DailyMemoryFile } from "../types";
|
||||
|
||||
export const workspaceApi = {
|
||||
listFiles: () =>
|
||||
request<MdFileInfo[]>("/agent/files").then((files) =>
|
||||
files.map((file) => ({
|
||||
...file,
|
||||
updated_at: new Date(file.modified_time).getTime(),
|
||||
})),
|
||||
),
|
||||
|
||||
loadFile: (fileName: string) =>
|
||||
request<MdFileContent>(`/agent/files/${encodeURIComponent(fileName)}`),
|
||||
|
||||
saveFile: (fileName: string, content: string) =>
|
||||
request<Record<string, unknown>>(
|
||||
`/agent/files/${encodeURIComponent(fileName)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ content }),
|
||||
},
|
||||
),
|
||||
|
||||
// Workspace package download
|
||||
downloadWorkspace: async (): Promise<Blob> => {
|
||||
const response = await fetch(getApiUrl("/workspace/download"), {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Workspace download failed: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return await response.blob();
|
||||
},
|
||||
|
||||
// File upload functionality
|
||||
uploadFile: async (
|
||||
file: File,
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(getApiUrl("/workspace/upload"), {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`Upload failed: ${response.status} ${response.statusText} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
listDailyMemory: () =>
|
||||
request<MdFileInfo[]>("/agent/memory").then((files) =>
|
||||
files.map((file) => {
|
||||
const date = file.filename.replace(".md", "");
|
||||
return {
|
||||
...file,
|
||||
date,
|
||||
updated_at: new Date(file.modified_time).getTime(),
|
||||
} as DailyMemoryFile;
|
||||
}),
|
||||
),
|
||||
|
||||
loadDailyMemory: (date: string) =>
|
||||
request<MdFileContent>(`/agent/memory/${encodeURIComponent(date)}.md`),
|
||||
|
||||
saveDailyMemory: (date: string, content: string) =>
|
||||
request<Record<string, unknown>>(
|
||||
`/agent/memory/${encodeURIComponent(date)}.md`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ content }),
|
||||
},
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { getApiUrl, getApiToken } from "./config";
|
||||
|
||||
function buildHeaders(method?: string, extra?: HeadersInit): Headers {
|
||||
// Normalize extra to a Headers instance for consistent handling
|
||||
const headers = extra instanceof Headers ? extra : new Headers(extra);
|
||||
|
||||
// Only add Content-Type for methods that typically have a body
|
||||
if (method && ["POST", "PUT", "PATCH"].includes(method.toUpperCase())) {
|
||||
// Don't override if caller explicitly set Content-Type
|
||||
if (!headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
}
|
||||
|
||||
// Add authorization token if available
|
||||
const token = getApiToken();
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function request<T = unknown>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const url = getApiUrl(path);
|
||||
const method = options.method || "GET";
|
||||
const headers = buildHeaders(method, options.headers);
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Request failed: ${response.status} ${response.statusText}${
|
||||
text ? ` - ${text}` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
return (await response.text()) as unknown as T;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface AgentRequest {
|
||||
input: unknown;
|
||||
session_id?: string | null;
|
||||
user_id?: string | null;
|
||||
channel?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AgentsRunningConfig {
|
||||
max_iters: number;
|
||||
max_input_length: number;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export interface BaseChannelConfig {
|
||||
enabled: boolean;
|
||||
bot_prefix: string;
|
||||
filter_tool_messages?: boolean;
|
||||
}
|
||||
|
||||
export interface IMessageChannelConfig extends BaseChannelConfig {
|
||||
db_path: string;
|
||||
poll_sec: number;
|
||||
}
|
||||
|
||||
export interface DiscordConfig extends BaseChannelConfig {
|
||||
bot_token: string;
|
||||
http_proxy: string;
|
||||
http_proxy_auth: string;
|
||||
}
|
||||
|
||||
export interface DingTalkConfig extends BaseChannelConfig {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
}
|
||||
|
||||
export interface FeishuConfig extends BaseChannelConfig {
|
||||
app_id: string;
|
||||
app_secret: string;
|
||||
encrypt_key: string;
|
||||
verification_token: string;
|
||||
media_dir: string;
|
||||
}
|
||||
|
||||
export interface QQConfig extends BaseChannelConfig {
|
||||
app_id: string;
|
||||
client_secret: string;
|
||||
}
|
||||
|
||||
export interface TelegramConfig extends BaseChannelConfig {
|
||||
bot_token: string;
|
||||
http_proxy: string;
|
||||
http_proxy_auth: string;
|
||||
show_typing?: boolean;
|
||||
}
|
||||
|
||||
export type ConsoleConfig = BaseChannelConfig;
|
||||
|
||||
export interface ChannelConfig {
|
||||
imessage: IMessageChannelConfig;
|
||||
discord: DiscordConfig;
|
||||
dingtalk: DingTalkConfig;
|
||||
feishu: FeishuConfig;
|
||||
qq: QQConfig;
|
||||
telegram: TelegramConfig;
|
||||
console: ConsoleConfig;
|
||||
}
|
||||
|
||||
export type SingleChannelConfig =
|
||||
| IMessageChannelConfig
|
||||
| DiscordConfig
|
||||
| DingTalkConfig
|
||||
| FeishuConfig
|
||||
| QQConfig
|
||||
| TelegramConfig
|
||||
| ConsoleConfig;
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface ChatSpec {
|
||||
id: string; // Chat UUID identifier
|
||||
name?: string; // Human-readable chat name
|
||||
session_id: string; // Session identifier (channel:user_id format)
|
||||
user_id: string; // User identifier
|
||||
channel: string; // Channel name, default: "default"
|
||||
created_at: string | null; // Chat creation timestamp (ISO 8601)
|
||||
updated_at: string | null; // Chat last update timestamp (ISO 8601)
|
||||
meta?: Record<string, unknown>; // Additional metadata
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
role: string;
|
||||
content: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ChatHistory {
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export interface ChatDeleteResponse {
|
||||
success: boolean;
|
||||
chat_id: string;
|
||||
}
|
||||
|
||||
// Legacy Session type alias for backward compatibility
|
||||
export type Session = ChatSpec;
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface CompanyInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
active: boolean;
|
||||
has_config: boolean;
|
||||
has_provider_settings: boolean;
|
||||
has_env_settings: boolean;
|
||||
has_logo: boolean;
|
||||
has_chat_history: boolean;
|
||||
has_linkedin_oauth: boolean;
|
||||
}
|
||||
|
||||
export interface CompanyState {
|
||||
active_company_id: string;
|
||||
companies: CompanyInfo[];
|
||||
}
|
||||
|
||||
export interface CreateCompanyPayload {
|
||||
name: string;
|
||||
switch?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export interface CronJobSchedule {
|
||||
type: "cron";
|
||||
cron: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export interface CronJobTarget {
|
||||
user_id: string;
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
export interface CronJobDispatch {
|
||||
type: "channel";
|
||||
channel?: string;
|
||||
target: CronJobTarget;
|
||||
mode?: "stream" | "final";
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CronJobRuntime {
|
||||
max_concurrency?: number;
|
||||
timeout_seconds?: number;
|
||||
misfire_grace_seconds?: number;
|
||||
}
|
||||
|
||||
export interface CronJobRequest {
|
||||
input: unknown;
|
||||
session_id?: string | null;
|
||||
user_id?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CronJobSpecInput {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled?: boolean;
|
||||
schedule: CronJobSchedule;
|
||||
task_type?: "text" | "agent";
|
||||
text?: string;
|
||||
request?: CronJobRequest;
|
||||
dispatch: CronJobDispatch;
|
||||
runtime?: CronJobRuntime;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type CronJobSpecOutput = CronJobSpecInput;
|
||||
|
||||
export interface CronJobView extends CronJobSpecOutput {
|
||||
// Extended view with runtime state
|
||||
state?: unknown;
|
||||
next_run_time?: number;
|
||||
last_run_time?: number;
|
||||
}
|
||||
|
||||
export type CronJobSpecInputLegacy = Record<string, unknown>;
|
||||
export type CronJobSpecOutputLegacy = Record<string, unknown>;
|
||||
export type CronJobViewLegacy = Record<string, unknown>;
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface EnvVar {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface ActiveHoursConfig {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export interface HeartbeatConfig {
|
||||
enabled: boolean;
|
||||
every: string;
|
||||
target: string;
|
||||
activeHours?: ActiveHoursConfig | null;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export * from "./agent";
|
||||
export * from "./channel";
|
||||
export * from "./heartbeat";
|
||||
export * from "./chat";
|
||||
export * from "./company";
|
||||
export * from "./cronjob";
|
||||
export * from "./env";
|
||||
export * from "./mcp";
|
||||
export * from "./provider";
|
||||
export * from "./skill";
|
||||
export * from "./workspace";
|
||||
export * from "./persona";
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* MCP (Model Context Protocol) client types
|
||||
*/
|
||||
|
||||
export interface MCPClientInfo {
|
||||
/** Unique client key identifier */
|
||||
key: string;
|
||||
/** Client display name */
|
||||
name: string;
|
||||
/** Client description */
|
||||
description: string;
|
||||
/** Whether the client is enabled */
|
||||
enabled: boolean;
|
||||
/** MCP transport type */
|
||||
transport: "stdio" | "streamable_http" | "sse";
|
||||
/** Remote MCP endpoint URL for HTTP/SSE transport */
|
||||
url: string;
|
||||
/** HTTP headers for remote transport */
|
||||
headers: Record<string, string>;
|
||||
/** Command to launch the MCP server */
|
||||
command: string;
|
||||
/** Command-line arguments */
|
||||
args: string[];
|
||||
/** Environment variables */
|
||||
env: Record<string, string>;
|
||||
/** Working directory for stdio command */
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export interface MCPClientCreateRequest {
|
||||
/** Unique client key identifier */
|
||||
client_key: string;
|
||||
/** Client configuration */
|
||||
client: {
|
||||
/** Client display name */
|
||||
name: string;
|
||||
/** Client description */
|
||||
description?: string;
|
||||
/** Whether to enable the client */
|
||||
enabled?: boolean;
|
||||
/** MCP transport type */
|
||||
transport?: "stdio" | "streamable_http" | "sse";
|
||||
/** Remote MCP endpoint URL for HTTP/SSE transport */
|
||||
url?: string;
|
||||
/** HTTP headers for remote transport */
|
||||
headers?: Record<string, string>;
|
||||
/** Command to launch the MCP server */
|
||||
command?: string;
|
||||
/** Command-line arguments */
|
||||
args?: string[];
|
||||
/** Environment variables */
|
||||
env?: Record<string, string>;
|
||||
/** Working directory for stdio command */
|
||||
cwd?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MCPClientUpdateRequest {
|
||||
/** Client display name */
|
||||
name?: string;
|
||||
/** Client description */
|
||||
description?: string;
|
||||
/** Whether to enable the client */
|
||||
enabled?: boolean;
|
||||
/** MCP transport type */
|
||||
transport?: "stdio" | "streamable_http" | "sse";
|
||||
/** Remote MCP endpoint URL for HTTP/SSE transport */
|
||||
url?: string;
|
||||
/** HTTP headers for remote transport */
|
||||
headers?: Record<string, string>;
|
||||
/** Command to launch the MCP server */
|
||||
command?: string;
|
||||
/** Command-line arguments */
|
||||
args?: string[];
|
||||
/** Environment variables */
|
||||
env?: Record<string, string>;
|
||||
/** Working directory for stdio command */
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface LinkedInOAuthStatus {
|
||||
configured: boolean;
|
||||
linkedin_mcp_configured: boolean;
|
||||
linkedin_image_post_configured: boolean;
|
||||
linkedin_image_post_enabled: boolean;
|
||||
client_id_ready: boolean;
|
||||
client_secret_ready: boolean;
|
||||
oauth_token_ready: boolean;
|
||||
w_member_social_ready: boolean;
|
||||
profile_scopes_ready: boolean;
|
||||
scope: string[];
|
||||
token_source: string;
|
||||
token_user_id: string;
|
||||
redirect_uri: string;
|
||||
auth_url: string;
|
||||
oauth_process_running: boolean;
|
||||
available_tools: string[];
|
||||
missing_step: string;
|
||||
}
|
||||
|
||||
export interface LinkedInOAuthStartRequest {
|
||||
user_id?: string;
|
||||
force_reauth?: boolean;
|
||||
}
|
||||
|
||||
export interface LinkedInOAuthStartResponse {
|
||||
started: boolean;
|
||||
auth_url: string;
|
||||
redirect_uri: string;
|
||||
oauth_process_running: boolean;
|
||||
missing_step: string;
|
||||
log_tail: string[];
|
||||
connection_warning?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface PersonaCron {
|
||||
enabled: boolean;
|
||||
schedule: string;
|
||||
prompt: string;
|
||||
output: "chat" | "file" | "both";
|
||||
}
|
||||
|
||||
export interface Persona {
|
||||
id: string;
|
||||
name: string;
|
||||
soul_md: string;
|
||||
model_provider: string;
|
||||
model_name: string;
|
||||
skills: string[];
|
||||
mcp_clients: string[];
|
||||
is_coordinator: boolean;
|
||||
cron: PersonaCron | null;
|
||||
}
|
||||
|
||||
export interface PersonaTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
soul_md: string;
|
||||
skills: string[];
|
||||
mcp_clients: string[];
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ProviderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
api_key_prefix: string;
|
||||
/** Built-in models (for built-in providers) or all models (for custom). */
|
||||
models: ModelInfo[];
|
||||
/** User-added models (deletable). Only populated for built-in providers. */
|
||||
extra_models: ModelInfo[];
|
||||
is_custom: boolean;
|
||||
is_local: boolean;
|
||||
supports_llm?: boolean;
|
||||
supports_image?: boolean;
|
||||
/** True when the user must supply a base URL (custom or no default URL). */
|
||||
needs_base_url: boolean;
|
||||
base_url_label?: string;
|
||||
base_url_placeholder?: string;
|
||||
base_url_help?: string;
|
||||
api_key_label?: string;
|
||||
api_key_help?: string;
|
||||
current_api_key: string;
|
||||
current_base_url: string;
|
||||
}
|
||||
|
||||
export interface ProviderConfigRequest {
|
||||
api_key?: string;
|
||||
base_url?: string;
|
||||
}
|
||||
|
||||
export interface ModelSlotConfig {
|
||||
provider_id: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface ActiveModelsInfo {
|
||||
active_llm: ModelSlotConfig;
|
||||
}
|
||||
|
||||
export interface ModelSlotRequest {
|
||||
provider_id: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface ProviderUsageInfo {
|
||||
provider_id?: string | null;
|
||||
provider_name?: string | null;
|
||||
tier?: string | null;
|
||||
period_start?: string | null;
|
||||
period_end?: string | null;
|
||||
messages_limit?: number | null;
|
||||
messages_used?: number | null;
|
||||
messages_remaining?: number | null;
|
||||
cost_cap_usd?: number | null;
|
||||
estimated_cost_usd?: number | null;
|
||||
default_model?: string | null;
|
||||
models?: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/* ---- Custom provider CRUD ---- */
|
||||
|
||||
export interface CreateCustomProviderRequest {
|
||||
id: string;
|
||||
name: string;
|
||||
default_base_url?: string;
|
||||
api_key_prefix?: string;
|
||||
models?: ModelInfo[];
|
||||
}
|
||||
|
||||
export interface AddModelRequest {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/* ---- Local models ---- */
|
||||
|
||||
export interface LocalModelResponse {
|
||||
id: string;
|
||||
repo_id: string;
|
||||
filename: string;
|
||||
backend: string;
|
||||
source: string;
|
||||
file_size: number;
|
||||
local_path: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export interface DownloadModelRequest {
|
||||
repo_id: string;
|
||||
filename?: string;
|
||||
backend: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface DownloadTaskResponse {
|
||||
task_id: string;
|
||||
status: "pending" | "downloading" | "completed" | "failed" | "cancelled";
|
||||
repo_id: string;
|
||||
filename: string | null;
|
||||
backend: string;
|
||||
source: string;
|
||||
error: string | null;
|
||||
result: LocalModelResponse | null;
|
||||
}
|
||||
|
||||
/* ---- Ollama models ---- */
|
||||
|
||||
export interface OllamaModelResponse {
|
||||
name: string;
|
||||
size: number;
|
||||
digest?: string | null;
|
||||
modified_at?: string | null;
|
||||
}
|
||||
|
||||
export interface OllamaDownloadRequest {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface OllamaDownloadTaskResponse {
|
||||
task_id: string;
|
||||
status: "pending" | "downloading" | "completed" | "failed" | "cancelled";
|
||||
name: string;
|
||||
error: string | null;
|
||||
result: OllamaModelResponse | null;
|
||||
}
|
||||
|
||||
/* ---- Fallback chain ---- */
|
||||
|
||||
export interface FallbackSlot {
|
||||
provider_id: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface FallbackConfig {
|
||||
enabled: boolean;
|
||||
timeout_seconds: number;
|
||||
chain: FallbackSlot[];
|
||||
}
|
||||
|
||||
/* ---- Test Connection ---- */
|
||||
|
||||
export interface TestConnectionResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TestProviderRequest {
|
||||
api_key?: string;
|
||||
base_url?: string;
|
||||
}
|
||||
|
||||
export interface TestModelRequest {
|
||||
model_id: string;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface SkillSecurity {
|
||||
score: number;
|
||||
pattern_scan: "pass" | "fail" | "pending";
|
||||
llm_audit: "pass" | "fail" | "pending";
|
||||
auto_healed: boolean;
|
||||
}
|
||||
|
||||
export interface SkillSpec {
|
||||
name: string;
|
||||
content: string;
|
||||
source: string;
|
||||
path: string;
|
||||
enabled?: boolean;
|
||||
security?: SkillSecurity;
|
||||
}
|
||||
|
||||
export interface HubSkillSpec {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
source_url: string;
|
||||
}
|
||||
|
||||
// Legacy Skill interface for backward compatibility
|
||||
export interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
function_name: string;
|
||||
enabled: boolean;
|
||||
version: string;
|
||||
tags: string[];
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface MdFileInfo {
|
||||
filename: string;
|
||||
path: string;
|
||||
size: number;
|
||||
created_time: string;
|
||||
modified_time: string;
|
||||
}
|
||||
|
||||
export interface MdFileContent {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface MarkdownFile extends MdFileInfo {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface DailyMemoryFile extends MdFileInfo {
|
||||
date: string;
|
||||
updated_at: number;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,68 @@
|
||||
.wrap {
|
||||
position: fixed;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: min(400px, calc(100vw - 48px));
|
||||
pointer-events: none;
|
||||
|
||||
& > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.bubble {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: var(--citedy-glass-bg, rgba(255, 255, 255, 0.8));
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid rgba(226, 232, 240, 0.4);
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--citedy-slate-900, #0f172a);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--citedy-slate-700, #334155);
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
max-height: 6em;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
line-clamp: 4;
|
||||
}
|
||||
|
||||
.close {
|
||||
flex-shrink: 0;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--citedy-slate-400, #94a3b8);
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--citedy-slate-700, #334155);
|
||||
background: var(--citedy-slate-100, #f1f5f9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { MessageCircle, X } from "lucide-react";
|
||||
import { consoleApi, type PushMessage } from "../../api/modules/console";
|
||||
import styles from "./index.module.less";
|
||||
|
||||
const POLL_INTERVAL_MS = 2500;
|
||||
const AUTO_DISMISS_MS = 8000;
|
||||
const MAX_SEEN_IDS = 500;
|
||||
const MAX_VISIBLE_BUBBLES = 4;
|
||||
const MAX_NEW_PER_POLL = 2;
|
||||
const TITLE_BLINK_PREFIX = "\u2022 ";
|
||||
|
||||
interface BubbleItem extends PushMessage {
|
||||
dismissAt: number;
|
||||
}
|
||||
|
||||
export default function ConsoleCronBubble() {
|
||||
const [items, setItems] = useState<BubbleItem[]>([]);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const seenIdsRef = useRef<Set<string>>(new Set());
|
||||
const originalTitleRef = useRef(document.title);
|
||||
const blinkRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const dismiss = (id: string) => {
|
||||
setItems((prev) => prev.filter((i) => i.id !== id));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
originalTitleRef.current = document.title;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () => {
|
||||
consoleApi
|
||||
.getPushMessages()
|
||||
.then((res) => {
|
||||
if (!res?.messages?.length) return;
|
||||
const seen = seenIdsRef.current;
|
||||
if (seen.size > MAX_SEEN_IDS) seen.clear();
|
||||
const newItems: BubbleItem[] = [];
|
||||
const now = Date.now();
|
||||
for (const m of res.messages) {
|
||||
if (seen.has(m.id)) continue;
|
||||
seen.add(m.id);
|
||||
newItems.push({ ...m, dismissAt: now + AUTO_DISMISS_MS });
|
||||
}
|
||||
if (newItems.length === 0) return;
|
||||
const toAdd = newItems.slice(-MAX_NEW_PER_POLL);
|
||||
setItems((prev) => {
|
||||
const merged = [...prev, ...toAdd];
|
||||
return merged.slice(-MAX_VISIBLE_BUBBLES);
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
tick();
|
||||
pollRef.current = setInterval(tick, POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length === 0) return;
|
||||
const t = setInterval(() => {
|
||||
const now = Date.now();
|
||||
setItems((prev) => {
|
||||
const next = prev.filter((i) => i.dismissAt > now);
|
||||
return next.length === prev.length ? prev : next;
|
||||
});
|
||||
}, 500);
|
||||
return () => clearInterval(t);
|
||||
}, [items.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length === 0 || !document.hidden || blinkRef.current) return;
|
||||
const original = originalTitleRef.current;
|
||||
let showPrefix = true;
|
||||
blinkRef.current = setInterval(() => {
|
||||
document.title = showPrefix
|
||||
? `${TITLE_BLINK_PREFIX}${original}`
|
||||
: original;
|
||||
showPrefix = !showPrefix;
|
||||
}, 800);
|
||||
return () => {
|
||||
if (blinkRef.current) {
|
||||
clearInterval(blinkRef.current);
|
||||
blinkRef.current = null;
|
||||
}
|
||||
document.title = original;
|
||||
};
|
||||
}, [items.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
if (blinkRef.current) {
|
||||
clearInterval(blinkRef.current);
|
||||
blinkRef.current = null;
|
||||
}
|
||||
document.title = originalTitleRef.current;
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => document.removeEventListener("visibilitychange", onVisibility);
|
||||
}, []);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.wrap} role="region" aria-label="Cron messages">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className={styles.bubble}>
|
||||
<MessageCircle size={18} className={styles.icon} aria-hidden />
|
||||
<p className={styles.text} title={item.text}>
|
||||
{item.text}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.close}
|
||||
onClick={() => dismiss(item.id)}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Button, message, Switch, Input } from "@agentscope-ai/design";
|
||||
import { CopyOutlined } from "@ant-design/icons";
|
||||
import { XMarkdown } from "@ant-design/x-markdown";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { CSSProperties } from "react";
|
||||
import { stripFrontmatter } from "../../utils/markdown";
|
||||
import styles from "./index.module.less";
|
||||
|
||||
interface MarkdownCopyProps {
|
||||
content: string;
|
||||
showMarkdown?: boolean;
|
||||
onShowMarkdownChange?: (show: boolean) => void;
|
||||
copyButtonProps?: {
|
||||
type?:
|
||||
| "text"
|
||||
| "link"
|
||||
| "default"
|
||||
| "primary"
|
||||
| "dashed"
|
||||
| "primaryLess"
|
||||
| "textCompact"
|
||||
| undefined;
|
||||
size?: "small" | "middle" | "large" | undefined;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
markdownViewerProps?: {
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
};
|
||||
textareaProps?: {
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
};
|
||||
showControls?: boolean;
|
||||
editable?: boolean;
|
||||
onContentChange?: (content: string) => void;
|
||||
}
|
||||
|
||||
export function MarkdownCopy({
|
||||
content,
|
||||
showMarkdown = true,
|
||||
onShowMarkdownChange,
|
||||
copyButtonProps = {},
|
||||
markdownViewerProps = {},
|
||||
textareaProps = {},
|
||||
showControls = true,
|
||||
editable = false,
|
||||
onContentChange,
|
||||
}: MarkdownCopyProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const [editContent, setEditContent] = useState(content);
|
||||
const [localShowMarkdown, setLocalShowMarkdown] = useState(showMarkdown);
|
||||
const markdownContent = useMemo(
|
||||
() => stripFrontmatter(content || ""),
|
||||
[content],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEditContent(content);
|
||||
}, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editable && !textareaProps.disabled) {
|
||||
setLocalShowMarkdown(false);
|
||||
} else {
|
||||
setLocalShowMarkdown(showMarkdown);
|
||||
}
|
||||
}, [editable, textareaProps.disabled, showMarkdown]);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
const contentToCopy =
|
||||
localShowMarkdown && !(editable && !textareaProps.disabled)
|
||||
? content
|
||||
: editable
|
||||
? editContent
|
||||
: content;
|
||||
|
||||
if (!contentToCopy) return;
|
||||
|
||||
setIsCopying(true);
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(contentToCopy);
|
||||
message.success(t("common.copied"));
|
||||
} else {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = contentToCopy;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.left = "-999999px";
|
||||
textArea.style.top = "-999999px";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
textArea.remove();
|
||||
message.success(t("common.copied"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to copy text: ", err);
|
||||
message.error(t("common.copyFailed"));
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newContent = e.target.value;
|
||||
setEditContent(newContent);
|
||||
if (onContentChange) {
|
||||
onContentChange(newContent);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowMarkdownChange = (show: boolean) => {
|
||||
setLocalShowMarkdown(show);
|
||||
if (onShowMarkdownChange) {
|
||||
onShowMarkdownChange(show);
|
||||
}
|
||||
};
|
||||
|
||||
const defaultCopyButtonProps = {
|
||||
type: "text" as const,
|
||||
size: "small" as const,
|
||||
...copyButtonProps,
|
||||
};
|
||||
|
||||
const defaultMarkdownViewerProps = {
|
||||
style: {
|
||||
padding: 16,
|
||||
height: "100%",
|
||||
overflow: "auto",
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 6,
|
||||
...markdownViewerProps.style,
|
||||
},
|
||||
...markdownViewerProps,
|
||||
};
|
||||
|
||||
const defaultTextareaProps = {
|
||||
rows: 12,
|
||||
placeholder: t("common.contentPlaceholder"),
|
||||
...textareaProps,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.markdownCopy}>
|
||||
{showControls && (
|
||||
<div className={styles.controls}>
|
||||
<div>{t("common.content")}</div>
|
||||
<div className={styles.controlGroup}>
|
||||
<div className={styles.previewToggle}>
|
||||
<span className={styles.previewLabel}>{t("common.preview")}</span>
|
||||
<Switch
|
||||
checked={localShowMarkdown}
|
||||
onChange={handleShowMarkdownChange}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
{...defaultCopyButtonProps}
|
||||
onClick={copyToClipboard}
|
||||
loading={isCopying}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localShowMarkdown ? (
|
||||
<div className={styles.markdownViewer}>
|
||||
<XMarkdown
|
||||
content={markdownContent}
|
||||
{...defaultMarkdownViewerProps}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.textareaContainer}>
|
||||
<Input.TextArea
|
||||
value={editable ? editContent : content}
|
||||
onChange={handleContentChange}
|
||||
{...defaultTextareaProps}
|
||||
className={styles.textarea}
|
||||
readOnly={!editable || textareaProps.disabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
.markdownCopy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.controlGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.previewToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.previewLabel {
|
||||
font-size: 12px;
|
||||
color: var(--citedy-slate-600);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.markdownViewer {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--citedy-slate-200);
|
||||
border-radius: 6px;
|
||||
background-color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.textareaContainer {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
resize: none;
|
||||
min-height: 300px;
|
||||
max-height: 300px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--citedy-slate-200);
|
||||
border-radius: 6px;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import en from "./locales/en.json";
|
||||
|
||||
const resources = {
|
||||
en: {
|
||||
translation: en,
|
||||
},
|
||||
};
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources,
|
||||
lng: localStorage.getItem("language") || "en",
|
||||
fallbackLng: "en",
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Layout } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const { Header: AntHeader } = Layout;
|
||||
|
||||
const keyToLabel: Record<string, string> = {
|
||||
chat: "nav.chat",
|
||||
channels: "nav.channels",
|
||||
sessions: "nav.sessions",
|
||||
"cron-jobs": "nav.cronJobs",
|
||||
heartbeat: "nav.heartbeat",
|
||||
skills: "nav.skills",
|
||||
mcp: "nav.mcp",
|
||||
personas: "nav.personas",
|
||||
"agent-config": "nav.agentConfig",
|
||||
workspace: "nav.workspace",
|
||||
models: "nav.models",
|
||||
environments: "nav.environments",
|
||||
brand: "nav.brand",
|
||||
};
|
||||
|
||||
interface HeaderProps {
|
||||
selectedKey: string;
|
||||
}
|
||||
|
||||
export default function Header({ selectedKey }: HeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<AntHeader
|
||||
style={{
|
||||
height: 64,
|
||||
padding: "0 24px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
background: "rgba(255, 255, 255, 0.8)",
|
||||
backdropFilter: "blur(24px)",
|
||||
WebkitBackdropFilter: "blur(24px)",
|
||||
borderBottom: "1px solid rgba(226, 232, 240, 0.6)",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 18, fontWeight: 500 }}>
|
||||
{t(keyToLabel[selectedKey] || "nav.chat")}
|
||||
</span>
|
||||
</AntHeader>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Layout } from "antd";
|
||||
import { useEffect } from "react";
|
||||
import { Routes, Route, useLocation, useNavigate } from "react-router-dom";
|
||||
import Sidebar from "../Sidebar";
|
||||
import ConsoleCronBubble from "../../components/ConsoleCronBubble";
|
||||
import Chat from "../../pages/Chat";
|
||||
import ChannelsPage from "../../pages/Control/Channels";
|
||||
import SessionsPage from "../../pages/Control/Sessions";
|
||||
import CronJobsPage from "../../pages/Control/CronJobs";
|
||||
import HeartbeatPage from "../../pages/Control/Heartbeat";
|
||||
import AgentConfigPage from "../../pages/Agent/Config";
|
||||
import SkillsPage from "../../pages/Agent/Skills";
|
||||
import WorkspacePage from "../../pages/Agent/Workspace";
|
||||
import MCPPage from "../../pages/Agent/MCP";
|
||||
import ModelsPage from "../../pages/Settings/Models";
|
||||
import EnvironmentsPage from "../../pages/Settings/Environments";
|
||||
import BrandPage from "../../pages/Settings/Brand";
|
||||
import CRMPage from "../../pages/Settings/CRM";
|
||||
import DiagnosticsPage from "../../pages/Control/Diagnostics";
|
||||
import PersonasPage from "../../pages/Personas";
|
||||
import DashboardPage from "../../pages/Dashboard";
|
||||
import WelcomePage from "../../pages/Welcome";
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
const pathToKey: Record<string, string> = {
|
||||
"/welcome": "welcome",
|
||||
"/dashboard": "dashboard",
|
||||
"/chat": "chat",
|
||||
"/channels": "channels",
|
||||
"/sessions": "sessions",
|
||||
"/cron-jobs": "cron-jobs",
|
||||
"/heartbeat": "heartbeat",
|
||||
"/skills": "skills",
|
||||
"/mcp": "mcp",
|
||||
"/workspace": "workspace",
|
||||
"/personas": "personas",
|
||||
"/agents": "agents",
|
||||
"/models": "models",
|
||||
"/environments": "environments",
|
||||
"/brand": "brand",
|
||||
"/crm": "crm",
|
||||
"/agent-config": "agent-config",
|
||||
"/diagnostics": "diagnostics",
|
||||
};
|
||||
|
||||
export default function MainLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const currentPath = location.pathname;
|
||||
const selectedKey = pathToKey[currentPath] || "chat";
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPath === "/") {
|
||||
const welcomeSeen = localStorage.getItem("MASKANX_welcome_seen");
|
||||
navigate(welcomeSeen ? "/chat" : "/welcome", { replace: true });
|
||||
}
|
||||
}, [currentPath, navigate]);
|
||||
|
||||
const isWelcomePage = currentPath === "/welcome";
|
||||
const isChatPage = currentPath === "/chat" || currentPath === "/";
|
||||
|
||||
if (isWelcomePage) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/welcome" element={<WelcomePage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout style={{ height: "100vh" }}>
|
||||
<Sidebar selectedKey={selectedKey} />
|
||||
<Layout>
|
||||
<Content className="page-container">
|
||||
<ConsoleCronBubble />
|
||||
<div
|
||||
className={`page-content${isChatPage ? " page-content-chat" : ""}`}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/chat" element={<Chat />} />
|
||||
<Route path="/channels" element={<ChannelsPage />} />
|
||||
<Route path="/sessions" element={<SessionsPage />} />
|
||||
<Route path="/cron-jobs" element={<CronJobsPage />} />
|
||||
<Route path="/heartbeat" element={<HeartbeatPage />} />
|
||||
<Route path="/skills" element={<SkillsPage />} />
|
||||
<Route path="/mcp" element={<MCPPage />} />
|
||||
<Route path="/workspace" element={<WorkspacePage />} />
|
||||
<Route path="/models" element={<ModelsPage />} />
|
||||
<Route path="/environments" element={<EnvironmentsPage />} />
|
||||
<Route path="/brand" element={<BrandPage />} />
|
||||
<Route path="/crm" element={<CRMPage />} />
|
||||
<Route path="/personas" element={<PersonasPage />} />
|
||||
<Route path="/agent-config" element={<AgentConfigPage />} />
|
||||
<Route path="/diagnostics" element={<DiagnosticsPage />} />
|
||||
<Route path="/" element={<Chat />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
import { Layout, Menu, Button, Modal, Input, Select, message, type MenuProps } from "antd";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import api from "../api";
|
||||
import { request } from "../api/request";
|
||||
import { companyApi, type CompanyInfo } from "../api/modules/company";
|
||||
import {
|
||||
MessageSquare,
|
||||
Radio,
|
||||
Zap,
|
||||
MessageCircle,
|
||||
Wifi,
|
||||
UsersRound,
|
||||
CalendarClock,
|
||||
Activity,
|
||||
Sparkles,
|
||||
Briefcase,
|
||||
Cpu,
|
||||
Box,
|
||||
Globe,
|
||||
Settings,
|
||||
Plug,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Wallet,
|
||||
ExternalLink,
|
||||
HeartPulse,
|
||||
Users,
|
||||
LayoutDashboard,
|
||||
Image,
|
||||
Building2,
|
||||
Database,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import maskanLogoUrl from "../assets/maskan-logo.png?url";
|
||||
|
||||
const { Sider } = Layout;
|
||||
const MOBILE_SIDEBAR_MAX_WIDTH = 1024;
|
||||
const keyToPath: Record<string, string> = {
|
||||
dashboard: "/dashboard",
|
||||
chat: "/chat",
|
||||
channels: "/channels",
|
||||
sessions: "/sessions",
|
||||
"cron-jobs": "/cron-jobs",
|
||||
heartbeat: "/heartbeat",
|
||||
skills: "/skills",
|
||||
mcp: "/mcp",
|
||||
workspace: "/workspace",
|
||||
personas: "/personas",
|
||||
models: "/models",
|
||||
environments: "/environments",
|
||||
brand: "/brand",
|
||||
crm: "/crm",
|
||||
"agent-config": "/agent-config",
|
||||
diagnostics: "/diagnostics",
|
||||
};
|
||||
|
||||
interface SidebarProps {
|
||||
selectedKey: string;
|
||||
}
|
||||
|
||||
interface CitedyStatusResponse {
|
||||
configured: boolean;
|
||||
balance?: { credits?: number } | null;
|
||||
status?: string;
|
||||
billing_url?: string;
|
||||
developer_url?: string;
|
||||
}
|
||||
|
||||
export default function Sidebar({ selectedKey }: SidebarProps) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const [desktopCollapsed, setDesktopCollapsed] = useState(false);
|
||||
const [isNarrowViewport, setIsNarrowViewport] = useState(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia(`(max-width: ${MOBILE_SIDEBAR_MAX_WIDTH}px)`)
|
||||
.matches;
|
||||
});
|
||||
const [openKeys, setOpenKeys] = useState<string[]>([
|
||||
"chat-group",
|
||||
"control-group",
|
||||
"agent-group",
|
||||
"settings-group",
|
||||
]);
|
||||
const [version, setVersion] = useState<string>("");
|
||||
const [companies, setCompanies] = useState<CompanyInfo[]>([]);
|
||||
const [activeCompanyId, setActiveCompanyId] = useState<string>("");
|
||||
const [companyModalOpen, setCompanyModalOpen] = useState(false);
|
||||
const [newCompanyName, setNewCompanyName] = useState("");
|
||||
const [companyBusy, setCompanyBusy] = useState(false);
|
||||
const [citedyBalance, setCitedyBalance] = useState<{
|
||||
configured: boolean;
|
||||
credits?: number;
|
||||
status?: string;
|
||||
billing_url?: string;
|
||||
developer_url?: string;
|
||||
} | null>(null);
|
||||
const collapsed = isNarrowViewport || desktopCollapsed;
|
||||
const useCompactPopupMenu = isNarrowViewport && collapsed;
|
||||
const menuMode: MenuProps["mode"] = useCompactPopupMenu
|
||||
? "vertical"
|
||||
: "inline";
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getVersion()
|
||||
.then((res) => setVersion(res?.version ?? ""))
|
||||
.catch(() => {});
|
||||
// Fetch Citedy status
|
||||
request<CitedyStatusResponse>("/citedy/status")
|
||||
.then((res) => {
|
||||
setCitedyBalance({
|
||||
configured: res.configured,
|
||||
credits: res.balance?.credits,
|
||||
status: res.status,
|
||||
billing_url: res.billing_url,
|
||||
developer_url: res.developer_url,
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
companyApi
|
||||
.listCompanies()
|
||||
.then((state) => {
|
||||
setCompanies(state.companies);
|
||||
setActiveCompanyId(state.active_company_id);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia(
|
||||
`(max-width: ${MOBILE_SIDEBAR_MAX_WIDTH}px)`,
|
||||
);
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
setIsNarrowViewport(event.matches);
|
||||
};
|
||||
|
||||
setIsNarrowViewport(mediaQuery.matches);
|
||||
|
||||
if (typeof mediaQuery.addEventListener === "function") {
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
}
|
||||
|
||||
mediaQuery.addListener(handleChange);
|
||||
return () => mediaQuery.removeListener(handleChange);
|
||||
}, []);
|
||||
|
||||
const menuItems: MenuProps["items"] = [
|
||||
{
|
||||
key: "chat-group",
|
||||
label: t("nav.chat"),
|
||||
icon: <MessageSquare size={16} />,
|
||||
children: [
|
||||
{
|
||||
key: "dashboard",
|
||||
label: "Dashboard",
|
||||
icon: <LayoutDashboard size={16} />,
|
||||
},
|
||||
{
|
||||
key: "chat",
|
||||
label: t("nav.chat"),
|
||||
icon: <MessageCircle size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "control-group",
|
||||
label: t("nav.control"),
|
||||
icon: <Radio size={16} />,
|
||||
children: [
|
||||
{
|
||||
key: "channels",
|
||||
label: t("nav.channels"),
|
||||
icon: <Wifi size={16} />,
|
||||
},
|
||||
{
|
||||
key: "sessions",
|
||||
label: t("nav.sessions"),
|
||||
icon: <UsersRound size={16} />,
|
||||
},
|
||||
{
|
||||
key: "cron-jobs",
|
||||
label: t("nav.cronJobs"),
|
||||
icon: <CalendarClock size={16} />,
|
||||
},
|
||||
{
|
||||
key: "heartbeat",
|
||||
label: t("nav.heartbeat"),
|
||||
icon: <Activity size={16} />,
|
||||
},
|
||||
{
|
||||
key: "diagnostics",
|
||||
label: "Diagnostics",
|
||||
icon: <HeartPulse size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "agent-group",
|
||||
label: t("nav.agent"),
|
||||
icon: <Zap size={16} />,
|
||||
children: [
|
||||
{
|
||||
key: "workspace",
|
||||
label: t("nav.workspace"),
|
||||
icon: <Briefcase size={16} />,
|
||||
},
|
||||
{
|
||||
key: "skills",
|
||||
label: t("nav.skills"),
|
||||
icon: <Sparkles size={16} />,
|
||||
},
|
||||
{
|
||||
key: "mcp",
|
||||
label: t("nav.mcp"),
|
||||
icon: <Plug size={16} />,
|
||||
},
|
||||
{
|
||||
key: "personas",
|
||||
label: t("nav.personas"),
|
||||
icon: <Users size={16} />,
|
||||
},
|
||||
{
|
||||
key: "agent-config",
|
||||
label: t("nav.agentConfig"),
|
||||
icon: <Settings size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "settings-group",
|
||||
label: t("nav.settings"),
|
||||
icon: <Cpu size={16} />,
|
||||
children: [
|
||||
{
|
||||
key: "models",
|
||||
label: t("nav.models"),
|
||||
icon: <Box size={16} />,
|
||||
},
|
||||
{
|
||||
key: "environments",
|
||||
label: t("nav.environments"),
|
||||
icon: <Globe size={16} />,
|
||||
},
|
||||
{
|
||||
key: "brand",
|
||||
label: t("nav.brand"),
|
||||
icon: <Image size={16} />,
|
||||
},
|
||||
{
|
||||
key: "crm",
|
||||
label: t("nav.crm"),
|
||||
icon: <Database size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const handleCompanySwitch = async (companyId: string) => {
|
||||
if (!companyId || companyId === activeCompanyId) return;
|
||||
|
||||
setCompanyBusy(true);
|
||||
try {
|
||||
await companyApi.switchCompany(companyId);
|
||||
message.success("Company switched");
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error("Failed to switch company:", error);
|
||||
message.error("Failed to switch company");
|
||||
setCompanyBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCompany = async () => {
|
||||
const name = newCompanyName.trim();
|
||||
if (!name) {
|
||||
message.error("Company name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setCompanyBusy(true);
|
||||
try {
|
||||
await companyApi.createCompany({ name, switch: true });
|
||||
message.success("Company created");
|
||||
setCompanyModalOpen(false);
|
||||
setNewCompanyName("");
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error("Failed to create company:", error);
|
||||
message.error("Failed to create company");
|
||||
setCompanyBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sider
|
||||
collapsed={collapsed}
|
||||
onCollapse={(value) => {
|
||||
if (!isNarrowViewport) {
|
||||
setDesktopCollapsed(value);
|
||||
}
|
||||
}}
|
||||
width={260}
|
||||
collapsedWidth={68}
|
||||
style={{
|
||||
overflow: collapsed ? "hidden" : "auto",
|
||||
height: "100vh",
|
||||
width: collapsed ? 68 : 260,
|
||||
minWidth: collapsed ? 68 : 260,
|
||||
maxWidth: collapsed ? 68 : 260,
|
||||
flex: `0 0 ${collapsed ? 68 : 260}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 64,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: collapsed ? "center" : "space-between",
|
||||
padding: collapsed ? "0" : "0 16px",
|
||||
gap: collapsed ? 0 : 10,
|
||||
}}
|
||||
>
|
||||
{!collapsed ? (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/maskan-logo.png"
|
||||
alt="MaskanX"
|
||||
style={{
|
||||
height: 34,
|
||||
width: 34,
|
||||
display: "block",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
color: "#0f172a",
|
||||
lineHeight: 1,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
MaskanX
|
||||
</span>
|
||||
{version && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "#94a3b8",
|
||||
fontWeight: 400,
|
||||
lineHeight: 1,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
v{version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PanelLeftClose size={20} />}
|
||||
onClick={() => setDesktopCollapsed(true)}
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
color: "#0f172a",
|
||||
width: 36,
|
||||
height: 36,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
src={maskanLogoUrl}
|
||||
alt="MaskanX"
|
||||
style={{
|
||||
height: 30,
|
||||
width: 30,
|
||||
display: "block",
|
||||
flexShrink: 0,
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
{!isNarrowViewport && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PanelLeftOpen size={20} />}
|
||||
onClick={() => setDesktopCollapsed(false)}
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
color: "#0f172a",
|
||||
width: 36,
|
||||
height: 36,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0 16px 12px",
|
||||
borderBottom: "1px solid rgba(226, 232, 240, 0.6)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Building2 size={16} color="#475569" />
|
||||
<Select
|
||||
size="small"
|
||||
value={activeCompanyId || undefined}
|
||||
placeholder="Select company"
|
||||
loading={companyBusy}
|
||||
disabled={companyBusy}
|
||||
onChange={handleCompanySwitch}
|
||||
options={companies.map((company) => ({
|
||||
value: company.id,
|
||||
label: company.name,
|
||||
}))}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
aria-label="Create company"
|
||||
icon={<Plus size={16} />}
|
||||
disabled={companyBusy}
|
||||
onClick={() => setCompanyModalOpen(true)}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Menu
|
||||
mode={menuMode}
|
||||
selectedKeys={[selectedKey]}
|
||||
triggerSubMenuAction={useCompactPopupMenu ? "click" : "hover"}
|
||||
openKeys={!useCompactPopupMenu && !collapsed ? openKeys : undefined}
|
||||
onOpenChange={(keys) => {
|
||||
if (!useCompactPopupMenu && !collapsed) {
|
||||
setOpenKeys(keys as string[]);
|
||||
}
|
||||
}}
|
||||
onClick={(info: { key: string | number }) => {
|
||||
const key = String(info.key);
|
||||
const path = keyToPath[key];
|
||||
if (path) {
|
||||
navigate(path);
|
||||
}
|
||||
}}
|
||||
items={menuItems}
|
||||
style={{
|
||||
width: "100%",
|
||||
borderInlineEnd: "none",
|
||||
background: "transparent",
|
||||
}}
|
||||
/>
|
||||
{!collapsed && citedyBalance?.configured && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderTop: "1px solid rgba(226, 232, 240, 0.6)",
|
||||
fontSize: 12,
|
||||
color: "#475569",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<Wallet size={14} />
|
||||
{citedyBalance.credits != null
|
||||
? `${citedyBalance.credits} credits`
|
||||
: citedyBalance.status === "invalid"
|
||||
? "Reconnect Citedy"
|
||||
: "Citedy"}
|
||||
</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
style={{ padding: 0, fontSize: 12 }}
|
||||
icon={<ExternalLink size={12} />}
|
||||
onClick={() =>
|
||||
window.open(
|
||||
citedyBalance.status === "invalid"
|
||||
? citedyBalance.developer_url ||
|
||||
"https://www.citedy.com/developer"
|
||||
: citedyBalance.billing_url ||
|
||||
"https://www.citedy.com/dashboard/billing",
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
{citedyBalance.status === "invalid" ? "Fix" : "Top Up"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Modal
|
||||
title="Create Company"
|
||||
open={companyModalOpen}
|
||||
okText="Create and Switch"
|
||||
cancelText="Cancel"
|
||||
confirmLoading={companyBusy}
|
||||
onOk={handleCreateCompany}
|
||||
onCancel={() => {
|
||||
if (!companyBusy) {
|
||||
setCompanyModalOpen(false);
|
||||
setNewCompanyName("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
value={newCompanyName}
|
||||
placeholder="Company name"
|
||||
maxLength={80}
|
||||
onChange={(event) => setNewCompanyName(event.target.value)}
|
||||
onPressEnter={handleCreateCompany}
|
||||
/>
|
||||
</Modal>
|
||||
</Sider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"reset": "Reset",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"create": "Create",
|
||||
"upload": "Upload",
|
||||
"download": "Download",
|
||||
"refresh": "Refresh",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"preview": "Preview",
|
||||
"content": "Content",
|
||||
"loading": "Loading...",
|
||||
"copied": "Copied to clipboard",
|
||||
"copyFailed": "Failed to copy to clipboard",
|
||||
"contentPlaceholder": "Enter content..."
|
||||
},
|
||||
"nav": {
|
||||
"chat": "Chat",
|
||||
"control": "Control",
|
||||
"channels": "Channels",
|
||||
"sessions": "Sessions",
|
||||
"cronJobs": "Cron Jobs",
|
||||
"heartbeat": "Heartbeat",
|
||||
"agent": "Agent",
|
||||
"workspace": "Workspace",
|
||||
"skills": "Skills",
|
||||
"mcp": "MCP",
|
||||
"agentConfig": "Configuration",
|
||||
"settings": "Settings",
|
||||
"models": "Models",
|
||||
"environments": "Environments",
|
||||
"brand": "Brand",
|
||||
"crm": "Maskan CRM",
|
||||
"personas": "Personas"
|
||||
},
|
||||
"workspace": {
|
||||
"title": "Workspace",
|
||||
"workspacePath": "Workspace:",
|
||||
"noFiles": "No files",
|
||||
"coreFiles": "Core Files",
|
||||
"coreFilesDesc": "Bootstrap persona, identity, and tool guidance.",
|
||||
"uploadTooltip": "Bootstrap persona, identity, and tool guidance. (ZIP files only, max 100MB)",
|
||||
"selectFile": "Select a file to edit",
|
||||
"fileContent": "File content...",
|
||||
"uploadSuccess": "File uploaded successfully",
|
||||
"uploadFailed": "File upload failed",
|
||||
"downloadSuccess": "Workspace downloaded successfully",
|
||||
"downloadFailed": "Workspace download failed",
|
||||
"zipOnly": "Only .zip files are supported for upload",
|
||||
"fileSizeExceeded": "File size exceeds 100MB limit. Current file: {{size}}MB",
|
||||
"attribution": "Workspace design partly inspired by the OpenClaw project — thank you! ðŸ¾"
|
||||
},
|
||||
"skills": {
|
||||
"title": "Skills",
|
||||
"description": "Manage agent skills and capabilities.",
|
||||
"importSkills": "Import Skill",
|
||||
"enterSkillUrl": "Enter Skill URL",
|
||||
"supportedSkillUrlSources": "Currently supported skill URL sources:",
|
||||
"urlExamples": "URL examples:",
|
||||
"invalidSkillUrlSource": "Skill URL currently needs to start with https://skills.sh/, https://clawhub.ai/, https://skillsmp.com/, or https://github.com/",
|
||||
"source": "Source",
|
||||
"path": "Path",
|
||||
"createSkill": "Create Skill",
|
||||
"viewSkill": "View Skill",
|
||||
"editSkill": "Edit Skill",
|
||||
"skillName": "Skill Name",
|
||||
"skillContent": "Skill Content",
|
||||
"pleaseInputName": "Please input skill name",
|
||||
"pleaseInputContent": "Please input skill content",
|
||||
"skillNamePlaceholder": "e.g., weather_query",
|
||||
"contentPlaceholder": "---\nname: <skill_name> (required)\ndescription: <skill description> (required)\nmetadata: { \"MaskanX\": { \"emoji\": \"🔧\" } }\n---\n\nSkill implementation content...\n\n# Example:\n# ---\n# name: cron\n# description: Manage cron jobs via MaskanX commands - create, query, pause, resume, delete tasks\n# metadata: { \"MaskanX\": { \"emoji\": \"â°\" } }\n# ---",
|
||||
"createSuccess": "Skill created successfully",
|
||||
"createFailed": "Failed to create skill",
|
||||
"deleteConfirm": "Are you sure you want to delete this skill?",
|
||||
"deleteSuccess": "Skill deleted successfully",
|
||||
"deleteFailed": "Failed to delete skill",
|
||||
"updateSuccess": "Skill updated successfully",
|
||||
"updateFailed": "Failed to update skill",
|
||||
"frontmatterRequired": "Skills must start and end with ---",
|
||||
"frontmatterNameRequired": "Skills missing required field : name",
|
||||
"frontmatterDescriptionRequired": "Skills missing required field : description",
|
||||
"editNotSupported": "Edit operation is not supported by backend API",
|
||||
"editNote": "Note: Backend API does not support editing skills. You can only view or toggle enable/disable status.",
|
||||
"create": "Create"
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP Clients",
|
||||
"description": "Manage Model Context Protocol (MCP) clients for extending agent capabilities.",
|
||||
"create": "Create Client",
|
||||
"formatSupport": "Supported formats",
|
||||
"emptyState": "No MCP clients configured yet",
|
||||
"loadError": "Failed to load MCP clients",
|
||||
"createSuccess": "MCP client created successfully",
|
||||
"createError": "Failed to create MCP client",
|
||||
"updateSuccess": "MCP client updated successfully",
|
||||
"updateError": "Failed to update MCP client",
|
||||
"enableSuccess": "MCP client enabled successfully",
|
||||
"disableSuccess": "MCP client disabled successfully",
|
||||
"toggleError": "Failed to toggle MCP client status",
|
||||
"deleteConfirm": "Are you sure you want to delete this MCP client?",
|
||||
"deleteSuccess": "MCP client deleted successfully",
|
||||
"deleteError": "Failed to delete MCP client"
|
||||
},
|
||||
"heartbeat": {
|
||||
"title": "Heartbeat",
|
||||
"description": "Run HEARTBEAT.md at a fixed interval for self-checks. By default runs silently without affecting current conversations, or optionally send replies to the last chat channel.",
|
||||
"enabled": "Enable heartbeat",
|
||||
"every": "Interval",
|
||||
"everyRequired": "Required",
|
||||
"everyMin": "Must be at least 1",
|
||||
"unitMinutes": "Minutes",
|
||||
"unitHours": "Hours",
|
||||
"target": "Reply target",
|
||||
"targetMain": "Silent mode (default, no channel output)",
|
||||
"targetLast": "Send to last chat channel",
|
||||
"activeHours": "Active hours (optional)",
|
||||
"activeStart": "Start time",
|
||||
"activeEnd": "End time",
|
||||
"loadFailed": "Failed to load heartbeat config",
|
||||
"saveSuccess": "Saved successfully; heartbeat hot-reloaded",
|
||||
"saveFailed": "Failed to save heartbeat config"
|
||||
},
|
||||
"cronJobs": {
|
||||
"title": "Cron Jobs",
|
||||
"description": "Create and manage scheduled tasks that automatically execute at specified times. ",
|
||||
"createJob": "Create Job",
|
||||
"editJob": "Edit Job",
|
||||
"confirmDelete": "Confirm Delete",
|
||||
"deleteConfirm": "Are you sure you want to delete this Cron Job?",
|
||||
"okText": "OK",
|
||||
"cancelText": "Cancel",
|
||||
"deleteText": "Delete",
|
||||
"id": "Job ID",
|
||||
"name": "Job Name",
|
||||
"enabled": "Status",
|
||||
"scheduleType": "Schedule Type",
|
||||
"scheduleCron": "Schedule (Cron)",
|
||||
"scheduleCronLabel": "Schedule (Cron)",
|
||||
"scheduleTimezone": "Timezone",
|
||||
"cronType": "Frequency",
|
||||
"cronTypeHourly": "Hourly",
|
||||
"cronTypeDaily": "Daily",
|
||||
"cronTypeWeekly": "Weekly",
|
||||
"cronTypeCustom": "Custom",
|
||||
"cronTime": "Time",
|
||||
"cronDaysOfWeek": "Days of Week",
|
||||
"cronDayMon": "Monday",
|
||||
"cronDayTue": "Tuesday",
|
||||
"cronDayWed": "Wednesday",
|
||||
"cronDayThu": "Thursday",
|
||||
"cronDayFri": "Friday",
|
||||
"cronDaySat": "Saturday",
|
||||
"cronDaySun": "Sunday",
|
||||
"cronCustomExpression": "Cron Expression",
|
||||
"taskText": "Description",
|
||||
"action": "Action",
|
||||
"disable": "Disable",
|
||||
"executeNow": "Execute Now",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"totalItems": "Total {{count}} items",
|
||||
"registryTitle": "Cron schedule registry",
|
||||
"registrySubtitle": "Track scheduled jobs in one calm operations surface.",
|
||||
"emptyTitle": "No cron jobs yet",
|
||||
"emptyDescription": "Create a scheduled job to automate reports, monitoring, and recurring agent tasks.",
|
||||
"perPage": "/ page",
|
||||
"pleaseInputId": "Please input job ID",
|
||||
"pleaseInputName": "Please input job name",
|
||||
"pleaseInputCron": "Please input cron expression",
|
||||
"pleaseSelectTaskType": "Please select task type",
|
||||
"pleaseInputRequest": "Please input request content",
|
||||
"pleaseInputChannel": "Please input target channel",
|
||||
"pleaseInputUserId": "Please input target user ID",
|
||||
"pleaseInputSessionId": "Please input target session ID",
|
||||
"jobIdPlaceholder": "e.g., daily-report-job",
|
||||
"jobNamePlaceholder": "e.g., Daily Morning Report",
|
||||
"selectTimezone": "Select timezone",
|
||||
"taskDescriptionPlaceholder": "Brief description of what this job does...",
|
||||
"invalidJsonFormat": "Invalid JSON format",
|
||||
"jsonFormatRequired": "JSON format required",
|
||||
"taskType": "Task Type",
|
||||
"text": "Description",
|
||||
"requestInput": "Request Content",
|
||||
"requestSessionId": "Request Session ID",
|
||||
"requestUserId": "Request User ID",
|
||||
"dispatchChannel": "Target Channel",
|
||||
"dispatchTargetUserId": "Target User ID",
|
||||
"dispatchTargetSessionId": "Target Session ID",
|
||||
"dispatchMode": "Delivery Mode",
|
||||
"runtimeMaxConcurrency": "Max Concurrency",
|
||||
"runtimeTimeoutSeconds": "Timeout (seconds)",
|
||||
"runtimeMisfireGraceSeconds": "Misfire Grace (seconds)",
|
||||
"idTooltip": "Unique identifier for this job. Use lowercase letters, numbers, hyphens, and underscores.",
|
||||
"nameTooltip": "A friendly name to help you identify this job.",
|
||||
"cronTooltip": "Define when the task should run",
|
||||
"cronExample": "Common examples: '0 9 * * *' = 9 AM daily | '*/30 * * * *' = every 30 min | '0 */2 * * *' = every 2 hours | '0 0 * * 0' = Sunday midnight",
|
||||
"cronHelper": "New to Cron expressions?",
|
||||
"cronHelperLink": "Use online generator",
|
||||
"timezoneTooltip": "Timezone for the cron schedule. Default: UTC",
|
||||
"taskTypeTooltip": "Choose 'text' for simple message tasks, or 'agent' for complex agent workflows.",
|
||||
"textTooltip": "Optional description of what this task does. Helpful for documentation.",
|
||||
"requestInputTooltip": "Message content in JSON format. This is what the agent will receive and process.",
|
||||
"requestInputExample": "Format: [{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Your message here\"}]}]",
|
||||
"requestSessionIdTooltip": "Session ID for the request context. Use 'default' if unsure.",
|
||||
"requestUserIdTooltip": "User ID that initiates the request. Use 'system' for automated tasks.",
|
||||
"dispatchChannelTooltip": "Target channel where the response will be sent (e.g., 'console', 'discord', 'imessage').",
|
||||
"dispatchTargetUserIdTooltip": "User ID who will receive the response in the target channel.",
|
||||
"dispatchTargetSessionIdTooltip": "Session ID where the response will be delivered in the target channel.",
|
||||
"dispatchModeTooltip": "Choose 'stream' for real-time responses or 'final' for complete responses only.",
|
||||
"maxConcurrencyTooltip": "Maximum number of this job that can run simultaneously. Default: 1",
|
||||
"timeoutSecondsTooltip": "Maximum execution time in seconds. Job will be terminated if exceeded.",
|
||||
"misfireGraceSecondsTooltip": "Grace period for missed executions. If a job misses its scheduled time by more than this, it won't run.",
|
||||
"executeNowTitle": "Execute Task Now",
|
||||
"executeNowContent": "Are you sure you want to execute \"{{name}}\" now?",
|
||||
"executeNowConfirm": "Execute Now"
|
||||
},
|
||||
"channels": {
|
||||
"title": "Channels",
|
||||
"description": "Manage and configure message channels",
|
||||
"loading": "Loading channels...",
|
||||
"configSaved": "Configuration saved successfully",
|
||||
"configFailed": "Failed to save configuration",
|
||||
"channelType": "Channel Type",
|
||||
"status": "Status",
|
||||
"totalItems": "Total {{count}} items",
|
||||
"botPrefix": "Bot Prefix",
|
||||
"filterToolMessages": "Show Tool Messages",
|
||||
"filterToolMessagesTooltip": "Display tool call and output messages to users (turn off to hide them)",
|
||||
"notSet": "Not set",
|
||||
"clickCardToEdit": "Click card to edit",
|
||||
"settings": "Settings",
|
||||
"channelSettings": "Channel Settings",
|
||||
"pleaseInputDbPath": "Please input DB path",
|
||||
"pleaseInputPollInterval": "Please input poll interval",
|
||||
"discordBotToken": "Discord bot token",
|
||||
"httpProxyPlaceholder": "http://127.0.0.1:18118",
|
||||
"httpProxyAuthPlaceholder": "user:password",
|
||||
"dbPathPlaceholder": "~/Library/Messages/chat.db",
|
||||
"botPrefixPlaceholder": "@bot"
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Sessions",
|
||||
"description": "View and manage active chat sessions",
|
||||
"loading": "Loading sessions...",
|
||||
"confirmDelete": "Confirm Delete",
|
||||
"deleteConfirm": "Are you sure you want to delete this session?",
|
||||
"batchDeleteConfirm": "Are you sure you want to delete selected {{count}} sessions?",
|
||||
"deleteSuccess": "Session deleted successfully",
|
||||
"deleteFailed": "Failed to delete session",
|
||||
"batchDeleteButton": "Batch Delete",
|
||||
"filterUserId": "Filter by User ID",
|
||||
"filterChannel": "Filter by Channel",
|
||||
"allChannels": "All Channels",
|
||||
"totalItems": "Total {{count}} items",
|
||||
"sessionListTitle": "Session list",
|
||||
"sessionListSubtitle": "Manage live conversations in one calm operations surface.",
|
||||
"emptyTitle": "No sessions yet",
|
||||
"emptyDescription": "New chat sessions will appear here once conversations start.",
|
||||
"selectedItems": "Selected {{count}} items",
|
||||
"editSession": "Edit Session",
|
||||
"pleaseInputName": "Please input session name",
|
||||
"sessionNamePlaceholder": "Session name"
|
||||
},
|
||||
"environments": {
|
||||
"title": "Environment Variables",
|
||||
"description": "Configure key-value environment variables for agents and skills.",
|
||||
"key": "Key",
|
||||
"value": "Value",
|
||||
"variableNamePlaceholder": "VARIABLE_NAME",
|
||||
"valuePlaceholder": "value",
|
||||
"insertRowBelow": "Insert row below",
|
||||
"deleteRow": "Delete row",
|
||||
"deleteVariable": "Delete Variable",
|
||||
"deleteConfirm": "Delete \"{{name}}\"?",
|
||||
"deleteSelected": "Delete Selected",
|
||||
"deleteSelectedConfirm": "Delete {{label}}?",
|
||||
"keyRequired": "Key is required",
|
||||
"invalidKeyFormat": "Invalid key format",
|
||||
"duplicateKey": "Duplicate key",
|
||||
"saveSuccess": "Environment variables saved",
|
||||
"saveFailed": "Failed to save",
|
||||
"noVariables": "No environment variables configured yet.",
|
||||
"addVariable": "Add Variable",
|
||||
"loading": "Loading…",
|
||||
"retry": "Retry",
|
||||
"of": "of",
|
||||
"selected": "selected",
|
||||
"variable": "variable",
|
||||
"variables": "variables"
|
||||
},
|
||||
"models": {
|
||||
"llmConfiguration": "LLM Configuration",
|
||||
"providersTitle": "Providers",
|
||||
"providersDescription": "Configure API keys and endpoints for each provider.",
|
||||
"llmTitle": "LLM",
|
||||
"llmDescription": "Choose the active LLM model from an authorized provider.",
|
||||
"configureProvider": "Configure {{name}}",
|
||||
"baseURL": "Base URL",
|
||||
"apiKey": "API Key",
|
||||
"currentKey": "Current: {{key}}",
|
||||
"startsWith": "Starts with \"{{prefix}}\"",
|
||||
"optionalSelfHosted": "Optional for self-hosted services",
|
||||
"leaveBlankKeep": "Leave blank to keep current key",
|
||||
"enterApiKey": "Enter API key ({{prefix}}-...)",
|
||||
"enterApiKeyOptional": "Enter API key (optional)",
|
||||
"openAIEndpoint": "OpenAI-compatible endpoint, e.g. http://localhost:11434/v1",
|
||||
"azureEndpointHint": "Azure OpenAI endpoint, e.g. https://<resource>.openai.azure.com/openai/v1",
|
||||
"pleaseEnterBaseURL": "Please enter the API base URL",
|
||||
"pleaseEnterValidURL": "Please enter a valid URL",
|
||||
"apiKeyShouldStart": "API Key should start with \"{{prefix}}\"",
|
||||
"configurationSaved": "{{name}} configuration saved",
|
||||
"failedToSaveConfig": "Failed to save configuration",
|
||||
"revokeAuthorization": "Revoke Authorization",
|
||||
"revokeConfirmContent": "Are you sure you want to remove the API key for {{name}}? The current LLM model configuration will also be cleared.",
|
||||
"revokeConfirmSimple": "Are you sure you want to remove the API key for {{name}}?",
|
||||
"authorizationRevoked": "{{name}} authorization revoked, LLM model cleared",
|
||||
"authorizationRevokedSimple": "{{name}} authorization revoked",
|
||||
"failedToRevoke": "Failed to revoke authorization",
|
||||
"active": "Active: {{provider}} / {{model}}",
|
||||
"provider": "Provider",
|
||||
"model": "Model",
|
||||
"selectProvider": "Select provider (must be authorized)",
|
||||
"selectModel": "Select a model",
|
||||
"llmModelUpdated": "LLM Model updated",
|
||||
"failedToSave": "Failed to save",
|
||||
"saved": "Saved",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"loading": "Loading...",
|
||||
"retry": "Retry",
|
||||
"notSet": "Not set",
|
||||
"available": "Available",
|
||||
"unavailable": "Unavailable",
|
||||
"settings": "Settings",
|
||||
"actions": "Actions",
|
||||
"custom": "Custom",
|
||||
"builtin": "Built-in",
|
||||
"addProvider": "Add Provider",
|
||||
"addProviderTitle": "Add Custom Provider",
|
||||
"providerIdLabel": "Provider ID",
|
||||
"providerIdPlaceholder": "e.g. openai, google, anthropic",
|
||||
"providerIdHint": "Lowercase letters, digits, hyphens, underscores. Cannot be changed later.",
|
||||
"providerNameLabel": "Display Name",
|
||||
"providerNamePlaceholder": "e.g. OpenAI, Google Gemini",
|
||||
"defaultBaseUrlLabel": "Default Base URL",
|
||||
"defaultBaseUrlPlaceholder": "e.g. https://api.openai.com/v1",
|
||||
"apiKeyPrefixLabel": "API Key Prefix (optional)",
|
||||
"apiKeyPrefixPlaceholder": "e.g. sk-",
|
||||
"providerCreated": "Provider \"{{name}}\" created",
|
||||
"providerCreateFailed": "Failed to create provider",
|
||||
"deleteProvider": "Delete Provider",
|
||||
"deleteProviderConfirm": "Delete custom provider \"{{name}}\" and all its models? This cannot be undone.",
|
||||
"providerDeleted": "Provider \"{{name}}\" deleted",
|
||||
"providerDeleteFailed": "Failed to delete provider",
|
||||
"manageModels": "Models",
|
||||
"manageModelsTitle": "{{provider}} — Model Management",
|
||||
"userAdded": "User-added",
|
||||
"addModel": "Add Model",
|
||||
"addModelTitle": "Add Model to {{provider}}",
|
||||
"modelIdLabel": "Model ID",
|
||||
"modelIdPlaceholder": "e.g. gpt-4o, gemini-2.0-flash",
|
||||
"modelNameLabel": "Model Name",
|
||||
"modelNameRequired": "Please enter model name",
|
||||
"modelNamePlaceholder": "e.g. GPT-4o, Gemini 2.0 Flash",
|
||||
"modelAdded": "Model \"{{name}}\" added",
|
||||
"modelAddFailed": "Failed to add model",
|
||||
"removeModel": "Remove",
|
||||
"removeModelConfirm": "Remove model \"{{name}}\" from {{provider}}?",
|
||||
"modelRemoved": "Model \"{{name}}\" removed",
|
||||
"modelRemoveFailed": "Failed to remove model",
|
||||
"modelsCount": "{{count}} models",
|
||||
"noModels": "No models",
|
||||
"addModelFirst": "Please add a model first",
|
||||
"MaskanXAiUsageLoading": "Loading included MaskanX AI messages...",
|
||||
"MaskanXAiUsageUnavailable": "Included MaskanX AI message balance is temporarily unavailable.",
|
||||
"MaskanXAiMessagesRemaining": "{{remaining}} / {{limit}} included MaskanX AI messages left this period.",
|
||||
"local": "Local",
|
||||
"localType": "Type",
|
||||
"localEmbedded": "Embedded (in-process)",
|
||||
"localDownloadFirst": "Download a model first",
|
||||
"localDownloadModel": "Download Model",
|
||||
"localModelsTitle": "{{provider}} — Local Models",
|
||||
"localNoModels": "No downloaded models yet",
|
||||
"localRepoId": "Repository ID",
|
||||
"localRepoIdRequired": "Please enter a repository ID",
|
||||
"localRepoIdPlaceholder": "e.g. TheBloke/Mistral-7B-GGUF",
|
||||
"localFilename": "Filename (optional)",
|
||||
"localFilenamePlaceholder": "e.g. mistral-7b.Q4_K_M.gguf",
|
||||
"localFilenameHint": "Leave empty to auto-select the best quantization",
|
||||
"localSource": "Source",
|
||||
"localDownloadSuccess": "Model downloaded successfully",
|
||||
"localDownloadFailed": "Failed to download model",
|
||||
"localDeleteModel": "Delete Model",
|
||||
"localDeleteConfirm": "Delete local model \"{{name}}\"? The model file will be removed from disk.",
|
||||
"localModelDeleted": "Model \"{{name}}\" deleted",
|
||||
"localDeleteFailed": "Failed to delete model",
|
||||
"localDownloadPending": "Preparing to download...",
|
||||
"localDownloading": "Downloading {{repo}}... This may take a few minutes.",
|
||||
"localDownloadNavigateHint": "You can navigate away — the download will continue in the background.",
|
||||
"localDownloadInProgress": "A download is already in progress",
|
||||
"localCancelDownload": "Cancel Download",
|
||||
"localCancelDownloadConfirm": "Cancel download of \"{{repo}}\"?",
|
||||
"localDownloadCancelled": "Download cancelled",
|
||||
"localCancelDownloadFailed": "Failed to cancel download",
|
||||
"ollamaModelNamePlaceholder": "e.g. mistral:7b, qwen3:8b",
|
||||
"testConnection": "Test Connection",
|
||||
"testConnectionSuccess": "Connection test successful",
|
||||
"testConnectionFailed": "Connection test failed",
|
||||
"testConnectionError": "An error occurred while testing connection",
|
||||
"modelTestFailed": "Model validation failed, please check if the model ID is correct",
|
||||
"openrouterRouting": "Routing Mode",
|
||||
"openrouterRoutingAuto": "Auto — OpenRouter picks optimal model",
|
||||
"openrouterRoutingNitro": "Nitro — fastest provider for selected model",
|
||||
"openrouterRoutingFree": "Free — random free model (testing only)",
|
||||
"openrouterRoutingFloor": "Floor — cheapest provider for selected model",
|
||||
"openrouterRoutingManual": "Manual — choose model directly",
|
||||
"openrouterRoutingInfo": "OpenRouter routing controls how your request is fulfilled. Auto lets OpenRouter pick; Nitro selects the fastest provider; Free uses free-tier models; Floor picks the cheapest provider."
|
||||
},
|
||||
"agentConfig": {
|
||||
"title": "Configuration",
|
||||
"description": "Configure agent runtime parameters",
|
||||
"maxIters": "Max Iterations",
|
||||
"maxItersTooltip": "Maximum number of reasoning-acting iterations for ReAct agent",
|
||||
"maxItersPlaceholder": "Enter max iterations",
|
||||
"maxItersRequired": "Max iterations is required",
|
||||
"maxItersMin": "Max iterations must be at least 1",
|
||||
"maxInputLength": "Max Input Length",
|
||||
"maxInputLengthTooltip": "Maximum input length (tokens) for the model context window",
|
||||
"maxInputLengthPlaceholder": "Enter max input length",
|
||||
"maxInputLengthRequired": "Max input length is required",
|
||||
"maxInputLengthMin": "Max input length must be at least 1000",
|
||||
"saveSuccess": "Configuration saved successfully",
|
||||
"saveFailed": "Failed to save configuration",
|
||||
"loadFailed": "Failed to load configuration"
|
||||
},
|
||||
"brand": {
|
||||
"title": "Brand",
|
||||
"description": "Upload the company logo used for branded social images and content assets.",
|
||||
"logoTitle": "Company Logo",
|
||||
"logoDescription": "MaskanX stores one normalized logo for image generation workflows. Uploaded images are resized into an exact 174 x 184 PNG.",
|
||||
"logoAlt": "Company logo preview",
|
||||
"noLogo": "No logo",
|
||||
"previewSize": "{{width}} x {{height}} px preview",
|
||||
"uploadTitle": "Logo Upload",
|
||||
"uploadDescription": "Choose your company logo. A transparent PNG works best, but JPG and WEBP are supported too.",
|
||||
"uploadLogo": "Upload Logo",
|
||||
"replaceLogo": "Replace Logo",
|
||||
"uploadSuccess": "Logo uploaded successfully",
|
||||
"uploadFailed": "Failed to upload logo",
|
||||
"loadFailed": "Failed to load brand logo",
|
||||
"invalidFileType": "Please upload a PNG, JPG, JPEG, or WEBP image.",
|
||||
"fileTooLarge": "Logo file exceeds 5MB. Current file: {{size}}MB",
|
||||
"removeLogo": "Remove Logo",
|
||||
"removeConfirm": "Remove the current company logo?",
|
||||
"removeSuccess": "Logo removed",
|
||||
"removeFailed": "Failed to remove logo",
|
||||
"ruleSize": "Saved size is exactly 174 x 184 px.",
|
||||
"ruleTypes": "Accepted files: PNG, JPG, JPEG, WEBP. Maximum size: 5MB.",
|
||||
"ruleTransparent": "Transparent PNG is recommended for the cleanest overlay.",
|
||||
"ruleUsage": "This logo is used later when finalizing generated LinkedIn images.",
|
||||
"savedPath": "Saved file"
|
||||
},
|
||||
"personas": {
|
||||
"title": "Agent Personas",
|
||||
"description": "Create and manage agent personas with custom identities, skills, and schedules.",
|
||||
"introEyebrow": "Included free",
|
||||
"introTitle": "Your virtual office",
|
||||
"introDescription": "Personas turn MaskanX into a virtual office of specialized AI teammates. We include {{count}} built-in personas for free to get you started, and you can add as many custom personas as your workflow needs.",
|
||||
"introHint": "Start with From Template to activate a built-in role, or use Create Agent to make your own.",
|
||||
"createAgent": "Create Agent",
|
||||
"fromTemplate": "From Template",
|
||||
"emptyState": "No personas active yet. Activate a built-in template or create a custom persona to get started.",
|
||||
"coordinator": "Coordinator",
|
||||
"defaultModel": "Default",
|
||||
"skills": "skills",
|
||||
"cronOn": "Cron: ON",
|
||||
"cronOff": "Cron: OFF",
|
||||
"edit": "Edit"
|
||||
},
|
||||
"modelConfig": {
|
||||
"promptTitle": "LLM Model Required",
|
||||
"promptMessage": "Chat requires an LLM model to function. Without a configured model, conversations and messages cannot be sent to the backend for processing and storage, and will be lost after a page refresh. Would you like to configure a model now?",
|
||||
"configureButton": "Configure Model",
|
||||
"skipButton": "Skip",
|
||||
"chatDisabledTitle": "Chat Disabled",
|
||||
"chatDisabledMessage": "Chat functionality is disabled because no model is configured. Please configure a model to enable chat.",
|
||||
"configureNow": "Configure Model",
|
||||
"modelNotConfigured": "Please configure a model in Settings before using chat"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./i18n";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const originalError = console.error;
|
||||
const originalWarn = console.warn;
|
||||
|
||||
console.error = function (...args: unknown[]) {
|
||||
const msg = String(args[0] ?? "");
|
||||
if (msg.includes(":first-child") || msg.includes("pseudo class")) {
|
||||
return;
|
||||
}
|
||||
originalError.apply(console, args);
|
||||
};
|
||||
|
||||
console.warn = function (...args: unknown[]) {
|
||||
const msg = String(args[0] ?? "");
|
||||
if (
|
||||
msg.includes(":first-child") ||
|
||||
msg.includes("pseudo class") ||
|
||||
msg.includes("potentially unsafe")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
@@ -0,0 +1,69 @@
|
||||
.page {
|
||||
padding: 24px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--citedy-slate-900);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
color: var(--citedy-slate-600);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.formCard {
|
||||
max-width: 800px;
|
||||
|
||||
&:hover {
|
||||
border: 1px solid var(--citedy-slate-300) !important;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.form {
|
||||
:global {
|
||||
.ant-form-item-label > label {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.centerState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stateText {
|
||||
font-size: 14px;
|
||||
color: var(--citedy-slate-600);
|
||||
}
|
||||
|
||||
.stateTextError {
|
||||
font-size: 14px;
|
||||
color: var(--citedy-red-600);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Form,
|
||||
InputNumber,
|
||||
Button,
|
||||
Card,
|
||||
message,
|
||||
} from "@agentscope-ai/design";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import api from "../../../api";
|
||||
import styles from "./index.module.less";
|
||||
import type { AgentsRunningConfig } from "../../../api/types";
|
||||
|
||||
function AgentConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const config = await api.getAgentRunningConfig();
|
||||
form.setFieldsValue(config);
|
||||
} catch (err) {
|
||||
const errMsg =
|
||||
err instanceof Error ? err.message : t("agentConfig.loadFailed");
|
||||
setError(errMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
await api.updateAgentRunningConfig(values as AgentsRunningConfig);
|
||||
message.success(t("agentConfig.saveSuccess"));
|
||||
} catch (err) {
|
||||
if (err instanceof Error && "errorFields" in err) {
|
||||
return;
|
||||
}
|
||||
const errMsg =
|
||||
err instanceof Error ? err.message : t("agentConfig.saveFailed");
|
||||
message.error(errMsg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
fetchConfig();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
{loading && (
|
||||
<div className={styles.centerState}>
|
||||
<span className={styles.stateText}>{t("common.loading")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<div className={styles.centerState}>
|
||||
<span className={styles.stateTextError}>{error}</span>
|
||||
<Button size="small" onClick={fetchConfig} style={{ marginTop: 12 }}>
|
||||
{t("environments.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: loading || error ? "none" : "block" }}>
|
||||
<div className={styles.header}>
|
||||
<div>
|
||||
<h1 className={styles.title}>{t("agentConfig.title")}</h1>
|
||||
<p className={styles.description}>{t("agentConfig.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className={styles.formCard}>
|
||||
<Form form={form} layout="vertical" className={styles.form}>
|
||||
<Form.Item
|
||||
label={t("agentConfig.maxIters")}
|
||||
name="max_iters"
|
||||
rules={[
|
||||
{ required: true, message: t("agentConfig.maxItersRequired") },
|
||||
{
|
||||
type: "number",
|
||||
min: 1,
|
||||
message: t("agentConfig.maxItersMin"),
|
||||
},
|
||||
]}
|
||||
tooltip={t("agentConfig.maxItersTooltip")}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: "100%" }}
|
||||
min={1}
|
||||
placeholder={t("agentConfig.maxItersPlaceholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t("agentConfig.maxInputLength")}
|
||||
name="max_input_length"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t("agentConfig.maxInputLengthRequired"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
min: 1000,
|
||||
message: t("agentConfig.maxInputLengthMin"),
|
||||
},
|
||||
]}
|
||||
tooltip={t("agentConfig.maxInputLengthTooltip")}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: "100%" }}
|
||||
min={1000}
|
||||
step={1024}
|
||||
placeholder={t("agentConfig.maxInputLengthPlaceholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className={styles.buttonGroup}>
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
disabled={saving}
|
||||
style={{ marginRight: 8 }}
|
||||
>
|
||||
{t("common.reset")}
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSave} loading={saving}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentConfigPage;
|
||||
@@ -0,0 +1,264 @@
|
||||
import { Card, Button, Modal, Tooltip, Input } from "@agentscope-ai/design";
|
||||
import { DeleteOutlined } from "@ant-design/icons";
|
||||
import { Server, Plug } from "lucide-react";
|
||||
import type {
|
||||
MCPClientInfo,
|
||||
MCPClientUpdateRequest,
|
||||
} from "../../../../api/types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import styles from "../index.module.less";
|
||||
|
||||
interface MCPClientCardProps {
|
||||
client: MCPClientInfo;
|
||||
onToggle: (client: MCPClientInfo, e: React.MouseEvent) => void;
|
||||
onDelete: (client: MCPClientInfo, e?: React.MouseEvent) => void;
|
||||
onUpdate: (
|
||||
key: string,
|
||||
updates: MCPClientUpdateRequest,
|
||||
) => Promise<boolean>;
|
||||
isHovered: boolean;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
}
|
||||
|
||||
const ACRONYMS: Record<string, string> = {
|
||||
ai: "AI",
|
||||
api: "API",
|
||||
ga4: "GA4",
|
||||
gsc: "GSC",
|
||||
llm: "LLM",
|
||||
mcp: "MCP",
|
||||
seo: "SEO",
|
||||
xai: "xAI",
|
||||
};
|
||||
|
||||
function normalizeMcpDisplayName(rawName: string): string {
|
||||
const normalized = rawName.trim().replace(/[_\s-]?mcp$/i, "");
|
||||
const words = normalized.split(/[_\s-]+/).filter(Boolean);
|
||||
const title = words
|
||||
.map((word) => {
|
||||
const lower = word.toLowerCase();
|
||||
return ACRONYMS[lower] || lower.charAt(0).toUpperCase() + lower.slice(1);
|
||||
})
|
||||
.join(" ");
|
||||
return title ? `${title} MCP` : rawName;
|
||||
}
|
||||
|
||||
export function MCPClientCard({
|
||||
client,
|
||||
onToggle,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
isHovered,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}: MCPClientCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [jsonModalOpen, setJsonModalOpen] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [editedJson, setEditedJson] = useState("");
|
||||
const [editedDescription, setEditedDescription] = useState("");
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const displayName = normalizeMcpDisplayName(client.name || client.key);
|
||||
|
||||
// Determine if MCP client is remote or local based on command
|
||||
const isRemote =
|
||||
client.transport === "streamable_http" || client.transport === "sse";
|
||||
const clientType = isRemote ? "Remote" : "Local";
|
||||
|
||||
const handleToggleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onToggle(client, e);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
setDeleteModalOpen(false);
|
||||
onDelete(client);
|
||||
};
|
||||
|
||||
const handleCardClick = () => {
|
||||
const { description, ...rest } = client;
|
||||
const jsonStr = JSON.stringify(rest, null, 2);
|
||||
setEditedJson(jsonStr);
|
||||
setEditedDescription(description || "");
|
||||
setIsEditing(false);
|
||||
setJsonModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveJson = async () => {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(editedJson);
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
throw new Error("Configuration must be a JSON object.");
|
||||
}
|
||||
const updates = Object.fromEntries(
|
||||
Object.entries(parsed).filter(([field]) => field !== "key"),
|
||||
) as MCPClientUpdateRequest;
|
||||
updates.description = editedDescription;
|
||||
|
||||
const success = await onUpdate(client.key, updates);
|
||||
if (success) {
|
||||
setJsonModalOpen(false);
|
||||
setIsEditing(false);
|
||||
}
|
||||
} catch {
|
||||
alert("Invalid JSON format");
|
||||
}
|
||||
};
|
||||
|
||||
const clientWithoutDesc = Object.fromEntries(
|
||||
Object.entries(client).filter(([field]) => field !== "description"),
|
||||
);
|
||||
const clientJson = JSON.stringify(clientWithoutDesc, null, 2);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
hoverable
|
||||
onClick={handleCardClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className={`${styles.mcpCard} ${
|
||||
client.enabled ? styles.enabledCard : ""
|
||||
} ${isHovered ? styles.hover : styles.normal}`}
|
||||
>
|
||||
<div className={styles.cardHeader}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span className={styles.fileIcon}>
|
||||
<Server style={{ color: "#3b82f6", fontSize: 20 }} />
|
||||
</span>
|
||||
<Tooltip title={`Config name: ${client.name}`}>
|
||||
<h3 className={styles.mcpTitle}>{displayName}</h3>
|
||||
</Tooltip>
|
||||
<span
|
||||
className={`${styles.typeBadge} ${
|
||||
isRemote ? styles.remote : styles.local
|
||||
}`}
|
||||
>
|
||||
{clientType}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statusContainer}>
|
||||
<span
|
||||
className={`${styles.statusDot} ${
|
||||
client.enabled ? styles.enabled : styles.disabled
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`${styles.statusText} ${
|
||||
client.enabled ? styles.enabled : styles.disabled
|
||||
}`}
|
||||
>
|
||||
{client.enabled ? t("common.enabled") : t("common.disabled")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.description}>
|
||||
{client.description || "\u00A0"}
|
||||
</div>
|
||||
|
||||
<div className={styles.cardFooter}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={handleToggleClick}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
{client.enabled ? t("common.disable") : t("common.enable")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
className={styles.deleteButton}
|
||||
onClick={handleDeleteClick}
|
||||
disabled={client.enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Decorative icon */}
|
||||
<Plug
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: -16,
|
||||
right: -16,
|
||||
width: 128,
|
||||
height: 128,
|
||||
opacity: 0.03,
|
||||
pointerEvents: "none",
|
||||
color: "#06b6d4",
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={t("common.confirm")}
|
||||
open={deleteModalOpen}
|
||||
onOk={confirmDelete}
|
||||
onCancel={() => setDeleteModalOpen(false)}
|
||||
okText={t("common.confirm")}
|
||||
cancelText={t("common.cancel")}
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<p>{t("mcp.deleteConfirm")}</p>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`${displayName} - Configuration`}
|
||||
open={jsonModalOpen}
|
||||
onCancel={() => setJsonModalOpen(false)}
|
||||
footer={
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<Button
|
||||
onClick={() => setJsonModalOpen(false)}
|
||||
style={{ marginRight: 8 }}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
{isEditing ? (
|
||||
<Button type="primary" onClick={handleSaveJson}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="primary" onClick={() => setIsEditing(true)}>
|
||||
{t("common.edit")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
width={700}
|
||||
>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: "block", marginBottom: 4, fontWeight: 500 }}>
|
||||
Description
|
||||
</label>
|
||||
<Input.TextArea
|
||||
value={editedDescription}
|
||||
onChange={(e) => setEditedDescription(e.target.value)}
|
||||
disabled={!isEditing}
|
||||
rows={2}
|
||||
placeholder="Short description of this MCP client..."
|
||||
/>
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editedJson}
|
||||
onChange={(e) => setEditedJson(e.target.value)}
|
||||
className={styles.editJsonTextArea}
|
||||
/>
|
||||
) : (
|
||||
<pre className={styles.preformattedText}>{clientJson}</pre>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Drawer, Form, Input, Switch, Button } from "@agentscope-ai/design";
|
||||
import type { MCPClientInfo } from "../../../../api/types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import type { FormInstance } from "antd";
|
||||
|
||||
interface MCPClientFormValues {
|
||||
key?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
command?: string;
|
||||
enabled?: boolean;
|
||||
args?: string;
|
||||
env?: string;
|
||||
}
|
||||
|
||||
interface MCPClientDrawerProps {
|
||||
open: boolean;
|
||||
client: MCPClientInfo | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (
|
||||
key: string,
|
||||
values: {
|
||||
name: string;
|
||||
command?: string;
|
||||
enabled?: boolean;
|
||||
transport?: "stdio" | "streamable_http" | "sse";
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
},
|
||||
) => Promise<boolean>;
|
||||
form: FormInstance<MCPClientFormValues>;
|
||||
}
|
||||
|
||||
export function MCPClientDrawer({
|
||||
open,
|
||||
client,
|
||||
onClose,
|
||||
onSubmit,
|
||||
form,
|
||||
}: MCPClientDrawerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const isEditing = !!client;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSubmitting(true);
|
||||
|
||||
const clientData = {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
command: values.command,
|
||||
enabled: values.enabled ?? true,
|
||||
args: values.args ? values.args.split(" ").filter(Boolean) : [],
|
||||
env: values.env ? JSON.parse(values.env) : {},
|
||||
};
|
||||
|
||||
const key = isEditing ? client.key : values.key;
|
||||
if (!key) {
|
||||
throw new Error("MCP client key is required.");
|
||||
}
|
||||
const success = await onSubmit(key, clientData);
|
||||
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Form validation failed:", error);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={isEditing ? t("mcp.editClient") : t("mcp.createClient")}
|
||||
placement="right"
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
width={600}
|
||||
footer={
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<Button onClick={onClose} style={{ marginRight: 8 }}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSubmit} loading={submitting}>
|
||||
{isEditing ? t("common.save") : t("common.create")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{!isEditing && (
|
||||
<Form.Item
|
||||
name="key"
|
||||
label={t("mcp.key")}
|
||||
rules={[{ required: true, message: t("mcp.keyRequired") }]}
|
||||
>
|
||||
<Input placeholder={t("mcp.keyPlaceholder")} />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t("mcp.name")}
|
||||
rules={[{ required: true, message: t("mcp.nameRequired") }]}
|
||||
>
|
||||
<Input placeholder={t("mcp.namePlaceholder")} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="description" label={t("mcp.description")}>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
placeholder={t("mcp.descriptionPlaceholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="command"
|
||||
label={t("mcp.command")}
|
||||
rules={[{ required: true, message: t("mcp.commandRequired") }]}
|
||||
>
|
||||
<Input placeholder={t("mcp.commandPlaceholder")} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="args" label={t("mcp.args")} extra={t("mcp.argsHelp")}>
|
||||
<Input placeholder={t("mcp.argsPlaceholder")} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="env" label={t("mcp.env")} extra={t("mcp.envHelp")}>
|
||||
<Input.TextArea rows={4} placeholder={t("mcp.envPlaceholder")} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="enabled"
|
||||
label={t("mcp.enabled")}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { MCPClientCard } from "./MCPClientCard";
|
||||
export { MCPClientDrawer } from "./MCPClientDrawer";
|
||||
@@ -0,0 +1,164 @@
|
||||
.mcpCard {
|
||||
border-radius: 16px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&.enabledCard {
|
||||
border: 2px solid var(--citedy-slate-900) !important;
|
||||
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.12) !important;
|
||||
}
|
||||
|
||||
&.hover {
|
||||
transform: translateY(-2px);
|
||||
border: 1px solid var(--citedy-slate-300) !important;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
&.normal {
|
||||
border: 1px solid rgba(226, 232, 240, 0.4) !important;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mcpTitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--citedy-slate-600);
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.typeBadge {
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-weight: 500;
|
||||
backdrop-filter: blur(8px);
|
||||
white-space: nowrap;
|
||||
|
||||
&.local {
|
||||
background-color: rgba(59, 130, 246, 0.08);
|
||||
color: var(--citedy-blue-500);
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
&.remote {
|
||||
background-color: rgba(245, 158, 11, 0.08);
|
||||
color: var(--citedy-amber-500);
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.fileIcon {
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
color: var(--citedy-slate-600);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 32px;
|
||||
min-height: 64px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.cardFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
margin-top: auto;
|
||||
border-top: 1px solid rgba(226, 232, 240, 0.4);
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.deleteButton {
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--citedy-red-600) !important;
|
||||
color: #fff !important;
|
||||
border-color: var(--citedy-red-600) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.statusContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
|
||||
&.enabled {
|
||||
background-color: var(--citedy-green-500);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background-color: var(--citedy-slate-300);
|
||||
}
|
||||
}
|
||||
|
||||
.statusText {
|
||||
font-size: 12px;
|
||||
|
||||
&.enabled {
|
||||
color: var(--citedy-green-500);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: var(--citedy-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
.editJsonTextArea {
|
||||
width: 100%;
|
||||
min-height: 500px;
|
||||
font-family:
|
||||
Monaco,
|
||||
Courier New,
|
||||
monospace;
|
||||
font-size: 13px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--citedy-slate-200);
|
||||
border-radius: 8px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.preformattedText {
|
||||
background-color: var(--citedy-slate-50);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
max-height: 500px;
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
import { useCallback, useState, useEffect } from "react";
|
||||
import { Button, Empty, Modal, Card, message } from "@agentscope-ai/design";
|
||||
import { ExternalLink, RefreshCw, ShieldCheck } from "lucide-react";
|
||||
import api, { request } from "../../../api";
|
||||
import type {
|
||||
LinkedInOAuthStatus,
|
||||
MCPClientCreateRequest,
|
||||
MCPClientInfo,
|
||||
} from "../../../api/types";
|
||||
import { MCPClientCard } from "./components";
|
||||
import { useMCP } from "./useMCP";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type MCPTransport = "stdio" | "streamable_http" | "sse";
|
||||
const CITEDY_MCP_TOOLS_URL = "https://www.citedy.com/tools/mcp";
|
||||
type MCPClientPayload = MCPClientCreateRequest["client"];
|
||||
type NormalizedMCPClientPayload = {
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
transport: MCPTransport;
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
command: string;
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
cwd: string;
|
||||
};
|
||||
|
||||
interface CitedyStatus {
|
||||
configured: boolean;
|
||||
api_key_prefix?: string;
|
||||
status?: string;
|
||||
balance?: { credits: number; status: string } | null;
|
||||
error?: string;
|
||||
developer_url: string;
|
||||
billing_url: string;
|
||||
}
|
||||
|
||||
type MCPClientDraft = Partial<MCPClientPayload> & {
|
||||
type?: unknown;
|
||||
baseUrl?: unknown;
|
||||
isActive?: unknown;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message ? error.message : fallback;
|
||||
}
|
||||
|
||||
function normalizeTransport(raw?: unknown): MCPTransport | undefined {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
const value = raw.trim().toLowerCase();
|
||||
switch (value) {
|
||||
case "stdio":
|
||||
return "stdio";
|
||||
case "sse":
|
||||
return "sse";
|
||||
case "streamablehttp":
|
||||
case "streamable_http":
|
||||
case "http":
|
||||
return "streamable_http";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeClientData(
|
||||
key: string,
|
||||
rawData: MCPClientDraft,
|
||||
): NormalizedMCPClientPayload {
|
||||
const transport =
|
||||
normalizeTransport(rawData.transport ?? rawData.type) ??
|
||||
(rawData.url || rawData.baseUrl || !rawData.command
|
||||
? "streamable_http"
|
||||
: "stdio");
|
||||
|
||||
const command =
|
||||
transport === "stdio" ? (rawData.command ?? "").toString() : "";
|
||||
const enabled =
|
||||
typeof rawData.enabled === "boolean"
|
||||
? rawData.enabled
|
||||
: typeof rawData.isActive === "boolean"
|
||||
? rawData.isActive
|
||||
: true;
|
||||
|
||||
return {
|
||||
name: rawData.name || key,
|
||||
description: rawData.description || "",
|
||||
enabled,
|
||||
transport,
|
||||
url: (rawData.url || rawData.baseUrl || "").toString(),
|
||||
headers: rawData.headers || {},
|
||||
command,
|
||||
args: Array.isArray(rawData.args) ? rawData.args : [],
|
||||
env: rawData.env || {},
|
||||
cwd: (rawData.cwd || "").toString(),
|
||||
};
|
||||
}
|
||||
|
||||
function citedyStatusLine(status: CitedyStatus): string {
|
||||
if (!status.configured) {
|
||||
return "API key not configured — connect Citedy to unlock 70+ marketing tools";
|
||||
}
|
||||
const keyLabel = `API key: ${status.api_key_prefix || "configured"}`;
|
||||
if (status.balance) {
|
||||
return `${keyLabel} | Balance: ${status.balance.credits} credits`;
|
||||
}
|
||||
if (status.status === "invalid") {
|
||||
return `${keyLabel} | Reconnect required — balance unavailable`;
|
||||
}
|
||||
return `${keyLabel} | Balance unavailable`;
|
||||
}
|
||||
|
||||
function linkedinStatusLine(status: LinkedInOAuthStatus): string {
|
||||
if (!status.linkedin_mcp_configured) {
|
||||
return "LinkedIn MCP is not added yet.";
|
||||
}
|
||||
if (!status.configured) {
|
||||
return "LinkedIn Client ID or Client Secret is missing.";
|
||||
}
|
||||
if (!status.oauth_token_ready) {
|
||||
return "Credentials are configured. OAuth login is still needed.";
|
||||
}
|
||||
if (!status.w_member_social_ready) {
|
||||
return "OAuth is connected, but posting permission is missing.";
|
||||
}
|
||||
if (!status.linkedin_image_post_enabled) {
|
||||
return "OAuth is ready. Enable the LinkedIn Image Post MCP for image posts.";
|
||||
}
|
||||
return "LinkedIn OAuth is ready for image and text posting.";
|
||||
}
|
||||
|
||||
function linkedinStatusTone(status: LinkedInOAuthStatus): "ready" | "warning" {
|
||||
return status.configured &&
|
||||
status.oauth_token_ready &&
|
||||
status.w_member_social_ready &&
|
||||
status.linkedin_image_post_enabled
|
||||
? "ready"
|
||||
: "warning";
|
||||
}
|
||||
|
||||
function readinessBadge(label: string, ready: boolean) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "4px 8px",
|
||||
borderRadius: 999,
|
||||
fontSize: 12,
|
||||
color: ready ? "#166534" : "#92400e",
|
||||
background: ready ? "#dcfce7" : "#fef3c7",
|
||||
border: `1px solid ${ready ? "#bbf7d0" : "#fde68a"}`,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
background: ready ? "#16a34a" : "#f59e0b",
|
||||
}}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MCPPage() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
clients,
|
||||
loading,
|
||||
loadClients,
|
||||
toggleEnabled,
|
||||
deleteClient,
|
||||
createClient,
|
||||
updateClient,
|
||||
} = useMCP();
|
||||
const [hoverKey, setHoverKey] = useState<string | null>(null);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [citedyStatus, setCitedyStatus] = useState<CitedyStatus | null>(null);
|
||||
const [linkedinStatus, setLinkedinStatus] =
|
||||
useState<LinkedInOAuthStatus | null>(null);
|
||||
const [linkedinLoading, setLinkedinLoading] = useState(false);
|
||||
const [linkedinStarting, setLinkedinStarting] = useState(false);
|
||||
|
||||
const refreshLinkedInStatus = useCallback(async () => {
|
||||
setLinkedinLoading(true);
|
||||
try {
|
||||
const status = await api.getLinkedInOAuthStatus();
|
||||
setLinkedinStatus(status);
|
||||
} catch (error) {
|
||||
console.error("Failed to load LinkedIn OAuth status:", error);
|
||||
} finally {
|
||||
setLinkedinLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
request<CitedyStatus>("/citedy/status")
|
||||
.then(setCitedyStatus)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshLinkedInStatus();
|
||||
}, [refreshLinkedInStatus]);
|
||||
const [newClientJson, setNewClientJson] = useState(`{
|
||||
"mcpServers": {
|
||||
"example-client": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@example/mcp-server"],
|
||||
"env": {
|
||||
"API_KEY": "<YOUR_API_KEY>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`);
|
||||
|
||||
const handleToggleEnabled = async (
|
||||
client: MCPClientInfo,
|
||||
e?: React.MouseEvent,
|
||||
) => {
|
||||
e?.stopPropagation();
|
||||
await toggleEnabled(client);
|
||||
};
|
||||
|
||||
const handleDelete = async (client: MCPClientInfo, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
await deleteClient(client);
|
||||
};
|
||||
|
||||
const handleStartLinkedInOAuth = async () => {
|
||||
setLinkedinStarting(true);
|
||||
try {
|
||||
const result = await api.startLinkedInOAuth({
|
||||
user_id: "default",
|
||||
force_reauth: true,
|
||||
});
|
||||
if (result.auth_url) {
|
||||
window.open(result.auth_url, "_blank", "noopener,noreferrer");
|
||||
message.success(
|
||||
"LinkedIn login opened. Approve it, then refresh status.",
|
||||
);
|
||||
} else if (result.missing_step) {
|
||||
message.error(result.missing_step);
|
||||
} else {
|
||||
message.error("LinkedIn OAuth could not be started.");
|
||||
}
|
||||
await Promise.all([refreshLinkedInStatus(), loadClients()]);
|
||||
} catch (error: unknown) {
|
||||
message.error(errorMessage(error, "LinkedIn OAuth failed to start."));
|
||||
} finally {
|
||||
setLinkedinStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateClient = async () => {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(newClientJson);
|
||||
|
||||
// Support two formats:
|
||||
// Format 1: { "mcpServers": { "key": { "command": "...", ... } } }
|
||||
// Format 2: { "key": { "command": "...", ... } }
|
||||
// Format 3: { "key": "...", "name": "...", "command": "...", ... } (direct)
|
||||
|
||||
const clientsToCreate: Array<{
|
||||
key: string;
|
||||
data: NormalizedMCPClientPayload;
|
||||
}> = [];
|
||||
|
||||
if (isRecord(parsed) && isRecord(parsed.mcpServers)) {
|
||||
// Format 1: nested mcpServers
|
||||
Object.entries(parsed.mcpServers).forEach(([key, data]) => {
|
||||
if (!isRecord(data)) return;
|
||||
|
||||
const normalizedData = normalizeClientData(
|
||||
key,
|
||||
data as MCPClientDraft,
|
||||
);
|
||||
clientsToCreate.push({
|
||||
key,
|
||||
data: normalizedData,
|
||||
});
|
||||
});
|
||||
} else if (
|
||||
isRecord(parsed) &&
|
||||
typeof parsed.key === "string" &&
|
||||
(parsed.command || parsed.url || parsed.baseUrl)
|
||||
) {
|
||||
// Format 3: direct format with key field
|
||||
const { key, ...clientData } = parsed;
|
||||
if (typeof key === "string") {
|
||||
clientsToCreate.push({
|
||||
key,
|
||||
data: normalizeClientData(key, clientData as MCPClientDraft),
|
||||
});
|
||||
}
|
||||
} else if (isRecord(parsed)) {
|
||||
// Format 2: direct client objects with keys
|
||||
Object.entries(parsed).forEach(([key, data]) => {
|
||||
if (
|
||||
!isRecord(data) ||
|
||||
(!data.command && !data.url && !data.baseUrl)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
clientsToCreate.push({
|
||||
key,
|
||||
data: normalizeClientData(key, data as MCPClientDraft),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Create all clients
|
||||
let allSuccess = true;
|
||||
for (const { key, data } of clientsToCreate) {
|
||||
const success = await createClient(key, data);
|
||||
if (!success) allSuccess = false;
|
||||
}
|
||||
|
||||
if (allSuccess) {
|
||||
setCreateModalOpen(false);
|
||||
setNewClientJson(`{
|
||||
"mcpServers": {
|
||||
"example-client": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@example/mcp-server"],
|
||||
"env": {
|
||||
"API_KEY": "<YOUR_API_KEY>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`);
|
||||
}
|
||||
} catch {
|
||||
alert("Invalid JSON format");
|
||||
}
|
||||
};
|
||||
|
||||
const linkedinTone = linkedinStatus
|
||||
? linkedinStatusTone(linkedinStatus)
|
||||
: "warning";
|
||||
const linkedinAuthButtonLabel = linkedinStatus?.oauth_token_ready
|
||||
? "Re-authenticate LinkedIn"
|
||||
: "Authenticate LinkedIn";
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div className="citedy-page-header">
|
||||
<div>
|
||||
<h1 className="citedy-page-title">{t("mcp.title")}</h1>
|
||||
<p className="citedy-page-description">{t("mcp.description")}</p>
|
||||
</div>
|
||||
<Button type="primary" onClick={() => setCreateModalOpen(true)}>
|
||||
{t("mcp.create")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{citedyStatus && (
|
||||
<Card
|
||||
className={`citedy-promo-card ${
|
||||
citedyStatus.configured ? "configured" : "unconfigured"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>
|
||||
<a
|
||||
href={CITEDY_MCP_TOOLS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Citedy SEO & Marketing Tools
|
||||
</a>
|
||||
</h3>
|
||||
<p style={{ margin: "4px 0 0", color: "#475569", fontSize: 13 }}>
|
||||
{citedyStatusLine(citedyStatus)}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{!citedyStatus.configured && (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() =>
|
||||
window.open(citedyStatus.developer_url, "_blank")
|
||||
}
|
||||
>
|
||||
Get API Key
|
||||
</Button>
|
||||
)}
|
||||
{citedyStatus.configured && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.open(
|
||||
citedyStatus.status === "invalid"
|
||||
? citedyStatus.developer_url
|
||||
: citedyStatus.billing_url,
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
{citedyStatus.status === "invalid"
|
||||
? "Reconnect Key"
|
||||
: "Top Up Balance"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{linkedinStatus && (
|
||||
<Card
|
||||
className={`citedy-promo-card ${
|
||||
linkedinTone === "ready" ? "configured" : "unconfigured"
|
||||
}`}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
gap: 16,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 260, flex: 1 }}>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<ShieldCheck size={18} />
|
||||
LinkedIn OAuth
|
||||
</h3>
|
||||
<p style={{ margin: "4px 0 0", color: "#475569", fontSize: 13 }}>
|
||||
{linkedinStatusLine(linkedinStatus)}
|
||||
</p>
|
||||
{linkedinStatus.missing_step && (
|
||||
<p
|
||||
style={{
|
||||
margin: "8px 0 0",
|
||||
color: "#92400e",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Missing step: {linkedinStatus.missing_step}
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
{readinessBadge("Credentials", linkedinStatus.configured)}
|
||||
{readinessBadge("OAuth token", linkedinStatus.oauth_token_ready)}
|
||||
{readinessBadge(
|
||||
"Posting scope",
|
||||
linkedinStatus.w_member_social_ready,
|
||||
)}
|
||||
{readinessBadge(
|
||||
"Image-post MCP",
|
||||
linkedinStatus.linkedin_image_post_enabled,
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
margin: "10px 0 0",
|
||||
color: "#64748b",
|
||||
fontSize: 12,
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
Add this callback URL in LinkedIn Developer Portal:{" "}
|
||||
<code>{linkedinStatus.redirect_uri}</code>
|
||||
</p>
|
||||
{linkedinStatus.scope.length > 0 && (
|
||||
<p
|
||||
style={{
|
||||
margin: "6px 0 0",
|
||||
color: "#64748b",
|
||||
fontSize: 12,
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
Scopes: {linkedinStatus.scope.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<Button
|
||||
icon={<RefreshCw size={14} />}
|
||||
loading={linkedinLoading}
|
||||
onClick={async () => {
|
||||
await Promise.all([refreshLinkedInStatus(), loadClients()]);
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ExternalLink size={14} />}
|
||||
loading={linkedinStarting}
|
||||
disabled={!linkedinStatus.configured}
|
||||
onClick={handleStartLinkedInOAuth}
|
||||
>
|
||||
{linkedinAuthButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: "center", padding: 60 }}>
|
||||
<p style={{ color: "#64748b" }}>{t("common.loading")}</p>
|
||||
</div>
|
||||
) : clients.length === 0 ? (
|
||||
<Empty description={t("mcp.emptyState")} />
|
||||
) : (
|
||||
<div className="citedy-content-grid">
|
||||
{clients.map((client) => (
|
||||
<MCPClientCard
|
||||
key={client.key}
|
||||
client={client}
|
||||
onToggle={handleToggleEnabled}
|
||||
onDelete={handleDelete}
|
||||
onUpdate={updateClient}
|
||||
isHovered={hoverKey === client.key}
|
||||
onMouseEnter={() => setHoverKey(client.key)}
|
||||
onMouseLeave={() => setHoverKey(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={t("mcp.create")}
|
||||
open={createModalOpen}
|
||||
onCancel={() => setCreateModalOpen(false)}
|
||||
footer={
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<Button
|
||||
onClick={() => setCreateModalOpen(false)}
|
||||
style={{ marginRight: 8 }}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleCreateClient}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
width={800}
|
||||
>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<p style={{ margin: 0, fontSize: 13, color: "#475569" }}>
|
||||
{t("mcp.formatSupport")}:
|
||||
</p>
|
||||
<ul
|
||||
style={{
|
||||
margin: "8px 0",
|
||||
padding: "0 0 0 20px",
|
||||
fontSize: 12,
|
||||
color: "#64748b",
|
||||
}}
|
||||
>
|
||||
<li>
|
||||
Standard format:{" "}
|
||||
<code>{`{ "mcpServers": { "key": {...} } }`}</code>
|
||||
</li>
|
||||
<li>
|
||||
Direct format: <code>{`{ "key": {...} }`}</code>
|
||||
</li>
|
||||
<li>
|
||||
Single format:{" "}
|
||||
<code>{`{ "key": "...", "name": "...", "command": "..." }`}</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<textarea
|
||||
value={newClientJson}
|
||||
onChange={(e) => setNewClientJson(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: 400,
|
||||
fontFamily: "Monaco, Courier New, monospace",
|
||||
fontSize: 13,
|
||||
padding: 16,
|
||||
border: "1px solid #e2e8f0",
|
||||
borderRadius: 4,
|
||||
resize: "vertical",
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MCPPage;
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { message } from "@agentscope-ai/design";
|
||||
import api from "../../../api";
|
||||
import type { MCPClientInfo } from "../../../api/types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function errorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message ? error.message : fallback;
|
||||
}
|
||||
|
||||
export function useMCP() {
|
||||
const { t } = useTranslation();
|
||||
const [clients, setClients] = useState<MCPClientInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadClients = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.listMCPClients();
|
||||
setClients(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load MCP clients:", error);
|
||||
message.error(t("mcp.loadError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadClients();
|
||||
}, [loadClients]);
|
||||
|
||||
const createClient = useCallback(
|
||||
async (
|
||||
key: string,
|
||||
clientData: {
|
||||
name: string;
|
||||
description?: string;
|
||||
command: string;
|
||||
enabled?: boolean;
|
||||
transport?: "stdio" | "streamable_http" | "sse";
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
},
|
||||
) => {
|
||||
try {
|
||||
await api.createMCPClient({
|
||||
client_key: key,
|
||||
client: clientData,
|
||||
});
|
||||
message.success(t("mcp.createSuccess"));
|
||||
await loadClients();
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const errorMsg = errorMessage(error, t("mcp.createError"));
|
||||
message.error(errorMsg);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[t, loadClients],
|
||||
);
|
||||
|
||||
const updateClient = useCallback(
|
||||
async (
|
||||
key: string,
|
||||
updates: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
command?: string;
|
||||
enabled?: boolean;
|
||||
transport?: "stdio" | "streamable_http" | "sse";
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
},
|
||||
) => {
|
||||
try {
|
||||
await api.updateMCPClient(key, updates);
|
||||
message.success(t("mcp.updateSuccess"));
|
||||
await loadClients();
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const errorMsg = errorMessage(error, t("mcp.updateError"));
|
||||
message.error(errorMsg);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[t, loadClients],
|
||||
);
|
||||
|
||||
const toggleEnabled = useCallback(
|
||||
async (client: MCPClientInfo) => {
|
||||
try {
|
||||
await api.toggleMCPClient(client.key);
|
||||
message.success(
|
||||
client.enabled ? t("mcp.disableSuccess") : t("mcp.enableSuccess"),
|
||||
);
|
||||
await loadClients();
|
||||
} catch {
|
||||
message.error(t("mcp.toggleError"));
|
||||
}
|
||||
},
|
||||
[t, loadClients],
|
||||
);
|
||||
|
||||
const deleteClient = useCallback(
|
||||
async (client: MCPClientInfo) => {
|
||||
try {
|
||||
await api.deleteMCPClient(client.key);
|
||||
message.success(t("mcp.deleteSuccess"));
|
||||
await loadClients();
|
||||
} catch {
|
||||
message.error(t("mcp.deleteError"));
|
||||
}
|
||||
},
|
||||
[t, loadClients],
|
||||
);
|
||||
|
||||
return {
|
||||
clients,
|
||||
loading,
|
||||
loadClients,
|
||||
createClient,
|
||||
updateClient,
|
||||
toggleEnabled,
|
||||
deleteClient,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Tooltip } from "@agentscope-ai/design";
|
||||
import type { SkillSecurity } from "../../../../api/types";
|
||||
|
||||
const statusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "pass":
|
||||
return "\u2705";
|
||||
case "fail":
|
||||
return "\u274C";
|
||||
default:
|
||||
return "\u23F3";
|
||||
}
|
||||
};
|
||||
|
||||
const scoreColor = (score: number) => {
|
||||
if (score >= 80) return "#22c55e";
|
||||
if (score >= 50) return "#f59e0b";
|
||||
return "#dc2626";
|
||||
};
|
||||
|
||||
interface Props {
|
||||
security?: SkillSecurity;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export function SecurityBadges({ security, source }: Props) {
|
||||
if (!security) {
|
||||
const isBuiltIn = source === "builtin";
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
color: isBuiltIn ? "#64748b" : "#94a3b8",
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<span>{isBuiltIn ? "Built-in verified" : "Scan available"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<Tooltip title={`Pattern scan: ${security.pattern_scan}`}>
|
||||
<span style={{ cursor: "default" }}>
|
||||
{"\uD83D\uDEE1\uFE0F"}
|
||||
{statusIcon(security.pattern_scan)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={`LLM audit: ${security.llm_audit}`}>
|
||||
<span style={{ cursor: "default" }}>
|
||||
{"\uD83E\uDD16"}
|
||||
{statusIcon(security.llm_audit)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{security.auto_healed && (
|
||||
<Tooltip title="Auto-healed by LLM">
|
||||
<span style={{ cursor: "default" }}>{"\uD83D\uDD27"}</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: scoreColor(security.score),
|
||||
marginLeft: "auto",
|
||||
}}
|
||||
>
|
||||
{security.score}/100
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { Card, Button } from "@agentscope-ai/design";
|
||||
import {
|
||||
DeleteOutlined,
|
||||
FileTextFilled,
|
||||
FileZipFilled,
|
||||
FilePdfFilled,
|
||||
FileWordFilled,
|
||||
FileExcelFilled,
|
||||
FilePptFilled,
|
||||
FileImageFilled,
|
||||
CodeFilled,
|
||||
} from "@ant-design/icons";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import type { SkillSpec } from "../../../../api/types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SecurityBadges } from "./SecurityBadges";
|
||||
import styles from "../index.module.less";
|
||||
|
||||
interface SkillCardProps {
|
||||
skill: SkillSpec;
|
||||
isHover: boolean;
|
||||
onClick: () => void;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
onToggleEnabled: (e: React.MouseEvent) => void;
|
||||
onDelete?: (e?: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const MAX_DESC_LEN = 90;
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
builtin: "Built-in",
|
||||
customized: "Customized",
|
||||
active: "Active",
|
||||
};
|
||||
|
||||
function extractDescription(content: string): string {
|
||||
// Parse description from YAML frontmatter: "description: ..." or multi-line "description: >\n ..."
|
||||
const match = content.match(
|
||||
/^description:\s*[>|]?\s*\n?([\s\S]*?)(?:\n[a-z_]+:|\n---)/m,
|
||||
);
|
||||
if (match) {
|
||||
const desc = match[1].replace(/\s+/g, " ").trim();
|
||||
if (desc) {
|
||||
return desc.length > MAX_DESC_LEN
|
||||
? desc.slice(0, MAX_DESC_LEN) + "…"
|
||||
: desc;
|
||||
}
|
||||
}
|
||||
// Fallback: try single-line description
|
||||
const single = content.match(/^description:\s*["']?(.+?)["']?\s*$/m);
|
||||
if (single) {
|
||||
const desc = single[1].trim();
|
||||
return desc.length > MAX_DESC_LEN
|
||||
? desc.slice(0, MAX_DESC_LEN) + "…"
|
||||
: desc;
|
||||
}
|
||||
// Last fallback: first heading or line after frontmatter
|
||||
const afterFm = content.split("---").slice(2).join("---").trim();
|
||||
const firstLine = afterFm
|
||||
.split("\n")
|
||||
.find((l) => l.trim() && !l.startsWith("#"));
|
||||
if (firstLine) {
|
||||
const desc = firstLine.trim();
|
||||
return desc.length > MAX_DESC_LEN
|
||||
? desc.slice(0, MAX_DESC_LEN) + "…"
|
||||
: desc;
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
const getFileIcon = (filePath: string) => {
|
||||
const extension = filePath.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
switch (extension) {
|
||||
case "txt":
|
||||
case "md":
|
||||
case "markdown":
|
||||
return <FileTextFilled style={{ color: "#3b82f6" }} />;
|
||||
case "zip":
|
||||
case "rar":
|
||||
case "7z":
|
||||
case "tar":
|
||||
case "gz":
|
||||
return <FileZipFilled style={{ color: "#fa8c16" }} />;
|
||||
case "pdf":
|
||||
return <FilePdfFilled style={{ color: "#dc2626" }} />;
|
||||
case "doc":
|
||||
case "docx":
|
||||
return <FileWordFilled style={{ color: "#2b579a" }} />;
|
||||
case "xls":
|
||||
case "xlsx":
|
||||
return <FileExcelFilled style={{ color: "#217346" }} />;
|
||||
case "ppt":
|
||||
case "pptx":
|
||||
return <FilePptFilled style={{ color: "#d24726" }} />;
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
case "png":
|
||||
case "gif":
|
||||
case "svg":
|
||||
case "webp":
|
||||
return <FileImageFilled style={{ color: "#eb2f96" }} />;
|
||||
case "py":
|
||||
case "js":
|
||||
case "ts":
|
||||
case "jsx":
|
||||
case "tsx":
|
||||
case "java":
|
||||
case "cpp":
|
||||
case "c":
|
||||
case "go":
|
||||
case "rs":
|
||||
case "rb":
|
||||
case "php":
|
||||
return <CodeFilled style={{ color: "#22c55e" }} />;
|
||||
default:
|
||||
return <FileTextFilled style={{ color: "#3b82f6" }} />;
|
||||
}
|
||||
};
|
||||
|
||||
export function SkillCard({
|
||||
skill,
|
||||
isHover,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onToggleEnabled,
|
||||
onDelete,
|
||||
}: SkillCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const isCustomized = skill.source === "customized";
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!skill.enabled && onDelete) {
|
||||
onDelete(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
hoverable
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className={`${styles.skillCard} ${
|
||||
skill.enabled ? styles.enabledCard : ""
|
||||
} ${isHover ? styles.hover : styles.normal}`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div className={styles.cardHeader}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span className={styles.fileIcon}>{getFileIcon(skill.name)}</span>
|
||||
<h3 className={styles.skillTitle}>{skill.name}</h3>
|
||||
</div>
|
||||
<div className={styles.statusContainer}>
|
||||
<span
|
||||
className={`${styles.statusDot} ${
|
||||
skill.enabled ? styles.enabled : styles.disabled
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`${styles.statusText} ${
|
||||
skill.enabled ? styles.enabled : styles.disabled
|
||||
}`}
|
||||
>
|
||||
{skill.enabled ? t("common.enabled") : t("common.disabled")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.sourcePill}>
|
||||
<span>{t("skills.source")}</span>
|
||||
<strong>{SOURCE_LABELS[skill.source] || skill.source}</strong>
|
||||
</div>
|
||||
|
||||
<div className={styles.infoSection}>
|
||||
<div className={styles.infoLabel}>
|
||||
{t("skills.description", "Description")}
|
||||
</div>
|
||||
<span className={`${styles.infoCode} ${styles.description}`}>
|
||||
{extractDescription(skill.content)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<SecurityBadges security={skill.security} source={skill.source} />
|
||||
</div>
|
||||
|
||||
<div className={styles.cardFooter}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={onToggleEnabled}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
{skill.enabled ? t("common.disable") : t("common.enable")}
|
||||
</Button>
|
||||
|
||||
{isCustomized && onDelete && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
className={styles.deleteButton}
|
||||
onClick={handleDeleteClick}
|
||||
disabled={skill.enabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Decorative icon */}
|
||||
<Sparkles
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: -16,
|
||||
right: -16,
|
||||
width: 128,
|
||||
height: 128,
|
||||
opacity: 0.03,
|
||||
pointerEvents: "none",
|
||||
color: "#3b82f6",
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Drawer, Form, Input, Button, message } from "@agentscope-ai/design";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FormInstance } from "antd";
|
||||
import type { SkillSpec } from "../../../../api/types";
|
||||
import { MarkdownCopy } from "../../../../components/MarkdownCopy/MarkdownCopy";
|
||||
|
||||
/**
|
||||
* Parse frontmatter from content string.
|
||||
* Returns an object with parsed key-value pairs, or null if no valid frontmatter found.
|
||||
*/
|
||||
function parseFrontmatter(content: string): Record<string, string> | null {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed.startsWith("---")) return null;
|
||||
|
||||
const endIndex = trimmed.indexOf("---", 3);
|
||||
if (endIndex === -1) return null;
|
||||
|
||||
const frontmatterBlock = trimmed.slice(3, endIndex).trim();
|
||||
if (!frontmatterBlock) return null;
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const line of frontmatterBlock.split("\n")) {
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex > 0) {
|
||||
const key = line.slice(0, colonIndex).trim();
|
||||
const value = line.slice(colonIndex + 1).trim();
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
interface SkillDrawerProps {
|
||||
open: boolean;
|
||||
editingSkill: SkillSpec | null;
|
||||
form: FormInstance<SkillSpec>;
|
||||
onClose: () => void;
|
||||
onSubmit: (values: SkillSpec) => void;
|
||||
onContentChange?: (content: string) => void;
|
||||
}
|
||||
|
||||
export function SkillDrawer({
|
||||
open,
|
||||
editingSkill,
|
||||
form,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onContentChange,
|
||||
}: SkillDrawerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showMarkdown, setShowMarkdown] = useState(true);
|
||||
const [contentValue, setContentValue] = useState("");
|
||||
|
||||
const validateFrontmatter = useCallback(
|
||||
(_: unknown, value: string) => {
|
||||
const content = contentValue || value;
|
||||
if (!content || !content.trim()) {
|
||||
return Promise.reject(new Error(t("skills.pleaseInputContent")));
|
||||
}
|
||||
const fm = parseFrontmatter(content);
|
||||
if (!fm) {
|
||||
return Promise.reject(new Error(t("skills.frontmatterRequired")));
|
||||
}
|
||||
if (!fm.name) {
|
||||
return Promise.reject(new Error(t("skills.frontmatterNameRequired")));
|
||||
}
|
||||
if (!fm.description) {
|
||||
return Promise.reject(
|
||||
new Error(t("skills.frontmatterDescriptionRequired")),
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
[contentValue, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingSkill) {
|
||||
setContentValue(editingSkill.content);
|
||||
form.setFieldsValue({
|
||||
name: editingSkill.name,
|
||||
content: editingSkill.content,
|
||||
});
|
||||
} else {
|
||||
setContentValue("");
|
||||
form.resetFields();
|
||||
}
|
||||
}, [editingSkill, form]);
|
||||
|
||||
const handleSubmit = (values: { name: string; content: string }) => {
|
||||
if (editingSkill) {
|
||||
message.warning(t("skills.editNotSupported"));
|
||||
onClose();
|
||||
} else {
|
||||
onSubmit({
|
||||
...values,
|
||||
content: contentValue || values.content,
|
||||
source: "",
|
||||
path: "",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleContentChange = (content: string) => {
|
||||
setContentValue(content);
|
||||
form.setFieldsValue({ content });
|
||||
// Re-validate the content field to give real-time feedback
|
||||
form.validateFields(["content"]).catch(() => {});
|
||||
if (onContentChange) {
|
||||
onContentChange(content);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width={600}
|
||||
placement="right"
|
||||
title={editingSkill ? t("skills.viewSkill") : t("skills.createSkill")}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
{!editingSkill && (
|
||||
<>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Name"
|
||||
rules={[{ required: true, message: t("skills.pleaseInputName") }]}
|
||||
>
|
||||
<Input placeholder={t("skills.skillNamePlaceholder")} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="Content"
|
||||
rules={[{ required: true, validator: validateFrontmatter }]}
|
||||
>
|
||||
<MarkdownCopy
|
||||
content={contentValue}
|
||||
showMarkdown={showMarkdown}
|
||||
onShowMarkdownChange={setShowMarkdown}
|
||||
editable={true}
|
||||
onContentChange={handleContentChange}
|
||||
textareaProps={{
|
||||
placeholder: t("skills.contentPlaceholder"),
|
||||
rows: 12,
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
<Button onClick={onClose}>{t("common.cancel")}</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
{t("skills.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editingSkill && (
|
||||
<>
|
||||
<Form.Item name="name" label="name">
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="content" label="Content">
|
||||
<MarkdownCopy
|
||||
content={editingSkill.content}
|
||||
showMarkdown={showMarkdown}
|
||||
onShowMarkdownChange={setShowMarkdown}
|
||||
textareaProps={{
|
||||
disabled: true,
|
||||
rows: 12,
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="source" label="Source">
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="path" label="Path">
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
backgroundColor: "#fffbe6",
|
||||
border: "1px solid #ffe58f",
|
||||
borderRadius: 4,
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: 0, fontSize: 12, color: "#8c8c8c" }}>
|
||||
{t("skills.editNote")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SkillCard } from "./SkillCard";
|
||||
export { SkillDrawer } from "./SkillDrawer";
|
||||
@@ -0,0 +1,338 @@
|
||||
.skillsPage {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.headerActionButton {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.headerInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 4px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--citedy-slate-900);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--citedy-slate-500);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
}
|
||||
|
||||
.loadingText {
|
||||
color: var(--citedy-slate-500);
|
||||
}
|
||||
|
||||
.importHintBlock {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.importHintTitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--citedy-slate-600);
|
||||
}
|
||||
|
||||
.importHintList {
|
||||
margin: 8px 0;
|
||||
padding: 0 0 0 20px;
|
||||
font-size: 12px;
|
||||
color: var(--citedy-slate-500);
|
||||
}
|
||||
|
||||
.importUrlInput {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--citedy-slate-200);
|
||||
border-radius: 9999px;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--citedy-slate-900);
|
||||
box-shadow: 0 0 0 2px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.importUrlError {
|
||||
margin-top: 8px;
|
||||
color: var(--citedy-red-600);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.importLoadingText {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--citedy-slate-600);
|
||||
}
|
||||
|
||||
.skillsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.skillCard {
|
||||
border-radius: 16px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&.enabledCard {
|
||||
border: 2px solid var(--citedy-slate-900) !important;
|
||||
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.12) !important;
|
||||
|
||||
.statusDot.enabled {
|
||||
background-color: var(--citedy-green-500);
|
||||
}
|
||||
|
||||
.statusText.enabled {
|
||||
color: var(--citedy-green-500);
|
||||
}
|
||||
}
|
||||
|
||||
&.hover {
|
||||
transform: translateY(-2px);
|
||||
border: 1px solid var(--citedy-slate-300) !important;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
&.normal {
|
||||
border: 1px solid rgba(226, 232, 240, 0.4) !important;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.skillTitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--citedy-slate-600);
|
||||
}
|
||||
|
||||
.fileIcon {
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.statusContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
|
||||
&.enabled {
|
||||
background-color: var(--citedy-green-500);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background-color: var(--citedy-slate-300);
|
||||
}
|
||||
}
|
||||
|
||||
.statusText {
|
||||
font-size: 12px;
|
||||
|
||||
&.enabled {
|
||||
color: var(--citedy-green-500);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: var(--citedy-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
.infoSection {
|
||||
margin-bottom: 12px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.sourcePill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: fit-content;
|
||||
margin-bottom: 12px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
border-radius: 9999px;
|
||||
background: rgba(248, 250, 252, 0.86);
|
||||
color: var(--citedy-slate-500);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
|
||||
strong {
|
||||
color: var(--citedy-slate-700);
|
||||
font-weight: 650;
|
||||
}
|
||||
}
|
||||
|
||||
.infoLabel {
|
||||
font-size: 12px;
|
||||
color: var(--citedy-slate-500);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.infoCode {
|
||||
font-size: 12px;
|
||||
color: var(--citedy-slate-600);
|
||||
background-color: var(--citedy-slate-50);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&.path {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&.description {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
font-family: inherit;
|
||||
background-color: transparent;
|
||||
color: var(--citedy-slate-500);
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cardFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(226, 232, 240, 0.4);
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.deleteButton {
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--citedy-red-600) !important;
|
||||
color: #fff !important;
|
||||
border-color: var(--citedy-red-600) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.contentLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
width: 100%;
|
||||
|
||||
label {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.markdownToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.skillsPage {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.headerActionButton {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.skillsGrid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.toggleLabel {
|
||||
font-size: 12px;
|
||||
color: var(--citedy-slate-600);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.markdownViewer {
|
||||
min-height: 200px;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--citedy-slate-200);
|
||||
border-radius: 6px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Form, Modal } from "@agentscope-ai/design";
|
||||
import { DownloadOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import type { SkillSpec } from "../../../api/types";
|
||||
import { SkillCard, SkillDrawer } from "./components";
|
||||
import { useSkills } from "./useSkills";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styles from "./index.module.less";
|
||||
|
||||
function SkillsPage() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
skills,
|
||||
loading,
|
||||
importing,
|
||||
createSkill,
|
||||
importFromHub,
|
||||
toggleEnabled,
|
||||
deleteSkill,
|
||||
} = useSkills();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||
const [importUrl, setImportUrl] = useState("");
|
||||
const [importUrlError, setImportUrlError] = useState("");
|
||||
const [editingSkill, setEditingSkill] = useState<SkillSpec | null>(null);
|
||||
const [hoverKey, setHoverKey] = useState<string | null>(null);
|
||||
const [form] = Form.useForm<SkillSpec>();
|
||||
|
||||
const supportedSkillUrlPrefixes = [
|
||||
"https://skills.sh/",
|
||||
"https://clawhub.ai/",
|
||||
"https://skillsmp.com/",
|
||||
"https://github.com/",
|
||||
];
|
||||
|
||||
const isSupportedSkillUrl = (url: string) => {
|
||||
return supportedSkillUrlPrefixes.some((prefix) => url.startsWith(prefix));
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingSkill(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
enabled: false,
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const closeImportModal = () => {
|
||||
if (importing) {
|
||||
return;
|
||||
}
|
||||
setImportModalOpen(false);
|
||||
setImportUrl("");
|
||||
setImportUrlError("");
|
||||
};
|
||||
|
||||
const handleImportFromHub = () => {
|
||||
setImportModalOpen(true);
|
||||
};
|
||||
|
||||
const handleImportUrlChange = (value: string) => {
|
||||
setImportUrl(value);
|
||||
const trimmed = value.trim();
|
||||
if (trimmed && !isSupportedSkillUrl(trimmed)) {
|
||||
setImportUrlError(t("skills.invalidSkillUrlSource"));
|
||||
return;
|
||||
}
|
||||
setImportUrlError("");
|
||||
};
|
||||
|
||||
const handleConfirmImport = async () => {
|
||||
if (importing) return;
|
||||
const trimmed = importUrl.trim();
|
||||
if (!trimmed) return;
|
||||
if (!isSupportedSkillUrl(trimmed)) {
|
||||
setImportUrlError(t("skills.invalidSkillUrlSource"));
|
||||
return;
|
||||
}
|
||||
const success = await importFromHub(trimmed);
|
||||
if (success) {
|
||||
closeImportModal();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (skill: SkillSpec) => {
|
||||
setEditingSkill(skill);
|
||||
form.setFieldsValue(skill);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (skill: SkillSpec, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await toggleEnabled(skill);
|
||||
};
|
||||
|
||||
const handleDelete = async (skill: SkillSpec, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
await deleteSkill(skill);
|
||||
};
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
setDrawerOpen(false);
|
||||
setEditingSkill(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: { name: string; content: string }) => {
|
||||
try {
|
||||
const success = await createSkill(values.name, values.content);
|
||||
if (success) {
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Submit failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.skillsPage}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerInfo}>
|
||||
<h1 className={styles.title}>{t("skills.title")}</h1>
|
||||
<p className={styles.description}>{t("skills.description")}</p>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
<Button
|
||||
className={styles.headerActionButton}
|
||||
type="primary"
|
||||
onClick={handleImportFromHub}
|
||||
icon={<DownloadOutlined />}
|
||||
>
|
||||
{t("skills.importSkills")}
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.headerActionButton}
|
||||
type="primary"
|
||||
onClick={handleCreate}
|
||||
icon={<PlusOutlined />}
|
||||
>
|
||||
{t("skills.createSkill")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={t("skills.importSkills")}
|
||||
open={importModalOpen}
|
||||
onCancel={closeImportModal}
|
||||
maskClosable={!importing}
|
||||
closable={!importing}
|
||||
keyboard={!importing}
|
||||
footer={
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<Button
|
||||
onClick={closeImportModal}
|
||||
style={{ marginRight: 8 }}
|
||||
disabled={importing}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleConfirmImport}
|
||||
loading={importing}
|
||||
disabled={importing || !importUrl.trim() || !!importUrlError}
|
||||
>
|
||||
{t("skills.importSkills")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
width={760}
|
||||
>
|
||||
<div className={styles.importHintBlock}>
|
||||
<p className={styles.importHintTitle}>
|
||||
{t("skills.supportedSkillUrlSources")}
|
||||
</p>
|
||||
<ul className={styles.importHintList}>
|
||||
<li>https://skills.sh/</li>
|
||||
<li>https://clawhub.ai/</li>
|
||||
<li>https://skillsmp.com/</li>
|
||||
<li>https://github.com/</li>
|
||||
</ul>
|
||||
<p className={styles.importHintTitle}>{t("skills.urlExamples")}</p>
|
||||
<ul className={styles.importHintList}>
|
||||
<li>https://skills.sh/vercel-labs/skills/find-skills</li>
|
||||
<li>
|
||||
https://github.com/anthropics/skills/tree/main/skills/skill-creator
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<input
|
||||
className={styles.importUrlInput}
|
||||
value={importUrl}
|
||||
onChange={(e) => handleImportUrlChange(e.target.value)}
|
||||
placeholder={t("skills.enterSkillUrl")}
|
||||
disabled={importing}
|
||||
/>
|
||||
{importUrlError ? (
|
||||
<div className={styles.importUrlError}>{importUrlError}</div>
|
||||
) : null}
|
||||
{importing ? (
|
||||
<div className={styles.importLoadingText}>{t("common.loading")}</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
{loading ? (
|
||||
<div className={styles.loading}>
|
||||
<span className={styles.loadingText}>{t("common.loading")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.skillsGrid}>
|
||||
{skills
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.enabled && !b.enabled) return -1;
|
||||
if (!a.enabled && b.enabled) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((skill) => (
|
||||
<SkillCard
|
||||
key={`${skill.source}:${skill.name}`}
|
||||
skill={skill}
|
||||
isHover={hoverKey === `${skill.source}:${skill.name}`}
|
||||
onClick={() => handleEdit(skill)}
|
||||
onMouseEnter={() =>
|
||||
setHoverKey(`${skill.source}:${skill.name}`)
|
||||
}
|
||||
onMouseLeave={() => setHoverKey(null)}
|
||||
onToggleEnabled={(e) => handleToggleEnabled(skill, e)}
|
||||
onDelete={(e) => handleDelete(skill, e)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SkillDrawer
|
||||
open={drawerOpen}
|
||||
editingSkill={editingSkill}
|
||||
form={form}
|
||||
onClose={handleDrawerClose}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkillsPage;
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { message, Modal } from "@agentscope-ai/design";
|
||||
import api from "../../../api";
|
||||
import type { SkillSpec } from "../../../api/types";
|
||||
|
||||
export function useSkills() {
|
||||
const [skills, setSkills] = useState<SkillSpec[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
const fetchSkills = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.listSkills();
|
||||
if (data) {
|
||||
setSkills(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load skills", error);
|
||||
message.error("Failed to load skills");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const loadSkills = async () => {
|
||||
await fetchSkills();
|
||||
};
|
||||
|
||||
if (mounted) {
|
||||
loadSkills();
|
||||
}
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const createSkill = async (name: string, content: string) => {
|
||||
try {
|
||||
await api.createSkill(name, content);
|
||||
message.success("Created successfully");
|
||||
await fetchSkills();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to save skill", error);
|
||||
message.error("Failed to save");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const importFromHub = async (input: string) => {
|
||||
const text = (input || "").trim();
|
||||
if (!text) {
|
||||
message.warning("Please provide a hub skill URL");
|
||||
return false;
|
||||
}
|
||||
if (!text.startsWith("http://") && !text.startsWith("https://")) {
|
||||
message.warning(
|
||||
"Please enter a valid URL starting with http:// or https://",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
setImporting(true);
|
||||
const payload = { bundle_url: text, enable: true, overwrite: false };
|
||||
const result = await api.installHubSkill(payload);
|
||||
if (result?.installed) {
|
||||
message.success(`Imported skill: ${result.name}`);
|
||||
await fetchSkills();
|
||||
return true;
|
||||
}
|
||||
message.error("Import failed");
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Failed to import skill from hub", error);
|
||||
message.error("Import failed");
|
||||
return false;
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEnabled = async (skill: SkillSpec) => {
|
||||
try {
|
||||
if (skill.enabled) {
|
||||
await api.disableSkill(skill.name);
|
||||
setSkills((prev) =>
|
||||
prev.map((s) =>
|
||||
s.name === skill.name ? { ...s, enabled: false } : s,
|
||||
),
|
||||
);
|
||||
message.success("Disabled successfully");
|
||||
} else {
|
||||
await api.enableSkill(skill.name);
|
||||
setSkills((prev) =>
|
||||
prev.map((s) =>
|
||||
s.name === skill.name ? { ...s, enabled: true } : s,
|
||||
),
|
||||
);
|
||||
message.success("Enabled successfully");
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle skill", error);
|
||||
message.error("Operation failed");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSkill = async (skill: SkillSpec) => {
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
Modal.confirm({
|
||||
title: "Confirm Delete",
|
||||
content: `Are you sure you want to delete skill "${skill.name}"? This action cannot be undone.`,
|
||||
okText: "Delete",
|
||||
okType: "danger",
|
||||
cancelText: "Cancel",
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
|
||||
if (!confirmed) return false;
|
||||
|
||||
try {
|
||||
const result = await api.deleteSkill(skill.name);
|
||||
if (result.deleted) {
|
||||
message.success("Deleted successfully");
|
||||
await fetchSkills();
|
||||
return true;
|
||||
} else {
|
||||
message.error("Failed to delete skill");
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete skill", error);
|
||||
message.error("Failed to delete skill");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
skills,
|
||||
loading,
|
||||
importing,
|
||||
createSkill,
|
||||
importFromHub,
|
||||
toggleEnabled,
|
||||
deleteSkill,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Button, Card, Input, Switch, message } from "@agentscope-ai/design";
|
||||
import { CopyOutlined, UndoOutlined, SaveOutlined } from "@ant-design/icons";
|
||||
import type { MarkdownFile } from "../../../../api/types";
|
||||
import { XMarkdown } from "@ant-design/x-markdown";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { stripFrontmatter } from "../../../../utils/markdown";
|
||||
import styles from "../index.module.less";
|
||||
|
||||
interface FileEditorProps {
|
||||
selectedFile: MarkdownFile | null;
|
||||
fileContent: string;
|
||||
loading: boolean;
|
||||
hasChanges: boolean;
|
||||
onContentChange: (content: string) => void;
|
||||
onSave: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export const FileEditor: React.FC<FileEditorProps> = ({
|
||||
selectedFile,
|
||||
fileContent,
|
||||
loading,
|
||||
hasChanges,
|
||||
onContentChange,
|
||||
onSave,
|
||||
onReset,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [showMarkdown, setShowMarkdown] = useState(true);
|
||||
|
||||
const isMarkdownFile = selectedFile?.filename.endsWith(".md") || false;
|
||||
const markdownContent = useMemo(
|
||||
() => stripFrontmatter(fileContent || ""),
|
||||
[fileContent],
|
||||
);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(fileContent);
|
||||
message.success(t("common.copied"));
|
||||
} else {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = fileContent;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.left = "-999999px";
|
||||
textArea.style.top = "-999999px";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
textArea.remove();
|
||||
message.success(t("common.copied"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to copy text: ", err);
|
||||
message.error(t("common.copyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.fileEditor}>
|
||||
<Card className={styles.editorCard}>
|
||||
{selectedFile ? (
|
||||
<>
|
||||
<div className={styles.editorHeader}>
|
||||
<div>
|
||||
<div className={styles.fileName}>{selectedFile.filename}</div>
|
||||
<div className={styles.filePath}>{selectedFile.path}</div>
|
||||
</div>
|
||||
<div className={styles.buttonGroup}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={onReset}
|
||||
disabled={!hasChanges}
|
||||
icon={<UndoOutlined />}
|
||||
>
|
||||
{t("common.reset")}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={onSave}
|
||||
disabled={!hasChanges}
|
||||
loading={loading}
|
||||
icon={<SaveOutlined />}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.editorContent}>
|
||||
<div className={styles.contentLabel}>
|
||||
<div>{t("common.content")}</div>
|
||||
{isMarkdownFile && (
|
||||
<div className={styles.buttonGroup}>
|
||||
<div className={styles.markdownToggle}>
|
||||
<span className={styles.toggleLabel}>
|
||||
{t("common.preview")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={showMarkdown}
|
||||
onChange={setShowMarkdown}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
type="text"
|
||||
onClick={copyToClipboard}
|
||||
className={styles.copyButton}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showMarkdown && isMarkdownFile ? (
|
||||
<XMarkdown
|
||||
content={markdownContent}
|
||||
className={styles.markdownViewer}
|
||||
/>
|
||||
) : (
|
||||
<Input.TextArea
|
||||
value={fileContent}
|
||||
onChange={(e) => onContentChange(e.target.value)}
|
||||
className={styles.textarea}
|
||||
placeholder={t("workspace.fileContent")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.emptyState}>{t("workspace.selectFile")}</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from "react";
|
||||
import type { MarkdownFile, DailyMemoryFile } from "../../../../api/types";
|
||||
import { formatFileSize, formatTimeAgo } from "./utils";
|
||||
import styles from "../index.module.less";
|
||||
|
||||
interface FileItemProps {
|
||||
file: MarkdownFile;
|
||||
selectedFile: MarkdownFile | null;
|
||||
expandedMemory: boolean;
|
||||
dailyMemories: DailyMemoryFile[];
|
||||
onFileClick: (file: MarkdownFile) => void;
|
||||
onDailyMemoryClick: (daily: DailyMemoryFile) => void;
|
||||
}
|
||||
|
||||
export const FileItem: React.FC<FileItemProps> = ({
|
||||
file,
|
||||
selectedFile,
|
||||
expandedMemory,
|
||||
dailyMemories,
|
||||
onFileClick,
|
||||
onDailyMemoryClick,
|
||||
}) => {
|
||||
const isSelected = selectedFile?.filename === file.filename;
|
||||
const isMemoryFile = file.filename === "MEMORY.md";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onClick={() => onFileClick(file)}
|
||||
className={`${styles.fileItem} ${isSelected ? styles.selected : ""}`}
|
||||
>
|
||||
<div className={styles.fileItemHeader}>
|
||||
<div className={styles.fileInfo}>
|
||||
<div className={styles.fileItemName}>{file.filename}</div>
|
||||
<div className={styles.fileItemMeta}>
|
||||
{formatFileSize(file.size)} · {formatTimeAgo(file.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
{isMemoryFile && (
|
||||
<span className={styles.expandIcon}>
|
||||
{expandedMemory ? "▼" : "▶"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMemoryFile && expandedMemory && (
|
||||
<div className={styles.dailyMemoryList}>
|
||||
{dailyMemories.map((daily) => {
|
||||
const isDailySelected =
|
||||
selectedFile?.filename === `${daily.date}.md`;
|
||||
return (
|
||||
<div
|
||||
key={daily.date}
|
||||
onClick={() => onDailyMemoryClick(daily)}
|
||||
className={`${styles.dailyMemoryItem} ${
|
||||
isDailySelected ? styles.selected : ""
|
||||
}`}
|
||||
>
|
||||
<div className={styles.dailyMemoryName}>{daily.date}.md</div>
|
||||
<div className={styles.dailyMemoryMeta}>
|
||||
{formatFileSize(daily.size)} ·{" "}
|
||||
{formatTimeAgo(daily.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from "react";
|
||||
import { Button, Card } from "@agentscope-ai/design";
|
||||
import { ReloadOutlined } from "@ant-design/icons";
|
||||
import type { MarkdownFile, DailyMemoryFile } from "../../../../api/types";
|
||||
import { FileItem } from "./FileItem";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styles from "../index.module.less";
|
||||
|
||||
interface FileListPanelProps {
|
||||
files: MarkdownFile[];
|
||||
selectedFile: MarkdownFile | null;
|
||||
dailyMemories: DailyMemoryFile[];
|
||||
expandedMemory: boolean;
|
||||
workspacePath: string;
|
||||
onRefresh: () => void;
|
||||
onFileClick: (file: MarkdownFile) => void;
|
||||
onDailyMemoryClick: (daily: DailyMemoryFile) => void;
|
||||
}
|
||||
|
||||
export const FileListPanel: React.FC<FileListPanelProps> = ({
|
||||
files,
|
||||
selectedFile,
|
||||
dailyMemories,
|
||||
expandedMemory,
|
||||
onRefresh,
|
||||
onFileClick,
|
||||
onDailyMemoryClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={styles.fileListPanel}>
|
||||
<Card
|
||||
bodyStyle={{
|
||||
padding: 16,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
overflow: "auto",
|
||||
}}
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
>
|
||||
<div className={styles.headerRow}>
|
||||
<h3 className={styles.sectionTitle}>{t("workspace.coreFiles")}</h3>
|
||||
<Button size="small" onClick={onRefresh} icon={<ReloadOutlined />}>
|
||||
{t("common.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className={styles.infoText}>{t("workspace.coreFilesDesc")}</p>
|
||||
<div className={styles.divider} />
|
||||
|
||||
<div className={styles.scrollContainer}>
|
||||
{files.length > 0 ? (
|
||||
files.map((file) => (
|
||||
<FileItem
|
||||
key={file.filename}
|
||||
file={file}
|
||||
selectedFile={selectedFile}
|
||||
expandedMemory={expandedMemory}
|
||||
dailyMemories={dailyMemories}
|
||||
onFileClick={onFileClick}
|
||||
onDailyMemoryClick={onDailyMemoryClick}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className={styles.emptyState}>{t("workspace.noFiles")}</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export { FileListPanel } from "./FileListPanel";
|
||||
export { FileEditor } from "./FileEditor";
|
||||
export { FileItem } from "./FileItem";
|
||||
export { useAgentsData } from "./useAgentsData";
|
||||
export * from "./utils";
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { message } from "@agentscope-ai/design";
|
||||
import api from "../../../../api";
|
||||
import type { MarkdownFile, DailyMemoryFile } from "../../../../api/types";
|
||||
|
||||
export const useAgentsData = () => {
|
||||
const [files, setFiles] = useState<MarkdownFile[]>([]);
|
||||
const [selectedFile, setSelectedFile] = useState<MarkdownFile | null>(null);
|
||||
const [dailyMemories, setDailyMemories] = useState<DailyMemoryFile[]>([]);
|
||||
const [expandedMemory, setExpandedMemory] = useState(false);
|
||||
const [fileContent, setFileContent] = useState("");
|
||||
const [originalContent, setOriginalContent] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [workspacePath, setWorkspacePath] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchFiles();
|
||||
}, []);
|
||||
|
||||
const fetchFiles = async () => {
|
||||
try {
|
||||
const fileList = await api.listFiles();
|
||||
setFiles(fileList as MarkdownFile[]);
|
||||
if (fileList.length > 0) {
|
||||
const path = fileList[0].path;
|
||||
const workspace = path.substring(
|
||||
0,
|
||||
path.lastIndexOf("/") || path.lastIndexOf("\\"),
|
||||
);
|
||||
setWorkspacePath(workspace);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch files", error);
|
||||
message.error("Failed to load file list");
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDailyMemories = async () => {
|
||||
try {
|
||||
const memoryList = await api.listDailyMemory();
|
||||
setDailyMemories(memoryList);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch daily memories", error);
|
||||
message.error("Failed to load memory list");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileClick = async (file: MarkdownFile) => {
|
||||
if (file.filename === "MEMORY.md") {
|
||||
if (expandedMemory && selectedFile?.filename === "MEMORY.md") {
|
||||
setExpandedMemory(false);
|
||||
return;
|
||||
} else {
|
||||
setExpandedMemory(true);
|
||||
fetchDailyMemories();
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedFile(file);
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.loadFile(file.filename);
|
||||
setFileContent(data.content);
|
||||
setOriginalContent(data.content);
|
||||
} catch (error) {
|
||||
console.error("Failed to load file", error);
|
||||
message.error("Failed to load file");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDailyMemoryClick = async (daily: DailyMemoryFile) => {
|
||||
setSelectedFile({
|
||||
filename: `${daily.date}.md`,
|
||||
path: daily.path,
|
||||
size: daily.size,
|
||||
created_time: daily.created_time,
|
||||
modified_time: daily.modified_time,
|
||||
updated_at: daily.updated_at,
|
||||
});
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.loadDailyMemory(daily.date);
|
||||
setFileContent(data.content);
|
||||
setOriginalContent(data.content);
|
||||
} catch (error) {
|
||||
console.error("Failed to load daily memory", error);
|
||||
message.error("Failed to load daily memory");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedFile) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
if (selectedFile.filename.match(/^\d{4}-\d{2}-\d{2}\.md$/)) {
|
||||
const date = selectedFile.filename.replace(".md", "");
|
||||
await api.saveDailyMemory(date, fileContent);
|
||||
} else {
|
||||
await api.saveFile(selectedFile.filename, fileContent);
|
||||
}
|
||||
setOriginalContent(fileContent);
|
||||
message.success("Saved successfully");
|
||||
if (selectedFile.filename.match(/^\d{4}-\d{2}-\d{2}\.md$/)) {
|
||||
fetchDailyMemories();
|
||||
} else {
|
||||
fetchFiles();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save file", error);
|
||||
message.error("Failed to save");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFileContent(originalContent);
|
||||
};
|
||||
|
||||
const hasChanges = fileContent !== originalContent;
|
||||
|
||||
return {
|
||||
files,
|
||||
selectedFile,
|
||||
dailyMemories,
|
||||
expandedMemory,
|
||||
fileContent,
|
||||
loading,
|
||||
workspacePath,
|
||||
hasChanges,
|
||||
setFileContent,
|
||||
fetchFiles,
|
||||
fetchDailyMemories,
|
||||
handleFileClick,
|
||||
handleDailyMemoryClick,
|
||||
handleSave,
|
||||
handleReset,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 B";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
export const formatTimeAgo = (timestamp: number): string => {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
return `${Math.floor(seconds / 86400)}d ago`;
|
||||
};
|
||||
|
||||
export const isDailyMemoryFile = (filename: string): boolean => {
|
||||
return /^\d{4}-\d{2}-\d{2}\.md$/.test(filename);
|
||||
};
|
||||
@@ -0,0 +1,417 @@
|
||||
.agentsPage {
|
||||
padding: 24px;
|
||||
height: calc(100vh - 96px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 4px;
|
||||
font-size: 24px;
|
||||
color: var(--citedy-slate-900);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--citedy-slate-500);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.fileListPanel {
|
||||
width: 400px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.cardContainer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.workspaceInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.headerRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--citedy-slate-900);
|
||||
}
|
||||
|
||||
.infoText {
|
||||
margin: 0;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--citedy-slate-500);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workspacePath {
|
||||
margin: 0;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--citedy-slate-600);
|
||||
font-family: monospace;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-color: rgba(226, 232, 240, 0.6);
|
||||
margin-bottom: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.scrollContainer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.fileEditor {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editorCard {
|
||||
height: 100% !important;
|
||||
|
||||
:global {
|
||||
.MaskanX-card-body {
|
||||
height: 100%;
|
||||
|
||||
.MaskanX-spark-card-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.MaskanX-spark-content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.agentscope-runtime-webui-spark-card-wrapper {
|
||||
height: 100%;
|
||||
|
||||
.agentscope-runtime-webui-spark-content {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.spark-card-wrapper {
|
||||
height: 100%;
|
||||
|
||||
.spark-content {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editorHeader {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.4);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: var(--citedy-slate-900);
|
||||
}
|
||||
|
||||
.filePath {
|
||||
font-size: 12px;
|
||||
color: var(--citedy-slate-500);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.editorContent {
|
||||
padding: 16px;
|
||||
height: calc(100% - 96px);
|
||||
|
||||
textarea {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.contentLabel {
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.markdownViewer {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--citedy-slate-200);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
height: min(100%, clamp(280px, 50vh, 640px));
|
||||
min-height: min(240px, 100%);
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.markdownToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toggleLabel {
|
||||
font-size: 12px;
|
||||
color: var(--citedy-slate-600);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--citedy-slate-400);
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.fileItem {
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.6);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
background-color: rgba(255, 255, 255, 0.6);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(.selected) {
|
||||
border-color: var(--citedy-slate-300);
|
||||
background-color: var(--citedy-slate-50);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border: 2px solid var(--citedy-slate-900);
|
||||
background-color: rgba(15, 23, 42, 0.04);
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.fileItemHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.fileInfo {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fileActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fileItemName {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: var(--citedy-slate-900);
|
||||
}
|
||||
|
||||
.fileItemMeta {
|
||||
font-size: 12px;
|
||||
color: var(--citedy-slate-500);
|
||||
}
|
||||
|
||||
.expandIcon {
|
||||
font-size: 12px;
|
||||
color: var(--citedy-slate-400);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.dailyMemoryList {
|
||||
margin-left: 16px;
|
||||
margin-top: -4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dailyMemoryItem {
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.6);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
background-color: rgba(255, 255, 255, 0.6);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(.selected) {
|
||||
border-color: var(--citedy-slate-300);
|
||||
background-color: var(--citedy-slate-50);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border: 2px solid var(--citedy-slate-900);
|
||||
background-color: rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
.dailyMemoryName {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
color: var(--citedy-slate-900);
|
||||
}
|
||||
|
||||
.dailyMemoryMeta {
|
||||
font-size: 11px;
|
||||
color: var(--citedy-slate-500);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.agentsPage {
|
||||
padding: 16px;
|
||||
height: auto;
|
||||
min-height: calc(100vh - 96px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.workspaceInfo {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
height: auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.workspacePath {
|
||||
margin-bottom: 0;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.fileListPanel {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editorHeader {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editorContent {
|
||||
height: auto;
|
||||
min-height: 320px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useAgentsData, FileListPanel, FileEditor } from "./components";
|
||||
import styles from "./index.module.less";
|
||||
import { UploadOutlined, DownloadOutlined } from "@ant-design/icons";
|
||||
import { Button, Tooltip, message } from "@agentscope-ai/design";
|
||||
import { workspaceApi } from "../../../api/modules/workspace";
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function WorkspacePage() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
files,
|
||||
selectedFile,
|
||||
dailyMemories,
|
||||
expandedMemory,
|
||||
fileContent,
|
||||
loading,
|
||||
workspacePath,
|
||||
hasChanges,
|
||||
setFileContent,
|
||||
fetchFiles,
|
||||
handleFileClick,
|
||||
handleDailyMemoryClick,
|
||||
handleSave,
|
||||
handleReset,
|
||||
} = useAgentsData();
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const blob = await workspaceApi.downloadWorkspace();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `workspace-${new Date().toISOString().split("T")[0]}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
message.success(t("workspace.downloadSuccess"));
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
message.error(
|
||||
t("workspace.downloadFailed") + ": " + (error as Error).message,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Check if file is zip format
|
||||
if (!file.name.toLowerCase().endsWith(".zip")) {
|
||||
message.error(t("workspace.zipOnly"));
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const maxSize = 100 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
message.error(
|
||||
t("workspace.fileSizeExceeded", {
|
||||
size: (file.size / (1024 * 1024)).toFixed(2),
|
||||
}),
|
||||
);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await workspaceApi.uploadFile(file);
|
||||
if (result.success) {
|
||||
message.success(t("workspace.uploadSuccess"));
|
||||
} else {
|
||||
message.error(t("workspace.uploadFailed") + ": " + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Upload failed:", error);
|
||||
message.error(
|
||||
t("workspace.uploadFailed") + ": " + (error as Error).message,
|
||||
);
|
||||
} finally {
|
||||
// Clear input value to allow re-uploading the same file
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.agentsPage}>
|
||||
<div className={styles.header}>
|
||||
<h1 className={styles.title}>{t("workspace.title")}</h1>
|
||||
<div className={styles.workspaceInfo}>
|
||||
<p className={styles.workspacePath}>
|
||||
{t("workspace.workspacePath")}{" "}
|
||||
{workspacePath ||
|
||||
(files.length === 0
|
||||
? t("workspace.noFiles")
|
||||
: t("common.loading"))}
|
||||
</p>
|
||||
<div className={styles.actionButtons}>
|
||||
<Tooltip
|
||||
title={t("workspace.uploadTooltip")}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleUploadClick}
|
||||
icon={<UploadOutlined />}
|
||||
>
|
||||
{t("common.upload")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleDownload}
|
||||
icon={<DownloadOutlined />}
|
||||
>
|
||||
{t("common.download")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<FileListPanel
|
||||
files={files}
|
||||
selectedFile={selectedFile}
|
||||
dailyMemories={dailyMemories}
|
||||
expandedMemory={expandedMemory}
|
||||
workspacePath={workspacePath}
|
||||
onRefresh={fetchFiles}
|
||||
onFileClick={handleFileClick}
|
||||
onDailyMemoryClick={handleDailyMemoryClick}
|
||||
/>
|
||||
|
||||
<FileEditor
|
||||
selectedFile={selectedFile}
|
||||
fileContent={fileContent}
|
||||
loading={loading}
|
||||
hasChanges={hasChanges}
|
||||
onContentChange={setFileContent}
|
||||
onSave={handleSave}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
</div>
|
||||
{/* Hidden file input - only accepts .zip files up to 100MB */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileUpload}
|
||||
style={{ display: "none" }}
|
||||
accept=".zip"
|
||||
title="Select a ZIP file (max 100MB)"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Form } from "antd";
|
||||
import type { FormItemProps as AntFormItemProps } from "antd";
|
||||
import type { FormListProps } from "antd/es/form/FormList";
|
||||
import { createStyles } from "antd-style";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface FormItemProps {
|
||||
name: string | string[];
|
||||
label: string;
|
||||
isList?: boolean;
|
||||
children: ReactNode | FormListProps["children"];
|
||||
normalize?: AntFormItemProps["normalize"];
|
||||
}
|
||||
|
||||
const useStyles = createStyles(({ token }) => ({
|
||||
label: {
|
||||
marginBottom: 6,
|
||||
fontSize: 12,
|
||||
color: token.colorTextSecondary,
|
||||
},
|
||||
}));
|
||||
|
||||
export default function FormItem(props: FormItemProps) {
|
||||
const { styles } = useStyles();
|
||||
|
||||
const node = props.isList ? (
|
||||
<Form.List name={props.name}>
|
||||
{props.children as FormListProps["children"]}
|
||||
</Form.List>
|
||||
) : (
|
||||
<Form.Item name={props.name} normalize={props.normalize}>
|
||||
{props.children as ReactNode}
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{props.label && <div className={styles.label}>{props.label}</div>}
|
||||
{node}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import React from "react";
|
||||
import { Form, Input, ColorPicker, Flex, Divider, InputNumber } from "antd";
|
||||
import type { FormListFieldData, FormListOperation } from "antd";
|
||||
import { createStyles } from "antd-style";
|
||||
import { Button, IconButton, Switch } from "@agentscope-ai/design";
|
||||
import { SparkDeleteLine, SparkPlusLine } from "@agentscope-ai/icons";
|
||||
import FormItem from "./FormItem";
|
||||
import defaultConfig from "./defaultConfig";
|
||||
|
||||
const useStyles = createStyles(({ token }) => ({
|
||||
container: {
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
|
||||
form: {
|
||||
height: 0,
|
||||
flex: 1,
|
||||
padding: "8px 16px 16px 16px",
|
||||
overflow: "auto",
|
||||
},
|
||||
actions: {
|
||||
padding: 16,
|
||||
display: "flex",
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
justifyContent: "flex-end",
|
||||
gap: 16,
|
||||
},
|
||||
}));
|
||||
|
||||
interface OptionsEditorProps {
|
||||
value?: Record<string, unknown>;
|
||||
onChange?: (value: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const OptionsEditor: React.FC<OptionsEditorProps> = ({ value, onChange }) => {
|
||||
const { styles } = useStyles();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleSave = () => {
|
||||
form
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
onChange?.(values);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Validation failed:", error);
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
form.setFieldsValue(defaultConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Form
|
||||
className={styles.form}
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={value}
|
||||
>
|
||||
<Divider orientation="left">Theme</Divider>
|
||||
|
||||
<FormItem
|
||||
name={["theme", "colorPrimary"]}
|
||||
label="colorPrimary"
|
||||
normalize={(value) => value.toHexString()}
|
||||
>
|
||||
<ColorPicker />
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
name={["theme", "colorBgBase"]}
|
||||
label="colorBgBase"
|
||||
normalize={(value) => value.toHexString()}
|
||||
>
|
||||
<ColorPicker />
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
name={["theme", "colorTextBase"]}
|
||||
label="colorTextBase"
|
||||
normalize={(value) => value.toHexString()}
|
||||
>
|
||||
<ColorPicker />
|
||||
</FormItem>
|
||||
|
||||
<FormItem name={["theme", "darkMode"]} label="darkMode">
|
||||
<Switch />
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
name={["theme", "leftHeader", "logo"]}
|
||||
label="leftHeader.logo"
|
||||
>
|
||||
<Input />
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
name={["theme", "leftHeader", "title"]}
|
||||
label="leftHeader.title"
|
||||
>
|
||||
<Input />
|
||||
</FormItem>
|
||||
|
||||
<Divider orientation="left">Sender</Divider>
|
||||
|
||||
<FormItem name={["sender", "disclaimer"]} label="disclaimer">
|
||||
<Input />
|
||||
</FormItem>
|
||||
|
||||
<FormItem name={["sender", "maxLength"]} label="maxLength">
|
||||
<InputNumber min={1000} />
|
||||
</FormItem>
|
||||
|
||||
<Divider orientation="left">Welcome</Divider>
|
||||
|
||||
<FormItem name={["welcome", "greeting"]} label="greeting">
|
||||
<Input />
|
||||
</FormItem>
|
||||
|
||||
<FormItem name={["welcome", "description"]} label="description">
|
||||
<Input />
|
||||
</FormItem>
|
||||
|
||||
<FormItem name={["welcome", "avatar"]} label="avatar">
|
||||
<Input />
|
||||
</FormItem>
|
||||
|
||||
<FormItem name={["welcome", "prompts"]} isList label="prompts">
|
||||
{(
|
||||
fields: FormListFieldData[],
|
||||
{ add, remove }: FormListOperation,
|
||||
) => {
|
||||
return (
|
||||
<div>
|
||||
{fields.map((field) => {
|
||||
return (
|
||||
<Flex key={field.key} gap={6}>
|
||||
<Form.Item
|
||||
style={{ flex: 1 }}
|
||||
key={field.key}
|
||||
name={[field.name, "value"]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<IconButton
|
||||
icon={<SparkPlusLine />}
|
||||
onClick={() => add({})}
|
||||
></IconButton>
|
||||
<IconButton
|
||||
icon={<SparkDeleteLine />}
|
||||
onClick={() => remove(field.name)}
|
||||
></IconButton>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</FormItem>
|
||||
|
||||
<Divider orientation="left">API</Divider>
|
||||
|
||||
<FormItem name={["api", "baseURL"]} label="baseURL">
|
||||
<Input />
|
||||
</FormItem>
|
||||
|
||||
<FormItem name={["api", "token"]} label="token">
|
||||
<Input />
|
||||
</FormItem>
|
||||
</Form>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button onClick={handleReset}>Reset</Button>
|
||||
<Button type="primary" onClick={handleSave}>
|
||||
Save & Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OptionsEditor;
|
||||
@@ -0,0 +1,38 @@
|
||||
const defaultConfig = {
|
||||
theme: {
|
||||
colorPrimary: "#615CED",
|
||||
darkMode: false,
|
||||
prefix: "maskanx",
|
||||
leftHeader: {
|
||||
logo: "",
|
||||
title: "Chats",
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
attachments: false,
|
||||
maxLength: 10000,
|
||||
disclaimer: "Works for you, grows with you",
|
||||
},
|
||||
welcome: {
|
||||
greeting: "Hello, how can I help you today?",
|
||||
description:
|
||||
"I am a helpful assistant that can help you with your questions.",
|
||||
avatar: `${import.meta.env.BASE_URL}maskan-logo.png`,
|
||||
prompts: [
|
||||
{
|
||||
value: "Let's start a new journey!",
|
||||
},
|
||||
{
|
||||
value: "Can you tell me what skills you have?",
|
||||
},
|
||||
],
|
||||
},
|
||||
api: {
|
||||
baseURL: "",
|
||||
token: "",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default defaultConfig;
|
||||
|
||||
export type DefaultConfig = typeof defaultConfig;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { SparkSettingLine } from "@agentscope-ai/icons";
|
||||
import { IconButton, Drawer } from "@agentscope-ai/design";
|
||||
import { useState } from "react";
|
||||
import OptionsEditor from "./OptionsEditor";
|
||||
|
||||
interface OptionsPanelProps {
|
||||
value?: Record<string, unknown>;
|
||||
onChange?: (value: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export default function OptionsPanel(props: OptionsPanelProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={() => setOpen(true)}
|
||||
icon={<SparkSettingLine />}
|
||||
bordered={false}
|
||||
/>
|
||||
<Drawer
|
||||
destroyOnHidden
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
styles={{ body: { padding: 0 }, header: { padding: 8 } }}
|
||||
>
|
||||
<OptionsEditor
|
||||
value={props.value}
|
||||
onChange={(v) => {
|
||||
setOpen(false);
|
||||
props.onChange?.(v);
|
||||
}}
|
||||
/>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Persona } from "../../api/types";
|
||||
import { getPersonaColor } from "./personaColors";
|
||||
|
||||
interface PersonaSelectorProps {
|
||||
personas: Persona[];
|
||||
selected: string | null;
|
||||
onSelect: (personaId: string | null) => void;
|
||||
}
|
||||
|
||||
export default function PersonaSelector({
|
||||
personas,
|
||||
selected,
|
||||
onSelect,
|
||||
}: PersonaSelectorProps) {
|
||||
if (personas.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 6,
|
||||
padding: "4px 0",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "#94a3b8",
|
||||
marginRight: 2,
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
@
|
||||
</span>
|
||||
{personas.map((p) => {
|
||||
const isActive = selected === p.id;
|
||||
const color = getPersonaColor(p);
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onSelect(isActive ? null : p.id)}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "2px 10px",
|
||||
borderRadius: 9999,
|
||||
border: `1px solid ${isActive ? color : "rgba(226,232,240,0.6)"}`,
|
||||
background: isActive ? `${color}14` : "transparent",
|
||||
color: isActive ? color : "#64748b",
|
||||
fontSize: 12,
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.15s ease",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
opacity: isActive ? 1 : 0.4,
|
||||
}}
|
||||
/>
|
||||
{p.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { createStyles } from "antd-style";
|
||||
import { Sun, Cloud, CloudRain } from "lucide-react";
|
||||
import { Card, Typography } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface WeatherItem {
|
||||
location: string;
|
||||
weather: string;
|
||||
temperature: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
const useStyles = createStyles(({ css }) => ({
|
||||
container: css`
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(135deg, #6b73ff 0%, #000dff 100%);
|
||||
color: white;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.15);
|
||||
border: none;
|
||||
|
||||
.ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
`,
|
||||
header: css`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
`,
|
||||
location: css`
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: white !important;
|
||||
`,
|
||||
date: css`
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
color: white !important;
|
||||
`,
|
||||
mainWeather: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px 0 32px;
|
||||
`,
|
||||
tempContainer: css`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
line-height: 1;
|
||||
`,
|
||||
temperature: css`
|
||||
font-size: 64px;
|
||||
font-weight: 700;
|
||||
color: white !important;
|
||||
`,
|
||||
degree: css`
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
margin-top: 8px;
|
||||
color: white !important;
|
||||
`,
|
||||
mainIcon: css`
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
filter: drop-shadow(0 4px 4px rgba(0, 0, 0, 0.2));
|
||||
`,
|
||||
condition: css`
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-top: 8px;
|
||||
opacity: 0.9;
|
||||
color: white !important;
|
||||
`,
|
||||
forecast: css`
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 16px 24px;
|
||||
`,
|
||||
forecastItem: css`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
&:first-of-type {
|
||||
padding-top: 4px;
|
||||
}
|
||||
`,
|
||||
forecastDay: css`
|
||||
font-size: 14px;
|
||||
width: 60px;
|
||||
color: white !important;
|
||||
`,
|
||||
forecastIcon: css`
|
||||
font-size: 20px;
|
||||
color: white;
|
||||
`,
|
||||
forecastTemp: css`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
width: 40px;
|
||||
text-align: right;
|
||||
color: white !important;
|
||||
`,
|
||||
}));
|
||||
|
||||
const WeatherIcon = ({
|
||||
type,
|
||||
className,
|
||||
}: {
|
||||
type: string;
|
||||
className?: string;
|
||||
}) => {
|
||||
switch (type) {
|
||||
case "sunny":
|
||||
return <Sun className={className} />;
|
||||
case "rainy":
|
||||
return <CloudRain className={className} />;
|
||||
case "cloudy":
|
||||
return <Cloud className={className} />;
|
||||
default:
|
||||
return <Sun className={className} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getWeatherLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case "sunny":
|
||||
return "Sunny";
|
||||
case "rainy":
|
||||
return "Rainy";
|
||||
case "cloudy":
|
||||
return "Cloudy";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
export default function Weather(props: {
|
||||
data: { content: Array<{ data: { output: string } }> };
|
||||
}) {
|
||||
const { styles } = useStyles();
|
||||
const data = useMemo(() => {
|
||||
try {
|
||||
const content = props.data?.content;
|
||||
if (!content || content.length < 2) {
|
||||
return [];
|
||||
}
|
||||
const output = content[1]?.data?.output;
|
||||
if (!output) {
|
||||
return [];
|
||||
}
|
||||
return JSON.parse(JSON.parse(output));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [props.data.content]);
|
||||
|
||||
if (!data?.length) return null;
|
||||
const current = data[0] as WeatherItem;
|
||||
const forecast = data.slice(1) as WeatherItem[];
|
||||
return (
|
||||
<Card className={styles.container} bordered={false}>
|
||||
<div className={styles.header}>
|
||||
<div>
|
||||
<Typography.Text className={styles.location}>
|
||||
{current.location}
|
||||
</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text className={styles.date}>
|
||||
{dayjs(current.date).format("MMM DD, dddd")}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.mainWeather}>
|
||||
<WeatherIcon type={current.weather} className={styles.mainIcon} />
|
||||
<div className={styles.tempContainer}>
|
||||
<Typography.Text className={styles.temperature}>
|
||||
{current.temperature}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.degree}>°C</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text className={styles.condition}>
|
||||
{getWeatherLabel(current.weather)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.forecast}>
|
||||
{forecast.map((item: WeatherItem, index: number) => (
|
||||
<div key={index} className={styles.forecastItem}>
|
||||
<Typography.Text className={styles.forecastDay}>
|
||||
{dayjs(item.date).format("ddd")}
|
||||
</Typography.Text>
|
||||
<WeatherIcon type={item.weather} className={styles.forecastIcon} />
|
||||
<Typography.Text className={styles.forecastTemp}>
|
||||
{item.temperature}°
|
||||
</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/* Welcome avatar: fit wide logo. */
|
||||
:global([class*="chat-anywhere-welcome-default"] [class*="-avatar"]) {
|
||||
width: 48px !important;
|
||||
height: 48px !important;
|
||||
min-width: 48px !important;
|
||||
min-height: 48px !important;
|
||||
font-size: 18px !important;
|
||||
flex-shrink: 0;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
:global([class*="chat-anywhere-welcome-default"] [class*="-avatar"] > img) {
|
||||
object-fit: contain !important;
|
||||
object-position: center;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.chatStage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.liveProgress {
|
||||
width: 100%;
|
||||
margin: 6px 0 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.liveProgressInner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 6px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--citedy-slate-700, #334155);
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.liveProgressPulse {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: #16a34a;
|
||||
box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.42);
|
||||
animation: live-progress-pulse 1.5s ease-out infinite;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.liveProgressError {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: #f97316;
|
||||
box-shadow: 0 0 0 4px rgba(249, 115, 22, 0.16);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.liveProgressText {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.liveProgressTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
color: var(--citedy-slate-900, #0f172a);
|
||||
}
|
||||
|
||||
.liveProgressDetail {
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
color: var(--citedy-slate-500, #64748b);
|
||||
}
|
||||
|
||||
.liveProgressElapsed {
|
||||
margin-left: auto;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
color: var(--citedy-slate-500, #64748b);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@keyframes live-progress-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.42);
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
70% {
|
||||
box-shadow: 0 0 0 8px rgba(22, 163, 74, 0);
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(22, 163, 74, 0);
|
||||
transform: scale(0.94);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.liveProgressPulse {
|
||||
animation: none;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.liveProgressInner {
|
||||
background: transparent;
|
||||
color: var(--citedy-slate-300, #cbd5e1);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.liveProgressTitle {
|
||||
color: var(--citedy-slate-50, #f8fafc);
|
||||
}
|
||||
|
||||
.liveProgressDetail {
|
||||
color: var(--citedy-slate-300, #cbd5e1);
|
||||
}
|
||||
|
||||
.liveProgressElapsed {
|
||||
background: rgba(248, 250, 252, 0.1);
|
||||
color: var(--citedy-slate-300, #cbd5e1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.liveProgress {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.liveProgressInner {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.liveProgressText {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.liveProgressElapsed {
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Disabled input overlay */
|
||||
.chatDisabledOverlay {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chatDisabledOverlay :global([class*="chat-anywhere-input"]),
|
||||
.chatDisabledOverlay :global([class*="chat-input"]),
|
||||
.chatDisabledOverlay :global(textarea),
|
||||
.chatDisabledOverlay :global(input[type="text"]) {
|
||||
pointer-events: none !important;
|
||||
opacity: 0.5 !important;
|
||||
background-color: var(--citedy-slate-50) !important;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
.chatDisabledOverlay :global(button[class*="send"]),
|
||||
.chatDisabledOverlay :global(button[class*="submit"]) {
|
||||
pointer-events: none !important;
|
||||
opacity: 0.3 !important;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user