feat: add React UI and AI page sharing tools

Move HTTP page rendering to the React app, fix multi-tool-call message handling, add API docs lookup, web page lookup, and Markdown preview page sharing.
This commit is contained in:
2026-07-07 22:27:14 +08:00
parent 3d77733e11
commit 640ba9e7d2
65 changed files with 8020 additions and 1732 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>iAs</title>
</head>
<body class="bg-gray-100 min-h-screen text-gray-900">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3371
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.1",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.2",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"tailwindcss": "^4.3.2",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}
+80
View File
@@ -0,0 +1,80 @@
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
import { UpdatesPage } from "./pages/UpdatesPage";
import { MailPage } from "./pages/MailPage";
import { MailDetailPage } from "./pages/MailDetailPage";
import { ToolsPage } from "./pages/ToolsPage";
import { ConfigsPage } from "./pages/ConfigsPage";
import { ContextSimulatorPage } from "./pages/ContextSimulatorPage";
import { HomePage } from "./pages/HomePage";
import {
GenericWebPageDetailPage,
MarkdownWebPageDetailPage,
MailWebPageDetailPage,
} from "./pages/WebPageDetailPage";
function App() {
return (
<BrowserRouter>
<div className="min-h-screen flex flex-col">
<header className="sticky top-0 z-50 bg-white/80 backdrop-blur border-b border-gray-200">
<div className="max-w-5xl mx-auto px-5 h-[52px] flex items-center justify-between">
<Link
to="/"
className="text-xl font-bold text-gray-900 no-underline tracking-tight"
>
iAs
</Link>
<nav className="flex gap-1">
<Link
to="/updates"
className="px-3.5 py-1.5 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition"
>
</Link>
<Link
to="/mail"
className="px-3.5 py-1.5 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition"
>
</Link>
<Link
to="/tools"
className="px-3.5 py-1.5 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition"
>
</Link>
<Link
to="/configs"
className="px-3.5 py-1.5 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition"
>
</Link>
<Link
to="/context"
className="px-3.5 py-1.5 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition"
>
</Link>
</nav>
</div>
</header>
<main className="flex-1 max-w-5xl mx-auto px-5 py-6 pb-16 w-full">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/updates/*" element={<UpdatesPage />} />
<Route path="/mail/messages/:id" element={<MailDetailPage />} />
<Route path="/mail/:slug" element={<MailWebPageDetailPage />} />
<Route path="/md/:slug" element={<MarkdownWebPageDetailPage />} />
<Route path="/mail" element={<MailPage />} />
<Route path="/web/:pageType/:slug" element={<GenericWebPageDetailPage />} />
<Route path="/tools" element={<ToolsPage />} />
<Route path="/configs" element={<ConfigsPage />} />
<Route path="/context" element={<ContextSimulatorPage />} />
</Routes>
</main>
</div>
</BrowserRouter>
);
}
export default App;
+6
View File
@@ -0,0 +1,6 @@
@import "tailwindcss";
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC",
sans-serif;
}
+42
View File
@@ -0,0 +1,42 @@
const BASE_URL = "";
interface ApiResponse {
success: boolean;
message?: string;
}
export async function apiGet<T = ApiResponse>(
path: string,
params?: Record<string, string | number | boolean | undefined>,
): Promise<T> {
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
});
}
const res = await fetch(url.toString());
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error((body as ApiResponse).message || `HTTP ${res.status}`);
}
return res.json();
}
export async function apiPost<T = ApiResponse>(
path: string,
body?: unknown,
): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const errBody = await res.json().catch(() => ({}));
throw new Error((errBody as ApiResponse).message || `HTTP ${res.status}`);
}
return res.json();
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+213
View File
@@ -0,0 +1,213 @@
import { useEffect, useState } from "react";
import { apiGet } from "../lib/api";
interface WechatConfig {
id: number;
user_name?: string;
account_id?: string;
base_url?: string;
user_id?: string;
user_status?: number;
token_configured: boolean;
updates_buf_present: boolean;
created_at?: string;
}
interface MailConfig {
id: number;
name: string;
source_key: string;
imap_host: string;
imap_port: number;
imap_username: string;
mailbox: string;
notify_account_id: string;
notify_user_id: string;
poll_seconds: number;
fetch_limit: number;
accept_invalid_certs: boolean;
enabled: boolean;
password_configured: boolean;
last_checked_at?: string;
last_error?: string;
created_at: string;
updated_at: string;
}
interface ConfigsResponse {
success: boolean;
wechat_total: number;
mail_total: number;
wechat_accounts: WechatConfig[];
mail_accounts: MailConfig[];
}
export function ConfigsPage() {
const [data, setData] = useState<ConfigsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
apiGet<ConfigsResponse>("/v1/user-configs")
.then(setData)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="text-center py-16 text-gray-400">
<div className="animate-pulse">...</div>
</div>
);
}
if (error) {
return (
<div className="text-center py-16 text-red-500">
<p>{error}</p>
</div>
);
}
if (!data) return null;
const total = data.wechat_total + data.mail_total;
const fmtTime = (t?: string) => {
if (!t) return "-";
return new Date(t).toLocaleString("zh-CN", { timeZone: "UTC" }) + " UTC";
};
return (
<div>
<div className="mb-7">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight"></h1>
<p className="mt-1.5 text-sm text-gray-500">
{total}
</p>
</div>
<section className="mb-8">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold"></h2>
<span className="text-sm text-gray-400">{data.wechat_accounts.length} </span>
</div>
<div className="flex flex-col gap-3">
{data.wechat_accounts.length === 0 ? (
<div className="bg-white rounded-lg border border-gray-200 p-5 text-sm text-gray-400">
</div>
) : (
data.wechat_accounts.map((item) => (
<article
key={item.id}
className="bg-white rounded-lg border border-gray-200 p-4 md:p-5"
>
<div className="flex items-start justify-between gap-3 mb-3">
<div className="min-w-0">
<h3 className="text-base font-semibold text-gray-900 break-words">
{item.user_name || "未命名微信账号"}
</h3>
<p className="mt-1 text-sm text-gray-500 break-all">
{item.account_id || "-"}
</p>
</div>
<span className="shrink-0 inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold bg-green-50 text-green-700">
</span>
</div>
<dl className="grid grid-cols-1 md:grid-cols-[120px,1fr] gap-x-4 gap-y-2 text-sm">
<dt className="text-gray-400"> ID</dt>
<dd className="text-gray-700 break-all">{item.account_id || "-"}</dd>
<dt className="text-gray-400"> ID</dt>
<dd className="text-gray-700 break-all">{item.user_id || "-"}</dd>
<dt className="text-gray-400"></dt>
<dd className="text-gray-700 break-all">{item.base_url || "-"}</dd>
<dt className="text-gray-400"></dt>
<dd className="text-gray-700">{item.user_status ?? "-"}</dd>
<dt className="text-gray-400">Token</dt>
<dd className="text-gray-700">{item.token_configured ? "已配置" : "未配置"}</dd>
<dt className="text-gray-400"></dt>
<dd className="text-gray-700">{item.updates_buf_present ? "已配置" : "未配置"}</dd>
<dt className="text-gray-400"></dt>
<dd className="text-gray-700">{fmtTime(item.created_at)}</dd>
</dl>
</article>
))
)}
</div>
</section>
<section className="mb-8">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold"></h2>
<span className="text-sm text-gray-400">{data.mail_accounts.length} </span>
</div>
<div className="flex flex-col gap-3">
{data.mail_accounts.length === 0 ? (
<div className="bg-white rounded-lg border border-gray-200 p-5 text-sm text-gray-400">
</div>
) : (
data.mail_accounts.map((item) => (
<article
key={item.id}
className="bg-white rounded-lg border border-gray-200 p-4 md:p-5"
>
<div className="flex items-start justify-between gap-3 mb-3">
<div className="min-w-0">
<h3 className="text-base font-semibold text-gray-900 break-words">
{item.name}
</h3>
<p className="mt-1 text-sm text-gray-500 break-all">
{item.imap_username} @ {item.imap_host}:{item.imap_port}
</p>
</div>
{item.enabled ? (
<span className="shrink-0 inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold bg-green-50 text-green-700">
</span>
) : (
<span className="shrink-0 inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold bg-gray-100 text-gray-500">
</span>
)}
</div>
<dl className="grid grid-cols-1 md:grid-cols-[120px,1fr] gap-x-4 gap-y-2 text-sm">
<dt className="text-gray-400"> ID</dt>
<dd className="text-gray-700">#{item.id}</dd>
<dt className="text-gray-400"></dt>
<dd className="text-gray-700 break-all">{item.source_key}</dd>
<dt className="text-gray-400"></dt>
<dd className="text-gray-700">{item.mailbox}</dd>
<dt className="text-gray-400"></dt>
<dd className="text-gray-700 break-all">{item.notify_account_id}</dd>
<dt className="text-gray-400"></dt>
<dd className="text-gray-700 break-all">{item.notify_user_id}</dd>
<dt className="text-gray-400"></dt>
<dd className="text-gray-700">{item.poll_seconds}s</dd>
<dt className="text-gray-400"></dt>
<dd className="text-gray-700">{item.fetch_limit}</dd>
<dt className="text-gray-400"></dt>
<dd className="text-gray-700">{item.accept_invalid_certs ? "是" : "否"}</dd>
<dt className="text-gray-400"></dt>
<dd className="text-gray-700">{item.password_configured ? "已配置" : "未配置"}</dd>
<dt className="text-gray-400"></dt>
<dd className="text-gray-700">{fmtTime(item.last_checked_at)}</dd>
<dt className="text-gray-400"></dt>
<dd className="text-gray-700">{fmtTime(item.updated_at)}</dd>
</dl>
{item.last_error && (
<div className="mt-3 text-sm text-red-600 bg-red-50 border border-red-100 rounded-md px-3 py-2 break-words">
{item.last_error}
</div>
)}
</article>
))
)}
</div>
</section>
</div>
);
}
+442
View File
@@ -0,0 +1,442 @@
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { apiGet } from "../lib/api";
interface ToolParam {
name: string;
param_type: string;
required: boolean;
description: string;
}
interface Tool {
name: string;
description: string;
source: string;
path?: string;
params: ToolParam[];
}
interface SessionRow {
id: number;
started_at: string;
last_message_at: string;
ended_at?: string;
end_reason?: string;
summary?: string;
}
interface MessageRow {
id: number;
role: string;
content: string;
tool_call_id?: string;
tool_calls?: string;
created_at: string;
}
interface MemoryRow {
id: number;
content: string;
metadata: string;
created_at: string;
updated_at: string;
deleted_at?: string;
}
interface SummaryRow {
id: number;
summary_type: string;
content: string;
started_at?: string;
ended_at?: string;
created_at: string;
}
interface SimulatorResponse {
success: boolean;
has_query: boolean;
scope_key: string;
tools: Tool[];
default_messages: { role: string; content: string }[];
default_system_prompt: string;
sessions: SessionRow[];
messages: MessageRow[];
memories: MemoryRow[];
summaries: SummaryRow[];
}
function toolToJson(tool: Tool): object {
return {
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: {
type: "object",
properties: Object.fromEntries(
tool.params.map((p) => [p.name, { type: p.param_type, description: p.description }])
),
required: tool.params.filter((p) => p.required).map((p) => p.name),
},
},
};
}
export function ContextSimulatorPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [data, setData] = useState<SimulatorResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const channel = searchParams.get("channel") || "wechat";
const accountId = searchParams.get("account_id") || "";
const fromUserId = searchParams.get("from_user_id") || "";
// 编辑器状态
const [messages, setMessages] = useState<{ role: string; content: string }[]>([]);
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
const loadData = (values: {
channel: string;
accountId: string;
fromUserId: string;
}) => {
setLoading(true);
setError(null);
const params: Record<string, string> = {};
if (values.channel) params.channel = values.channel;
if (values.accountId) params.account_id = values.accountId;
if (values.fromUserId) params.from_user_id = values.fromUserId;
apiGet<SimulatorResponse>("/v1/context/simulator", params)
.then((res) => {
setData(res);
setMessages(res.default_messages);
setSelectedTools(new Set());
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
};
useEffect(() => {
loadData({ channel, accountId, fromUserId });
}, [channel, accountId, fromUserId]);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
const params = new URLSearchParams();
const c = (form.get("channel") as string) || "";
const a = (form.get("account_id") as string) || "";
const f = (form.get("from_user_id") as string) || "";
if (c) params.set("channel", c);
if (a) params.set("account_id", a);
if (f) params.set("from_user_id", f);
setSearchParams(params);
};
const handleReset = () => {
if (data) {
setMessages(data.default_messages);
setSelectedTools(new Set());
}
};
const toggleTool = (name: string) => {
setSelectedTools((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
};
const selectAllTools = () => {
if (data) setSelectedTools(new Set(data.tools.map((t) => t.name)));
};
const deselectAllTools = () => {
setSelectedTools(new Set());
};
const buildRequestPreview = () => {
const tools = (data?.tools || [])
.filter((t) => selectedTools.has(t.name))
.map(toolToJson);
return JSON.stringify(
{
model: "deepseek-v4-flash",
messages,
tools,
stream: false,
extra_body: { thinking: { type: "enabled" } },
},
null,
2
);
};
const requestJson = buildRequestPreview();
const totalChars = requestJson.length;
if (loading) {
return (
<div className="text-center py-16 text-gray-400">
<div className="animate-pulse">...</div>
</div>
);
}
if (error) {
return (
<div className="text-center py-16 text-red-500">
<p>{error}</p>
</div>
);
}
return (
<div>
<div className="mb-5">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight"></h1>
<p className="mt-1.5 text-sm text-gray-500">
LLM messages tools
</p>
</div>
{/* 查询表单 */}
<form
onSubmit={handleSubmit}
className="bg-white rounded-lg border border-gray-200 p-4 mb-5"
>
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs font-medium text-gray-500 mb-0.5">channel</label>
<input
name="channel"
defaultValue={channel}
placeholder="wechat"
className="w-28 border border-gray-300 rounded px-2.5 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-0.5">account_id</label>
<input
name="account_id"
defaultValue={accountId}
placeholder="account"
className="w-32 border border-gray-300 rounded px-2.5 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-0.5">from_user_id</label>
<input
name="from_user_id"
defaultValue={fromUserId}
placeholder="user"
className="w-32 border border-gray-300 rounded px-2.5 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
/>
</div>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm font-medium hover:bg-blue-700 transition"
>
</button>
<button
type="button"
onClick={handleReset}
className="bg-gray-100 text-gray-700 px-4 py-1.5 rounded text-sm font-medium hover:bg-gray-200 transition border border-gray-300"
>
</button>
</div>
</form>
{/* 数据概览 */}
{data?.has_query && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-5 text-sm">
<div className="font-medium text-green-800 mb-1"></div>
<div className="text-green-700 flex flex-wrap gap-x-4 gap-y-1">
<span>
scope_key:{" "}
<code className="text-xs bg-green-100 px-1 rounded">{data.scope_key}</code>
</span>
<span> {data.sessions.length} </span>
<span> {data.messages.length} </span>
<span> {data.memories.length} </span>
<span> {data.summaries.length} </span>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* 左栏:工具选择 + 消息编辑 */}
<div className="space-y-5">
{/* 工具选择 */}
<div className="bg-white rounded-lg border border-gray-200">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<h2 className="text-sm font-semibold text-gray-800">
{" "}
<span className="text-gray-400 font-normal"> tools </span>
</h2>
<div className="flex gap-2">
<button
type="button"
onClick={selectAllTools}
className="text-xs text-blue-600 hover:text-blue-800"
>
</button>
<button
type="button"
onClick={deselectAllTools}
className="text-xs text-gray-400 hover:text-gray-600"
>
</button>
</div>
</div>
<div className="p-3 max-h-80 overflow-y-auto space-y-1">
{(data?.tools || []).map((tool) => (
<label
key={tool.name}
className="flex items-start gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer border border-transparent hover:border-gray-200 transition"
>
<input
type="checkbox"
className="mt-0.5 shrink-0"
checked={selectedTools.has(tool.name)}
onChange={() => toggleTool(tool.name)}
/>
<div className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-sm font-medium text-gray-800">{tool.name}</span>
<span
className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
tool.source === "内置"
? "bg-blue-100 text-blue-700"
: "bg-orange-100 text-orange-700"
}`}
>
{tool.source}
</span>
</div>
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{tool.description}</p>
<div className="flex flex-wrap gap-1 mt-1">
{tool.params.map((p) => (
<span key={p.name} className="text-[11px] text-gray-500">
{p.required && <span className="text-red-400">*</span>}
{p.name}:{p.param_type}
</span>
))}
</div>
</div>
</label>
))}
</div>
<div className="px-4 py-2 border-t border-gray-100 text-xs text-gray-400">
{selectedTools.size} / {data?.tools.length || 0}
</div>
</div>
{/* 消息编辑 */}
<div className="bg-white rounded-lg border border-gray-200">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<h2 className="text-sm font-semibold text-gray-800">Messages </h2>
<button
type="button"
onClick={() => setMessages([...messages, { role: "user", content: "" }])}
className="text-xs text-blue-600 hover:text-blue-800"
>
+
</button>
</div>
<div className="p-3 space-y-2 max-h-96 overflow-y-auto">
{messages.map((msg, i) => {
const roleColor =
msg.role === "system"
? "border-blue-300 bg-blue-50"
: msg.role === "user"
? "border-green-300 bg-green-50"
: msg.role === "assistant"
? "border-purple-300 bg-purple-50"
: "border-orange-300 bg-orange-50";
return (
<div key={i} className={`border rounded-lg p-3 ${roleColor}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<select
className="text-xs border rounded px-1.5 py-0.5 bg-white"
value={msg.role}
onChange={(e) => {
const next = [...messages];
next[i] = { ...next[i], role: e.target.value };
setMessages(next);
}}
>
<option value="system">system</option>
<option value="user">user</option>
<option value="assistant">assistant</option>
<option value="tool">tool</option>
</select>
<span className="text-xs text-gray-500">#{i + 1}</span>
</div>
<button
onClick={() => {
const next = [...messages];
next.splice(i, 1);
setMessages(next);
}}
className="text-xs text-red-400 hover:text-red-600"
>
</button>
</div>
<textarea
className="w-full border rounded px-2 py-1.5 text-sm font-mono bg-white resize-y"
rows={3}
value={msg.content}
onChange={(e) => {
const next = [...messages];
next[i] = { ...next[i], content: e.target.value };
setMessages(next);
}}
/>
</div>
);
})}
{messages.length === 0 && (
<div className="text-sm text-gray-400 text-center py-4">
+
</div>
)}
</div>
<div className="px-4 py-2 border-t border-gray-100 text-xs text-gray-400">
{messages.length} {totalChars}
</div>
</div>
</div>
{/* 右栏:预览 */}
<div className="space-y-5">
<div className="bg-white rounded-lg border border-gray-200">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<h2 className="text-sm font-semibold text-gray-800"> LLM </h2>
<span className="text-xs text-gray-400">POST /v1/chat/completions</span>
</div>
<div className="p-4">
<pre className="text-xs leading-relaxed whitespace-pre-wrap break-all font-mono bg-gray-900 text-green-400 rounded-lg p-4 max-h-[600px] overflow-auto">
{requestJson}
</pre>
</div>
<div className="px-4 py-2 border-t border-gray-100 text-xs text-gray-400">
token: {Math.ceil(totalChars / 2)} 1 token 2
</div>
</div>
</div>
</div>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { Link } from "react-router-dom";
export function HomePage() {
return (
<div className="text-center py-16">
<h1 className="text-3xl font-bold tracking-tight mb-3">iAs</h1>
<p className="text-gray-500 mb-8"></p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 max-w-md mx-auto">
<Link
to="/updates"
className="bg-white rounded-xl border border-gray-200 p-4 hover:border-blue-400 hover:shadow-md transition text-center"
>
<div className="text-2xl mb-1">📋</div>
<div className="text-sm font-medium text-gray-700"></div>
</Link>
<Link
to="/mail"
className="bg-white rounded-xl border border-gray-200 p-4 hover:border-blue-400 hover:shadow-md transition text-center"
>
<div className="text-2xl mb-1">📧</div>
<div className="text-sm font-medium text-gray-700"></div>
</Link>
<Link
to="/tools"
className="bg-white rounded-xl border border-gray-200 p-4 hover:border-blue-400 hover:shadow-md transition text-center"
>
<div className="text-2xl mb-1">🔧</div>
<div className="text-sm font-medium text-gray-700"></div>
</Link>
<Link
to="/configs"
className="bg-white rounded-xl border border-gray-200 p-4 hover:border-blue-400 hover:shadow-md transition text-center"
>
<div className="text-2xl mb-1"></div>
<div className="text-sm font-medium text-gray-700"></div>
</Link>
<Link
to="/context"
className="bg-white rounded-xl border border-gray-200 p-4 hover:border-blue-400 hover:shadow-md transition text-center"
>
<div className="text-2xl mb-1">🧠</div>
<div className="text-sm font-medium text-gray-700"></div>
</Link>
<a
href="/web/page/index"
className="bg-white rounded-xl border border-gray-200 p-4 hover:border-blue-400 hover:shadow-md transition text-center"
>
<div className="text-2xl mb-1">📄</div>
<div className="text-sm font-medium text-gray-700"></div>
</a>
</div>
</div>
);
}
+217
View File
@@ -0,0 +1,217 @@
import { useEffect, useState } from "react";
import { useParams, useSearchParams, Link } from "react-router-dom";
import { apiGet } from "../lib/api";
interface MailMessage {
id: number;
from_label: string;
subject: string;
content: string;
content_preview: string;
mailbox: string;
source_key: string;
seen: boolean;
received_at: string;
raw_size: number;
mail_account_id?: number;
message_id?: string;
uid: string;
raw_headers?: string;
preview_url?: string;
created_at: string;
}
interface MailDetailResponse {
success: boolean;
message: MailMessage;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
function contentIsHtml(content: string): boolean {
const lower = content.trim().slice(0, 1024).toLowerCase();
return (
lower.includes("<html") ||
lower.includes("<!doctype") ||
lower.includes("<body") ||
lower.includes("<div") ||
lower.includes("<table") ||
lower.includes("<meta") ||
lower.includes("<head") ||
lower.includes("<a href") ||
lower.includes("<p ") ||
lower.includes("<br") ||
lower.includes("<img") ||
lower.includes("<span")
);
}
function sandboxedEmailHtml(content: string): string {
const parsed = new DOMParser().parseFromString(content, "text/html");
const styles = Array.from(parsed.head.querySelectorAll("style"))
.map((style) => style.textContent || "")
.filter(Boolean)
.join("\n");
const body = parsed.body.innerHTML.trim() || content;
const styleTag = styles ? `<style>${styles}</style>` : "";
return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data: cid: https: http:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">${styleTag}</head><body>${body}</body></html>`;
}
function renderTextContent(content: string) {
return content.split("\n").map((line, i) =>
line.trim() === "" ? (
<br key={i} />
) : (
<p key={i} className="mb-2 last:mb-0">
{line}
</p>
)
);
}
export function MailDetailPage() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const [data, setData] = useState<MailDetailResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
setLoading(true);
setError(null);
apiGet<MailDetailResponse>(`/v1/mail/messages/${id}`)
.then(setData)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
if (loading) {
return (
<div className="text-center py-16 text-gray-400">
<div className="animate-pulse">...</div>
</div>
);
}
if (error || !data) {
return (
<div className="text-center py-16">
<h1 className="text-2xl font-bold"></h1>
<p className="mt-2 text-gray-500">{error || "没有找到对应的邮件记录"}</p>
<Link to="/mail" className="mt-4 inline-block text-blue-600 hover:text-blue-800">
</Link>
</div>
);
}
const msg = data.message;
const backParams = searchParams.toString();
const backUrl = `/mail${backParams ? `?${backParams}` : ""}`;
const subject = msg.subject || "无主题";
const received = msg.received_at
? new Date(msg.received_at).toLocaleString("zh-CN", { timeZone: "UTC" }) + " UTC"
: "-";
const htmlContent = contentIsHtml(msg.content);
return (
<div>
<div className="flex items-center justify-between flex-wrap gap-2 mb-5">
<Link
to={backUrl}
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 transition"
>
</Link>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 md:p-6 mb-4 shadow-sm">
<h1 className="text-lg md:text-xl font-bold leading-snug break-words mb-3">{subject}</h1>
<div className="flex flex-wrap gap-x-4 gap-y-1.5 text-sm text-gray-500">
<span className="inline-flex items-center gap-1">
<span className="font-semibold text-gray-700"></span>
{msg.from_label}
</span>
<span className="inline-flex items-center gap-1">
<span className="font-semibold text-gray-700"></span>
{received}
</span>
<span className="inline-flex items-center gap-1">
<span className="font-semibold text-gray-700"></span>
{msg.source_key}
</span>
</div>
</div>
<details className="bg-white rounded-xl border border-gray-200 overflow-hidden mb-4">
<summary className="px-4 md:px-5 py-3 cursor-pointer text-sm font-semibold text-blue-600 hover:bg-gray-50 transition select-none">
</summary>
<dl className="grid grid-cols-1 md:grid-cols-[auto,1fr] gap-x-5 gap-y-2.5 p-4 md:p-5 pt-2 md:pt-3 border-t border-gray-100 text-sm">
<dt className="text-gray-400 md:text-right"></dt>
<dd className="text-gray-700 break-all">{msg.mailbox}</dd>
<dt className="text-gray-400 md:text-right"></dt>
<dd className="text-gray-700">{msg.seen ? "已读" : "未读"}</dd>
<dt className="text-gray-400 md:text-right"></dt>
<dd className="text-gray-700">
{msg.mail_account_id ? `账户 #${msg.mail_account_id}` : "环境配置"}
</dd>
<dt className="text-gray-400 md:text-right">UID</dt>
<dd className="text-gray-700 break-all">
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">{msg.uid}</code>
</dd>
<dt className="text-gray-400 md:text-right">Message-ID</dt>
<dd className="text-gray-700 break-all">
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
{msg.message_id?.slice(0, 72) || "-"}
</code>
</dd>
<dt className="text-gray-400 md:text-right"> ID</dt>
<dd className="text-gray-700">{msg.id}</dd>
<dt className="text-gray-400 md:text-right"></dt>
<dd className="text-gray-700">{formatBytes(msg.raw_size)}</dd>
<dt className="text-gray-400 md:text-right"></dt>
<dd className="text-gray-700">
{msg.created_at
? new Date(msg.created_at).toLocaleString("zh-CN", { timeZone: "UTC" }) + " UTC"
: "-"}
</dd>
</dl>
</details>
{htmlContent ? (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<iframe
sandbox=""
srcDoc={sandboxedEmailHtml(msg.content)}
className="w-full min-h-[420px] border-0 block bg-white"
loading="lazy"
title="邮件内容"
/>
</div>
) : (
<article className="bg-white rounded-xl border border-gray-200 p-4 md:p-6 text-sm md:text-base leading-relaxed whitespace-normal break-words">
{renderTextContent(msg.content)}
</article>
)}
{/* 原始头信息 */}
{msg.raw_headers && msg.raw_headers.trim() && (
<details className="bg-white rounded-xl border border-gray-200 overflow-hidden mb-4 mt-4">
<summary className="px-4 md:px-5 py-3 cursor-pointer text-sm font-semibold text-blue-600 hover:bg-gray-50 transition select-none">
</summary>
<pre className="m-0 px-4 md:px-5 py-4 text-xs leading-relaxed font-mono text-gray-700 whitespace-pre-wrap break-all border-t border-gray-100 max-h-80 overflow-y-auto">
{msg.raw_headers}
</pre>
</details>
)}
</div>
);
}
+284
View File
@@ -0,0 +1,284 @@
import { useEffect, useState } from "react";
import { useSearchParams, Link } from "react-router-dom";
import { apiGet } from "../lib/api";
interface MailMessage {
id: number;
from_label: string;
subject: string;
content_preview: string;
mailbox: string;
source_key: string;
seen: boolean;
received_at: string;
raw_size: number;
}
interface MailListResponse {
success: boolean;
page: number;
per_page: number;
total: number;
messages: MailMessage[];
}
export function MailPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [data, setData] = useState<MailListResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const page = Number(searchParams.get("page") || "1");
const perPage = Number(searchParams.get("per_page") || "20");
const q = searchParams.get("q") || "";
const mailbox = searchParams.get("mailbox") || "";
const sourceKey = searchParams.get("source_key") || "";
const seen = searchParams.get("seen") || "";
useEffect(() => {
setLoading(true);
setError(null);
const params: Record<string, string | number> = { page, per_page: perPage };
if (q) params.q = q;
if (mailbox) params.mailbox = mailbox;
if (sourceKey) params.source_key = sourceKey;
if (seen) params.seen = seen;
apiGet<MailListResponse>("/v1/mail/messages", params)
.then(setData)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [page, perPage, q, mailbox, sourceKey, seen]);
const updateParams = (updates: Record<string, string>) => {
const newParams = new URLSearchParams(searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value) {
newParams.set(key, value);
} else {
newParams.delete(key);
}
});
if (updates.q !== undefined || updates.mailbox !== undefined || updates.source_key !== undefined || updates.seen !== undefined) {
newParams.set("page", "1");
}
setSearchParams(newParams);
};
const hasFilter = q || mailbox || sourceKey || seen;
const totalPages = data ? Math.ceil(data.total / data.per_page) : 0;
const formatTime = (t: string) => {
if (!t) return "-";
const d = new Date(t);
return `${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
};
const relativeTime = (t: string) => {
if (!t) return "";
const delta = Date.now() - new Date(t).getTime();
const mins = Math.floor(delta / 60000);
if (mins < 1) return "刚刚";
if (mins < 60) return `${mins} 分钟前`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days} 天前`;
return "";
};
const truncate = (s: string, max: number) => {
if (!s) return "";
return s.length > max ? s.slice(0, max - 1) + "…" : s;
};
return (
<div>
<div className="mb-6">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">📧 </h1>
<p className="mt-1.5 text-sm text-gray-500">
{data
? hasFilter
? `筛选结果:共 ${data.total} 封,当前 ${(page - 1) * perPage + 1}${Math.min(page * perPage, data.total)}`
: `${data.total} 封邮件`
: "加载中..."}
</p>
</div>
{/* 筛选栏 */}
<form
onSubmit={(e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
updateParams({
q: (form.get("q") as string) || "",
mailbox: (form.get("mailbox") as string) || "",
source_key: (form.get("source_key") as string) || "",
seen: (form.get("seen") as string) || "",
});
}}
className="bg-white rounded-xl border border-gray-200 p-4 md:p-5 mb-5"
>
<div className="flex flex-col md:flex-row gap-3">
<div className="flex-1 min-w-0">
<input
name="q"
defaultValue={q}
placeholder="搜索发件人、主题或正文…"
className="w-full border border-gray-200 rounded-lg px-3.5 py-2.5 text-sm focus:outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition placeholder:text-gray-400"
/>
</div>
<div className="flex gap-2">
<input
name="mailbox"
defaultValue={mailbox}
placeholder="目录"
className="w-24 md:w-28 border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition placeholder:text-gray-400"
/>
<input
name="source_key"
defaultValue={sourceKey}
placeholder="来源"
className="w-24 md:w-28 border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition placeholder:text-gray-400"
/>
<select
name="seen"
defaultValue={seen}
className="w-24 md:w-28 border border-gray-200 rounded-lg px-3 py-2.5 text-sm bg-white focus:outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
>
<option value=""></option>
<option value="false"></option>
<option value="true"></option>
</select>
</div>
<input type="hidden" name="per_page" value={perPage} />
<div className="flex gap-2">
<button
type="submit"
className="inline-flex items-center justify-center px-5 py-2.5 rounded-lg text-sm font-semibold bg-blue-600 text-white hover:bg-blue-700 transition shadow-sm"
>
</button>
{hasFilter && (
<Link
to="/mail"
className="inline-flex items-center justify-center px-4 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-gray-600 hover:border-blue-300 hover:text-blue-600 transition"
>
</Link>
)}
</div>
</div>
</form>
{loading && (
<div className="text-center py-16 text-gray-400">
<div className="animate-pulse">...</div>
</div>
)}
{error && (
<div className="text-center py-16 text-red-500">
<p>{error}</p>
</div>
)}
{data && !loading && (
<>
<div className="flex flex-col gap-3 mb-4">
{data.messages.length === 0 ? (
<div className="text-center py-16 text-gray-400">
<div className="text-4xl mb-3">📭</div>
<p className="text-base"></p>
</div>
) : (
data.messages.map((msg) => (
<Link
key={msg.id}
to={`/mail/messages/${msg.id}?${searchParams.toString()}`}
className={`block bg-white rounded-xl border p-4 md:p-5 hover:border-blue-400 hover:shadow-md transition group ${
msg.seen ? "border-gray-200" : "border-blue-200 shadow-sm"
}`}
>
<div className="flex items-start justify-between gap-3 mb-2">
<h3 className="text-sm md:text-base font-semibold text-gray-900 group-hover:text-blue-700 leading-snug min-w-0 break-words">
{msg.subject || "无主题"}
</h3>
<div className="shrink-0 flex items-center gap-2 pt-0.5">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium ${
msg.seen
? "bg-gray-100 text-gray-500"
: "bg-blue-50 text-blue-700 font-semibold"
}`}
>
{msg.seen ? "已读" : "未读"}
</span>
<span className="text-xs text-gray-400 whitespace-nowrap hidden sm:inline">
{formatTime(msg.received_at)}
</span>
</div>
</div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1.5 mb-2.5">
<span className="text-sm text-gray-600 truncate">{msg.from_label}</span>
<div className="flex items-center gap-1.5">
<span className="sm:hidden text-xs text-gray-400">{formatTime(msg.received_at)}</span>
<span className="text-xs text-gray-400">{relativeTime(msg.received_at)}</span>
</div>
</div>
{msg.content_preview && (
<p className="text-xs text-gray-400 line-clamp-2 leading-relaxed">
{truncate(msg.content_preview, 120)}
</p>
)}
<div className="flex items-center gap-2 mt-2.5">
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium bg-blue-50 text-blue-600">
#{msg.id}
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium bg-gray-100 text-gray-500">
{msg.mailbox}
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium bg-gray-100 text-gray-500">
{msg.source_key}
</span>
</div>
</Link>
))
)}
</div>
{/* 分页 */}
{totalPages > 1 && (
<nav className="flex items-center justify-between gap-3 mt-5">
{page > 1 ? (
<Link
to={`/mail?${new URLSearchParams({ ...Object.fromEntries(searchParams), page: String(page - 1) })}`}
className="inline-flex items-center gap-1 px-4 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-blue-600 hover:border-blue-400 hover:bg-blue-50 transition"
>
</Link>
) : (
<span className="inline-flex items-center gap-1 px-4 py-2 rounded-lg text-sm border border-gray-200 bg-white text-gray-300 cursor-default">
</span>
)}
<span className="text-sm text-gray-400">{page} / {totalPages}</span>
{page < totalPages ? (
<Link
to={`/mail?${new URLSearchParams({ ...Object.fromEntries(searchParams), page: String(page + 1) })}`}
className="inline-flex items-center gap-1 px-4 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-blue-600 hover:border-blue-400 hover:bg-blue-50 transition"
>
</Link>
) : (
<span className="inline-flex items-center gap-1 px-4 py-2 rounded-lg text-sm border border-gray-200 bg-white text-gray-300 cursor-default">
</span>
)}
</nav>
)}
</>
)}
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { useEffect, useState } from "react";
import { apiGet } from "../lib/api";
interface ToolParam {
name: string;
param_type: string;
required: boolean;
description: string;
}
interface ToolItem {
name: string;
description: string;
source: string;
path?: string;
params: ToolParam[];
required_env: string[];
}
interface ToolsResponse {
success: boolean;
total: number;
tools_path: string;
builtin_tools: ToolItem[];
capability_tools: ToolItem[];
warnings: string[];
}
export function ToolsPage() {
const [data, setData] = useState<ToolsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
apiGet<ToolsResponse>("/v1/tools")
.then(setData)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="text-center py-16 text-gray-400">
<div className="animate-pulse">...</div>
</div>
);
}
if (error) {
return (
<div className="text-center py-16 text-red-500">
<p>{error}</p>
</div>
);
}
if (!data) return null;
const renderToolCard = (item: ToolItem) => (
<article
key={item.name}
className="bg-white rounded-lg border border-gray-200 p-4 md:p-5"
>
<div className="flex items-start justify-between gap-3 mb-2">
<div className="min-w-0">
<h3 className="text-base font-semibold text-gray-900 break-words">
{item.name}
</h3>
<p className="mt-1 text-sm text-gray-500 leading-relaxed">
{item.description}
</p>
</div>
<span className="shrink-0 inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold bg-blue-50 text-blue-700">
{item.source}
</span>
</div>
{item.path && (
<div className="flex flex-wrap gap-2 mb-3">
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-gray-100 text-gray-500">
{item.path}
</span>
</div>
)}
{item.params.length > 0 ? (
<div className="mt-3">
{item.params.map((param) => (
<div
key={param.name}
className="py-1.5 border-t border-gray-100 first:border-t-0"
>
<div className="flex flex-wrap items-center gap-2">
<code className="text-xs bg-gray-100 text-gray-700 px-1.5 py-0.5 rounded">
{param.name}
</code>
<span className="text-xs text-gray-400">{param.param_type}</span>
{param.required ? (
<span className="text-[11px] font-medium text-red-600 bg-red-50 px-1.5 py-0.5 rounded">
</span>
) : (
<span className="text-[11px] font-medium text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded">
</span>
)}
</div>
<p className="mt-1 text-sm text-gray-500">{param.description}</p>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-400"></p>
)}
{item.required_env.length > 0 && (
<div className="mt-3 text-xs text-gray-500">
{" "}
{item.required_env.map((env) => (
<code
key={env}
className="bg-gray-100 px-1.5 py-0.5 rounded mr-1"
>
{env}
</code>
))}
</div>
)}
</article>
);
return (
<div>
<div className="mb-7">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight"></h1>
<p className="mt-1.5 text-sm text-gray-500">
{data.total} YAML
<code className="text-xs bg-gray-200 text-gray-700 px-1.5 py-0.5 rounded">
{data.tools_path}
</code>
</p>
</div>
{data.warnings.length > 0 && (
<section className="bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-lg p-4 mb-5 text-sm">
<div className="font-semibold mb-1"></div>
<ul>
{data.warnings.map((w, i) => (
<li key={i} className="ml-4 list-disc">
{w}
</li>
))}
</ul>
</section>
)}
<section className="mb-8">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold"></h2>
<span className="text-sm text-gray-400">{data.builtin_tools.length} </span>
</div>
<div className="flex flex-col gap-3">
{data.builtin_tools.length === 0 ? (
<div className="bg-white rounded-lg border border-gray-200 p-5 text-sm text-gray-400">
</div>
) : (
data.builtin_tools.map(renderToolCard)
)}
</div>
</section>
<section className="mb-8">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">YAML </h2>
<span className="text-sm text-gray-400">{data.capability_tools.length} </span>
</div>
<div className="flex flex-col gap-3">
{data.capability_tools.length === 0 ? (
<div className="bg-white rounded-lg border border-gray-200 p-5 text-sm text-gray-400">
</div>
) : (
data.capability_tools.map(renderToolCard)
)}
</div>
</section>
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
import { useEffect, useState } from "react";
import { useParams, Link } from "react-router-dom";
import { apiGet } from "../lib/api";
interface UpgradeLog {
version: string;
released_at: string;
title: string;
description: string;
changes: string[];
}
export function UpdatesPage() {
const { "*": splat } = useParams();
const version = splat || undefined;
if (version) {
return <UpdateDetail version={version} />;
}
return <UpdateList />;
}
function UpdateList() {
const [logs, setLogs] = useState<UpgradeLog[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
apiGet<{ success: boolean; logs: UpgradeLog[] }>("/v1/updates")
.then((data) => setLogs(data.logs || []))
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="text-center py-16 text-gray-400">
<div className="animate-pulse">...</div>
</div>
);
}
if (error) {
return (
<div className="text-center py-16 text-red-500">
<p>{error}</p>
</div>
);
}
return (
<div>
<div className="mb-6">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
</h1>
<p className="mt-1.5 text-sm text-gray-500"> {logs.length} </p>
</div>
<div className="flex flex-col gap-3">
{logs.length === 0 ? (
<div className="text-center py-16 text-gray-400">
<div className="text-4xl mb-3">📭</div>
<p className="text-base"></p>
</div>
) : (
logs.map((log) => (
<Link
key={log.version}
to={`/updates/${log.version}`}
className="block bg-white rounded-xl border border-gray-200 p-4 md:p-5 hover:border-blue-400 hover:shadow-md transition"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-base font-semibold text-gray-900">
v{log.version} {log.title}
</h3>
<p className="mt-1 text-sm text-gray-500 leading-relaxed">
{log.description}
</p>
</div>
<span className="shrink-0 text-xs text-gray-400">
{log.released_at}
</span>
</div>
</Link>
))
)}
</div>
</div>
);
}
function UpdateDetail({ version }: { version: string }) {
const [log, setLog] = useState<UpgradeLog | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
apiGet<{ success: boolean; log: UpgradeLog }>(`/v1/updates/${version}`)
.then((data) => setLog(data.log))
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [version]);
if (loading) {
return (
<div className="text-center py-16 text-gray-400">
<div className="animate-pulse">...</div>
</div>
);
}
if (error || !log) {
return (
<div className="text-center py-16">
<h1 className="text-2xl font-bold"></h1>
<p className="mt-2 text-gray-500">
{error || "没有找到对应版本的更新日志"}
</p>
<Link
to="/updates"
className="mt-4 inline-block text-blue-600 hover:text-blue-800"
>
</Link>
</div>
);
}
return (
<div>
<div className="mb-5">
<Link
to="/updates"
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 transition"
>
</Link>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 md:p-6 mb-4 shadow-sm">
<h1 className="text-lg md:text-xl font-bold leading-snug mb-2">
v{log.version} {log.title}
</h1>
<p className="text-sm text-gray-500">{log.released_at}</p>
<p className="mt-3 text-gray-700">{log.description}</p>
{log.changes && log.changes.length > 0 && (
<ul className="mt-4 space-y-1.5">
{log.changes.map((change, i) => (
<li key={i} className="text-sm text-gray-600 flex items-start gap-2">
<span className="text-blue-500 mt-0.5"></span>
<span>{change}</span>
</li>
))}
</ul>
)}
</div>
</div>
);
}
+313
View File
@@ -0,0 +1,313 @@
import { useEffect, useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import { Link, useParams } from "react-router-dom";
import remarkGfm from "remark-gfm";
import { apiGet } from "../lib/api";
interface WebPage {
id: number;
slug: string;
page_type: string;
title: string;
summary?: string | null;
content: string;
source_label?: string | null;
metadata: string;
created_at?: string | null;
}
interface WebPageResponse {
success: boolean;
page: WebPage;
}
interface MetadataItem {
key: string;
label: string;
value: string;
}
const metadataLabels: Record<string, string> = {
from: "发件人",
subject: "主题",
message_id: "邮件 ID",
mailbox: "邮箱目录",
uid: "UID",
reminder: "提醒",
received_at: "收件时间",
};
export function GenericWebPageDetailPage() {
const { pageType, slug } = useParams<{ pageType: string; slug: string }>();
if (!pageType || !slug) {
return <PageError message="页面地址不完整" />;
}
return (
<WebPageDetail
endpoint={`/v1/web/pages/${encodeURIComponent(pageType)}/${encodeURIComponent(slug)}`}
backUrl="/"
/>
);
}
export function MailWebPageDetailPage() {
const { slug } = useParams<{ slug: string }>();
if (!slug) {
return <PageError message="邮件页面地址不完整" />;
}
return (
<WebPageDetail
endpoint={`/v1/web/mail/${encodeURIComponent(slug)}`}
backUrl="/mail"
/>
);
}
export function MarkdownWebPageDetailPage() {
const { slug } = useParams<{ slug: string }>();
if (!slug) {
return <PageError message="Markdown 页面地址不完整" />;
}
return (
<WebPageDetail
endpoint={`/v1/web/pages/markdown/${encodeURIComponent(slug)}`}
backUrl="/"
/>
);
}
function WebPageDetail({ endpoint, backUrl }: { endpoint: string; backUrl: string }) {
const [page, setPage] = useState<WebPage | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
setError(null);
apiGet<WebPageResponse>(endpoint)
.then((data) => setPage(data.page))
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [endpoint]);
const metadata = useMemo(() => parseMetadata(page?.metadata), [page?.metadata]);
if (loading) {
return (
<div className="text-center py-16 text-gray-400">
<div className="animate-pulse">...</div>
</div>
);
}
if (error || !page) {
return <PageError message={error || "没有找到对应的内容页面"} />;
}
const contentFormat = pageContentFormat(page, metadata);
return (
<div>
<div className="mb-5">
<Link
to={backUrl}
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 transition"
>
</Link>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 md:p-6 mb-4 shadow-sm">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold bg-blue-50 text-blue-700 mb-3">
{page.page_type}
</span>
<h1 className="text-lg md:text-xl font-bold leading-snug break-words">
{page.title}
</h1>
{page.summary && (
<p className="mt-2 text-sm text-gray-500 leading-relaxed">{page.summary}</p>
)}
<div className="flex flex-wrap gap-x-5 gap-y-1 mt-3 text-sm text-gray-400">
{page.source_label && <span>{page.source_label}</span>}
{page.created_at && <span>{formatTime(page.created_at)}</span>}
</div>
</div>
{metadata.length > 0 && (
<section className="bg-white rounded-xl border border-gray-200 mb-4">
<dl className="grid grid-cols-1 md:grid-cols-[120px,1fr] gap-x-4 gap-y-2 p-4 text-sm">
{metadata.map((item) => (
<div key={item.key} className="contents">
<dt className="text-gray-400">{item.label}</dt>
<dd className="text-gray-700 break-all">{item.value}</dd>
</div>
))}
</dl>
</section>
)}
<article className="bg-white rounded-xl border border-gray-200 p-4 md:p-6 text-sm md:text-base leading-relaxed whitespace-normal break-words">
{contentFormat === "markdown" ? (
<MarkdownContent content={page.content} />
) : (
renderPlainText(page.content)
)}
</article>
</div>
);
}
function PageError({ message }: { message: string }) {
return (
<div className="text-center py-16">
<h1 className="text-2xl font-bold"></h1>
<p className="mt-2 text-gray-500">{message}</p>
<Link to="/" className="mt-4 inline-block text-blue-600 hover:text-blue-800">
</Link>
</div>
);
}
function parseMetadata(raw?: string | null): MetadataItem[] {
if (!raw) return [];
try {
const value = JSON.parse(raw) as unknown;
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
return Object.entries(value)
.map(([key, item]) => {
const text = metadataValueToText(item);
if (!text) return null;
return {
key,
label: metadataLabels[key] || key,
value: text,
};
})
.filter((item): item is MetadataItem => item !== null);
} catch {
return [];
}
}
function metadataValueToText(value: unknown): string | null {
if (value === null || value === undefined) return null;
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
if (Array.isArray(value) && value.length === 0) return null;
if (typeof value === "object" && Object.keys(value).length === 0) return null;
return JSON.stringify(value);
}
function renderPlainText(content: string) {
return content.split("\n").map((line, i) =>
line.trim() === "" ? (
<br key={i} />
) : (
<p key={i} className="mb-2 last:mb-0">
{line}
</p>
)
);
}
function MarkdownContent({ content }: { content: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noreferrer"
className="text-blue-600 hover:text-blue-800 underline underline-offset-2"
>
{children}
</a>
),
h1: ({ children }) => (
<h1 className="text-2xl font-bold mt-0 mb-4 leading-tight">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-xl font-bold mt-7 mb-3 leading-tight">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-lg font-semibold mt-6 mb-2 leading-tight">{children}</h3>
),
p: ({ children }) => <p className="my-3 leading-7">{children}</p>,
ul: ({ children }) => (
<ul className="my-3 list-disc pl-6 space-y-1">{children}</ul>
),
ol: ({ children }) => (
<ol className="my-3 list-decimal pl-6 space-y-1">{children}</ol>
),
blockquote: ({ children }) => (
<blockquote className="my-4 border-l-4 border-gray-300 pl-4 text-gray-600">
{children}
</blockquote>
),
code: ({ className, children }) => {
const isBlock = Boolean(className) || String(children).includes("\n");
return (
<code
className={
isBlock
? "font-mono text-gray-100"
: "rounded bg-gray-100 px-1.5 py-0.5 font-mono text-[0.92em] text-gray-900"
}
>
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="my-4 overflow-x-auto rounded-lg bg-gray-950 p-4 text-sm leading-6 text-gray-100">
{children}
</pre>
),
table: ({ children }) => (
<div className="my-4 overflow-x-auto">
<table className="w-full border-collapse text-sm">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border border-gray-200 bg-gray-50 px-3 py-2 text-left font-semibold">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-gray-200 px-3 py-2 align-top">{children}</td>
),
}}
>
{content}
</ReactMarkdown>
);
}
function pageContentFormat(page: WebPage, metadata: MetadataItem[]): "markdown" | "plain" {
if (page.page_type === "markdown" || page.page_type === "md") return "markdown";
const format = metadata
.find((item) => item.key === "content_format" || item.key === "format")
?.value.trim()
.toLowerCase();
return format === "markdown" || format === "md" ? "markdown" : "plain";
}
function formatTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { timeZone: "UTC" }) + " UTC";
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
build: {
outDir: "dist",
emptyOutDir: true,
},
server: {
proxy: {
"/v1": "http://127.0.0.1:9003",
},
},
});