feat: AgentMail —— 以邮件为统一范式的多智能体协作平台

Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是
「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制,
数据库默认内置 SQLite,systemd 托管。

核心设计
- 三维寻址 name@path.session,按最后一个 . 切分;session 位三态:
  省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达)
- 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的
  标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖
- 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构,
  再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载
- 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重,
  且路径与用户 filename 无关,杜绝 ../ 穿越
- 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与
  最终总结走免配额通道,靠上游消息 id 做幂等键而非计数
- 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过

后端 gateway/
- models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL)
  共用一份 repo 层 SQL,差异集中在 internal/db
- 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界)
- 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文
  只从客户端流向服务器一次
- 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、
  附件挂载),并发下不会刷穿

前端 web/
- 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件
- 全站纯 SVG 图标,不使用 emoji
- api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts

插件 plugins/opencode-mail-bridge/
- 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、
  session.idle 时转发本轮总结)
This commit is contained in:
2026-09-02 10:29:26 +08:00
commit 0e754617a4
95 changed files with 23219 additions and 0 deletions

12
web/index.html Normal file
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>AgentMail</title>
</head>
<body class="bg-gray-50 text-gray-900 antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4228
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
web/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "agentmail-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "node test/markdown-xss.test.mjs"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",
"zustand": "^4.5.4"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3",
"vite": "^5.3.4"
}
}

6
web/postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

165
web/src/App.tsx Normal file
View File

@ -0,0 +1,165 @@
import { useEffect } from 'react';
import { connectSSE } from './api/sse';
import { useAuthStore } from './stores/authStore';
import { useMailStore } from './stores/mailStore';
import { useSessionStore } from './stores/sessionStore';
import { useContactStore } from './stores/contactStore';
import { useUIStore } from './stores/uiStore';
import Sidebar from './components/Sidebar';
import MailList from './components/MailList';
import ContactPanel from './components/ContactPanel';
import MailView from './components/MailView';
import ComposePage from './components/ComposePage';
import LoginPage from './components/LoginPage';
import SetupPage from './components/SetupPage';
import AccountPage from './components/AccountPage';
import AdminUsersPage from './components/AdminUsersPage';
export default function App() {
const phase = useAuthStore(s => s.phase);
const bootstrap = useAuthStore(s => s.bootstrap);
const user = useAuthStore(s => s.user);
const viewMode = useUIStore(s => s.viewMode);
const composing = useUIStore(s => s.composing);
const resetUI = useUIStore(s => s.reset);
const fetchInbox = useMailStore(s => s.fetchInbox);
const fetchSent = useMailStore(s => s.fetchSent);
const dropMailSession = useMailStore(s => s.dropSession);
const fetchSessions = useSessionStore(s => s.fetchSessions);
const dropSessionIfCurrent = useSessionStore(s => s.dropSessionIfCurrent);
const refreshRenameProposal = useSessionStore(s => s.refreshRenameProposal);
const refreshBudget = useSessionStore(s => s.refreshBudget);
const fetchContacts = useContactStore(s => s.fetchContacts);
const removeSessionLocally = useContactStore(s => s.removeSessionLocally);
// 启动时检测初始化状态 / 登录态
useEffect(() => {
bootstrap();
}, []);
// 登出后清理客户端状态,避免脏数据残留
useEffect(() => {
if (phase === 'anonymous') {
resetUI();
}
}, [phase]);
// 登录态就绪后拉取数据 + SSE
useEffect(() => {
if (phase !== 'authenticated') return;
fetchInbox('all');
fetchSent();
fetchSessions();
fetchContacts();
return connectSSE((type, data) => {
switch (type) {
case 'new_mail':
fetchInbox('all');
fetchSessions();
fetchContacts();
// 新来信可能带改名建议Agent 发信也会消耗本任务的往返预算
refreshRenameProposal();
refreshBudget();
break;
case 'session_update':
// 别人(或另一个标签页)改了预算/别名
refreshBudget();
fetchInbox('all');
fetchSessions();
fetchContacts();
break;
case 'permission_decision':
fetchInbox('all');
fetchSessions();
fetchContacts();
break;
case 'session_archived': {
const id = typeof data.session_id === 'string' ? data.session_id : '';
if (!id) break;
removeSessionLocally(id);
dropMailSession(id);
dropSessionIfCurrent(id);
break;
}
}
});
}, [phase]);
// loading 阶段
if (phase === 'checking') {
return (
<div className="h-full flex items-center justify-center bg-slate-100 text-gray-400">
<p className="text-sm"></p>
</div>
);
}
// 未登录
if (phase === 'anonymous') {
return <AnonymousRoute onBootDone={bootstrap} />;
}
// 已登录主界面
return (
<div className="h-full flex bg-gray-50">
<Sidebar />
{viewMode === 'contacts' ? (
<ContactPanel />
) : viewMode === 'inbox' || viewMode === 'sent' ? (
<MailList />
) : null}
{composing ? (
<ComposePage />
) : viewMode === 'account' ? (
<AccountPage />
) : viewMode === 'admin' && user?.role === 'admin' ? (
<AdminUsersPage />
) : viewMode === 'inbox' || viewMode === 'sent' || viewMode === 'contacts' ? (
<MailView />
) : (
<MailView />
)}
</div>
);
}
/** 未登录时:先查 needs_setup决定展示初始化向导还是登录页 */
function AnonymousRoute({ onBootDone }: { onBootDone: () => void }) {
// bootstrap 已经在 App 里调过了,此处仅判断路由
// 若 bootstrap 已经把 phase 推到 anonymousneeds_setup 需独立查询
// 为简化,在 LoginPage 上方嵌套 SetupPage 的判断逻辑
return <LoginOrSetup onDone={onBootDone} />;
}
import { setupStatus } from './api/client';
function LoginOrSetup({ onDone }: { onDone: () => void }) {
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
useEffect(() => {
setupStatus()
.then(r => setNeedsSetup(r.needs_setup))
.catch(() => setNeedsSetup(false));
}, []);
if (needsSetup === null) {
return (
<div className="h-full flex items-center justify-center bg-slate-100 text-gray-400">
<p className="text-sm"></p>
</div>
);
}
if (needsSetup) {
return <SetupPage onDone={onDone} />;
}
return <LoginPage />;
}
import { useState } from 'react';

489
web/src/api/client.ts Normal file
View File

@ -0,0 +1,489 @@
import type { User } from '../types';
import type { Agent, Attachment, Contact, HumanSession, Mail, PermissionRequest, SuggestResult, SessionDetail, ThreadPage, RenameProposal, SessionBudget } from '../types';
import { API_BASE, authHeaders, withToken } from './config';
export { API_BASE, setToken, getToken, authHeaders, withToken } from './config';
const BASE = API_BASE;
export class ApiError extends Error {
status: number;
retryAfter?: number;
constructor(status: number, message: string, retryAfter?: number) {
super(message);
this.status = status;
this.retryAfter = retryAfter;
}
}
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: () => void) {
onUnauthorized = fn;
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const init: RequestInit = {
method,
// Cookie 模式需要 include带 Bearer 时多发一个 Cookie 也无害
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...authHeaders() }
};
if (body !== undefined) init.body = JSON.stringify(body);
const res = await fetch(`${BASE}${path}`, init);
if (!res.ok) {
const payload = await res.json().catch(() => ({ error: res.statusText }));
if (res.status === 401 && !path.startsWith('/auth/login') && !path.startsWith('/setup')) {
onUnauthorized?.();
}
throw new ApiError(res.status, payload.error || `HTTP ${res.status}`, payload.retry_after);
}
return res.json();
}
// ---------- 首次初始化 ----------
export async function setupStatus() {
return request<{ needs_setup: boolean }>('GET', '/setup/status');
}
export async function setupAdmin(payload: {
username: string;
password: string;
display_name?: string;
}) {
return request<{ user: User }>('POST', '/setup/admin', payload);
}
// ---------- 认证 ----------
export async function login(username: string, password: string) {
return request<{ user: User }>('POST', '/auth/login', { username, password });
}
export async function logout() {
return request<{ status: string }>('POST', '/auth/logout');
}
export async function me() {
return request<{ user: User }>('GET', '/auth/me');
}
export async function changePassword(oldPassword: string, newPassword: string) {
return request<{ status: string }>('POST', '/auth/password', {
old_password: oldPassword,
new_password: newPassword
});
}
// ---------- 管理员:用户管理 ----------
export async function adminListUsers() {
return request<{ users: User[] }>('GET', '/admin/users');
}
export async function adminCreateUser(payload: {
username: string;
password: string;
display_name?: string;
role?: string;
allowed_agents?: string[];
allowed_paths?: string[];
}) {
return request<{ user: User }>('POST', '/admin/users', payload);
}
export async function adminUpdateUser(
id: string,
payload: {
display_name?: string;
role?: string;
status?: string;
allowed_agents?: string[];
allowed_paths?: string[];
}
) {
return request<{ user: User }>('PUT', `/admin/users/${id}`, payload);
}
export async function adminDisableUser(id: string) {
return request<{ status: string }>('DELETE', `/admin/users/${id}`);
}
export async function adminResetPassword(id: string, newPassword: string) {
return request<{ status: string }>('POST', `/admin/users/${id}/reset`, {
new_password: newPassword
});
}
export async function adminListScopes() {
const r = await request<{ agents: string[]; paths: string[] }>('GET', '/admin/scopes');
return r;
}
// ---------- 密钥 ----------
export type KeyType = 'permanent' | 'one_time' | 'timed';
/** 密钥全文 key_token 仅在创建响应里出现一次,列表只给 token_hint。 */
export interface AgentKey {
key_id: string;
key_token?: string;
token_hint: string;
agent_name: string | null;
key_type: KeyType;
label: string;
expires_at: string | null;
used_at: string | null;
created_at: string;
}
export interface UserKey {
key_id: string;
key_token?: string;
token_hint: string;
label: string;
key_type: KeyType;
expires_at: string | null;
used_at: string | null;
created_at: string;
}
export interface CreateKeyPayload {
key_type: KeyType;
label?: string;
/** 仅 timed 需要 */
expires_hours?: number;
/** 仅 Agent 密钥:留空 = 待绑定,首次注册时落定 */
agent_name?: string;
/** 仅 Agent 密钥:登记客户端已在本地生成的密钥 */
key_token?: string;
}
export async function adminListAgentKeys(agentName?: string) {
const q = agentName ? `?agent_name=${encodeURIComponent(agentName)}` : '';
return request<{ keys: AgentKey[] }>('GET', `/admin/agent-keys${q}`);
}
export async function adminCreateAgentKey(payload: CreateKeyPayload) {
return request<{ key: AgentKey }>('POST', '/admin/agent-keys', payload);
}
export async function adminDeleteAgentKey(id: string) {
return request<{ status: string }>('DELETE', `/admin/agent-keys/${id}`);
}
export async function adminBindAgentKey(id: string, agentName: string) {
return request<{ status: string; agent_name: string }>(
'POST',
`/admin/agent-keys/${id}/bind`,
{ agent_name: agentName }
);
}
export async function listMyKeys() {
return request<{ keys: UserKey[] }>('GET', '/me/keys');
}
export async function createMyKey(payload: CreateKeyPayload) {
return request<{ key: UserKey }>('POST', '/me/keys', payload);
}
export async function deleteMyKey(id: string) {
return request<{ status: string }>('DELETE', `/me/keys/${id}`);
}
// ---------- Agents ----------
export async function listAgents(status?: string) {
const q = status ? `?status=${encodeURIComponent(status)}` : '';
return request<{ agents: Agent[] }>('GET', `/agents${q}`);
}
// ---------- 自己的邮箱 ----------
export interface SendMailOpts {
cc?: string;
reply_to?: string;
/** 仅在用 .new 新建会话时生效:给新会话命名,之后可用 name@path.<别名> 续谈 */
session_alias?: string;
/** 先用 uploadAttachment 上传取得的 id 列表 */
attachment_ids?: string[];
/**
* 本任务的往返预算0/省略 = 不限)。仅在新建会话时生效;
* 续谈已有会话请用 updateSessionBudget对话页里可随时改
*/
max_rounds?: number;
}
export async function sendMail(
to: string,
subject: string,
body: string,
opts: SendMailOpts = {}
) {
return request<{
mail_id: string;
session_id: string;
session_alias: string;
budget_max?: number;
budget_used?: number;
budget_remaining?: number;
}>('POST', '/me/mail/send', {
to,
subject,
body,
cc: opts.cc ?? '',
reply_to: opts.reply_to ?? '',
session_alias: opts.session_alias ?? '',
attachment_ids: opts.attachment_ids ?? [],
// null 而非 00 是「不限」的合法取值,省略才表示「不设置」
max_rounds: opts.max_rounds ?? null
});
}
export async function getInbox(status = 'all', limit = 50) {
return request<{ mails: Mail[]; total: number }>(
'GET',
`/me/mail/inbox?status=${encodeURIComponent(status)}&limit=${limit}`
);
}
export async function getSent() {
return request<{ mails: Mail[] }>('GET', '/me/mail/sent');
}
export async function getMail(id: string) {
return request<Mail>('GET', `/mail/${id}`);
}
export async function markMailRead(id: string) {
return request<{ status: string }>('POST', `/mail/${id}/read`);
}
/**
* 取线索的一块。
*
* dir=around 首屏(锚点 + 部分祖先 + 部分子孙up/down 配 offset 增量加载。
* 树可跨会话,服务端按会话逐个鉴权,看不到的节点不返回并计入 hidden。
*/
export async function getMailThread(
id: string,
opts: { dir?: 'around' | 'up' | 'down'; offset?: number; limit?: number } = {}
) {
const q = new URLSearchParams();
if (opts.dir) q.set('dir', opts.dir);
if (opts.offset !== undefined) q.set('offset', String(opts.offset));
if (opts.limit !== undefined) q.set('limit', String(opts.limit));
const qs = q.toString();
return request<ThreadPage>('GET', `/mail/${id}/thread${qs ? '?' + qs : ''}`);
}
// ---------- 附件 ----------
/**
* 上传附件,返回 attachment_id。
*
* 不能走 request():那里固定 Content-Type: application/json
* 而 multipart 必须让浏览器自己带 boundary。
*/
export async function uploadAttachment(file: File, onProgress?: (pct: number) => void) {
const form = new FormData();
form.append('file', file);
// 需要进度就用 XHR —— fetch 至今没有上传进度事件
if (onProgress) {
return new Promise<{ attachment: Attachment }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', `${BASE}/me/attachments`);
xhr.withCredentials = true;
for (const [k, v] of Object.entries(authHeaders())) xhr.setRequestHeader(k, v);
xhr.upload.onprogress = e => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => {
let payload: { attachment?: Attachment; error?: string } = {};
try {
payload = JSON.parse(xhr.responseText);
} catch {
/* 非 JSON 响应按状态码处理 */
}
if (xhr.status >= 200 && xhr.status < 300 && payload.attachment) {
resolve({ attachment: payload.attachment });
} else {
if (xhr.status === 401) onUnauthorized?.();
reject(new ApiError(xhr.status, payload.error || `HTTP ${xhr.status}`));
}
};
xhr.onerror = () => reject(new ApiError(0, '网络错误'));
xhr.send(form);
});
}
const res = await fetch(`${BASE}/me/attachments`, {
method: 'POST',
credentials: 'include',
// 不设 Content-Typemultipart 的 boundary 要交给浏览器生成
headers: authHeaders(),
body: form
});
if (!res.ok) {
const payload = await res.json().catch(() => ({ error: res.statusText }));
if (res.status === 401) onUnauthorized?.();
throw new ApiError(res.status, payload.error || `HTTP ${res.status}`);
}
return res.json() as Promise<{ attachment: Attachment }>;
}
export async function deleteAttachment(id: string) {
return request<{ status: string }>('DELETE', `/me/attachments/${id}`);
}
/**
* 附件下载链接。由浏览器直接发起(<a download>),因此无法带 Authorization 头:
* Cookie 模式靠同源 Cookie密钥模式回退到 ?access_token=。
*/
export function attachmentURL(id: string) {
return withToken(`${BASE}/me/attachments/${id}`);
}
/** 人类可读的字节数 */
export function formatSize(n: number) {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
export interface ForwardPayload {
/** 新收件人的三维地址 */
to: string;
/** 转发说明,置于引用原文之前 */
comment?: string;
cc?: string;
/** 留空则自动加 Fwd: 前缀 */
subject?: string;
/** 仅当 to 以 .new 结尾时生效 */
session_alias?: string;
}
export async function forwardMail(id: string, payload: ForwardPayload) {
return request<{
mail_id: string;
session_id: string;
session_alias: string;
forwarded_from: string;
}>('POST', `/me/mail/${id}/forward`, {
to: payload.to,
comment: payload.comment ?? '',
cc: payload.cc ?? '',
subject: payload.subject ?? '',
session_alias: payload.session_alias ?? ''
});
}
// ---------- 配额 ----------
export interface Quota {
agent_name: string;
max_rounds: number;
used_rounds: number;
/** 不限额时为 -1 */
remaining: number;
unlimited: boolean;
}
export async function adminListQuotas() {
return request<{ quotas: Quota[] }>('GET', '/admin/quotas');
}
/** 设上限0 = 不限)或把已用次数归零 */
export async function adminSetQuota(
agentName: string,
payload: { max_rounds?: number; reset?: boolean }
) {
return request<{ quota: Quota }>('PUT', `/admin/quotas/${encodeURIComponent(agentName)}`, payload);
}
// ---------- Sessions ----------
export async function getHumanSessions() {
return request<{ sessions: HumanSession[] }>('GET', '/me/sessions');
}
export async function getSessionDetail(id: string) {
return request<SessionDetail>('GET', `/sessions/${id}`);
}
export async function updateSessionAlias(id: string, alias: string) {
return request<{ status: string; alias: string }>('PUT', `/sessions/${id}/alias`, { alias });
}
/**
* 取该会话里最新一条尚未处理的改名提议Agent 在邮件正文里提的)。
* 已接受(提议就是当前别名)或已驳回的不再返回。
*/
export async function getRenameProposal(id: string) {
return request<{ proposal: RenameProposal | null }>('GET', `/sessions/${id}/rename-proposal`);
}
/** 本会话(= 本任务)的往返预算。 */
export async function getSessionBudget(id: string) {
return request<SessionBudget>('GET', `/sessions/${id}/budget`);
}
/**
* 改本会话的往返预算。
*
* max_rounds = 0 表示不限reset 把已用次数归零。两者可同时给
* (「加到 20 并从头算」是一次很自然的操作,拆成两个请求只会多一次往返)。
*/
export async function updateSessionBudget(
id: string,
patch: { max_rounds?: number; reset?: boolean }
) {
return request<SessionBudget>('PUT', `/sessions/${id}/budget`, patch);
}
/** 驳回当前提议。记下来,提示条不再反复弹同一个建议。 */
export async function dismissRenameProposal(id: string) {
return request<{ status: string; dismissed?: string }>(
'POST',
`/sessions/${id}/rename-proposal/dismiss`
);
}
// ---------- Contacts ----------
export async function listContacts(archived = false) {
return request<{ contacts: Contact[] }>('GET', `/contacts?archived=${archived}`);
}
export async function suggestAddress(name?: string, path?: string) {
const p = new URLSearchParams();
if (name) p.set('name', name);
if (path) p.set('path', path);
return request<SuggestResult>('GET', `/contacts/suggest?${p.toString()}`);
}
export async function archiveContact(payload: { address?: string; session_id?: string }) {
return request<{ status: string; session_id: string; session_alias: string }>(
'POST',
'/contacts/archive',
payload
);
}
// ---------- Permission ----------
export async function decidePermission(mailId: string, decision: string, note?: string) {
return request<{ status: string; decision_mail_id: string }>('POST', '/permission/decide', {
mail_id: mailId,
decision,
note: note ?? ''
});
}
export async function listPendingPermissions() {
return request<{ requests: PermissionRequest[] }>('GET', '/permission/pending');
}

68
web/src/api/config.ts Normal file
View File

@ -0,0 +1,68 @@
/**
* API 接入配置。
*
* WebUI 与第三方客户端调用的是**同一套 WebAPI**,差别只在两点:
* 1. 基地址:内嵌在 Gateway 里时是同源的 /api/v1独立部署的客户端需要指向具体主机
* 2. 凭证:浏览器用登录 Cookie第三方客户端用用户密钥Authorization: Bearer
*
* 这两点都在此处集中配置,业务代码不感知差异 —— 这样把 src/api/ 整个抽成 SDK 时
* 不需要改任何调用点。
*/
/** 运行时注入点:宿主页面可在加载 bundle 前设置这两个全局量 */
declare global {
interface Window {
__AGENTMAIL_API_BASE__?: string;
__AGENTMAIL_TOKEN__?: string;
}
}
/**
* 基地址优先级:运行时全局 > 构建期环境变量 > 同源默认值。
*
* 运行时优先是为了让同一份构建产物能部署到不同后端(容器镜像不必按环境重打)。
*/
function resolveBase(): string {
const runtime = typeof window !== 'undefined' ? window.__AGENTMAIL_API_BASE__ : undefined;
const build = import.meta.env?.VITE_API_BASE as string | undefined;
const base = (runtime || build || '/api/v1').trim();
// 统一去掉尾部斜杠,拼接时只在 path 侧带前导斜杠
return base.replace(/\/+$/, '');
}
export const API_BASE = resolveBase();
/** 当前用于 Authorization 头的令牌;空表示走 Cookie。 */
let bearerToken: string | null =
(typeof window !== 'undefined' ? window.__AGENTMAIL_TOKEN__ : undefined) ?? null;
/**
* 设置用户密钥。第三方客户端在启动时调用一次即可,
* 之后所有请求(含 SSE 与附件下载)自动带上。
*/
export function setToken(token: string | null) {
bearerToken = token && token.trim() !== '' ? token.trim() : null;
}
export function getToken(): string | null {
return bearerToken;
}
/** 认证请求头。用 Cookie 时返回空对象。 */
export function authHeaders(): Record<string, string> {
return bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {};
}
/**
* 给 URL 附加认证信息,供无法设置请求头的场景使用:
* - EventSourceSSE不支持自定义头
* - <a download> / <img src> 由浏览器直接发起
*
* 服务端仅在 SSE 与附件下载这两处接受 ?access_token=
* 其余接口一律要求请求头 —— URL 里的令牌会进访问日志。
*/
export function withToken(url: string): string {
if (!bearerToken) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}access_token=${encodeURIComponent(bearerToken)}`;
}

76
web/src/api/sse.ts Normal file
View File

@ -0,0 +1,76 @@
import { API_BASE, withToken } from './config';
export type SSEEventHandler = (eventType: string, data: Record<string, unknown>) => void;
const EVENTS = [
'new_mail',
'permission_decision',
'session_update',
'session_archived',
'agent_online'
] as const;
let es: EventSource | null = null;
let handlers: SSEEventHandler[] = [];
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let backoff = 1000;
export function connectSSE(onEvent: SSEEventHandler): () => void {
handlers.push(onEvent);
if (!es) open();
return () => {
handlers = handlers.filter(h => h !== onEvent);
if (handlers.length === 0) close();
};
}
function open() {
close(false);
// EventSource 无法设置请求头Cookie 模式靠同源 Cookie
// 密钥模式只能把令牌放进 query服务端仅此端点与附件下载接受 ?access_token=)。
es = new EventSource(withToken(`${API_BASE}/events/stream`), { withCredentials: true });
es.addEventListener('connected', () => {
backoff = 1000;
});
for (const name of EVENTS) {
es.addEventListener(name, (e: MessageEvent) => {
let data: Record<string, unknown> = {};
try {
data = JSON.parse(e.data);
} catch {
/* 忽略非 JSON 负载 */
}
handlers.forEach(h => h(name, data));
});
}
es.onerror = () => {
close(false);
if (handlers.length === 0) return;
if (retryTimer) return;
retryTimer = setTimeout(() => {
retryTimer = null;
backoff = Math.min(backoff * 2, 15000);
open();
}, backoff);
};
}
function close(clearHandlers = true) {
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = null;
}
if (es) {
es.close();
es = null;
}
if (clearHandlers) handlers = [];
}
export function disconnectSSE() {
close();
}

View File

@ -0,0 +1,218 @@
import { useCallback, useEffect, useState } from 'react';
import { useAuthStore } from '../stores/authStore';
import * as api from '../api/client';
import { LockIcon } from './icons';
import KeyPanel from './KeyPanel';
/** 当前用户个人中心:查看资料、修改密码、管理客户端连接密钥 */
export default function AccountPage() {
const user = useAuthStore(s => s.user);
const [oldPw, setOldPw] = useState('');
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// 密钥面板状态
const [keys, setKeys] = useState<api.UserKey[]>([]);
const [keyBusy, setKeyBusy] = useState(false);
const [keyError, setKeyError] = useState<string | null>(null);
const [newToken, setNewToken] = useState<string | null>(null);
const loadKeys = useCallback(async () => {
try {
const r = await api.listMyKeys();
setKeys(r.keys);
setKeyError(null);
} catch (err) {
setKeyError(err instanceof Error ? err.message : String(err));
}
}, []);
useEffect(() => {
loadKeys();
}, [loadKeys]);
const createKey = async (payload: api.CreateKeyPayload) => {
setKeyBusy(true);
setKeyError(null);
try {
const r = await api.createMyKey(payload);
// 全文只在创建响应里出现一次,必须当场展示
setNewToken(r.key.key_token ?? null);
await loadKeys();
} catch (err) {
setKeyError(err instanceof Error ? err.message : String(err));
} finally {
setKeyBusy(false);
}
};
const deleteKey = async (id: string) => {
setKeyError(null);
try {
await api.deleteMyKey(id);
await loadKeys();
} catch (err) {
setKeyError(err instanceof Error ? err.message : String(err));
}
};
const mismatch = confirmPw !== '' && newPw !== confirmPw;
const canSubmit = oldPw.length > 0 && newPw.length >= 8 && !mismatch && !busy;
const changePw = async (e: React.FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
setBusy(true);
setError(null);
setMsg(null);
try {
await api.changePassword(oldPw, newPw);
setMsg('密码已修改,请重新登录');
setOldPw('');
setNewPw('');
setConfirmPw('');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
if (!user) return null;
return (
<div className="flex-1 min-w-0 flex flex-col bg-white">
<div className="px-6 py-3 border-b border-gray-200">
<h2 className="text-sm font-semibold text-gray-900"></h2>
</div>
<div className="max-w-lg px-6 py-6 space-y-6">
{/* 基本信息 */}
<section>
<h3 className="text-xs font-medium text-gray-500 mb-3"></h3>
<dl className="text-sm space-y-2">
<Row label="用户名" value={user.username} mono />
<Row label="显示名" value={user.display_name} />
<Row label="角色" value={user.role === 'admin' ? '管理员' : '普通用户'} />
<Row label="状态" value={user.status === 'active' ? '启用' : '禁用'} />
<Row label="创建时间" value={user.created_at || '-'} />
<Row label="最后登录" value={user.last_login || '从未登录'} />
</dl>
</section>
{/* 权限边界 */}
{user.role !== 'admin' && (
<section>
<h3 className="text-xs font-medium text-gray-500 mb-3"></h3>
<dl className="text-sm space-y-2">
<Row
label="可调用 Agent"
value={
user.allowed_agents.length === 0
? '不限(全部可用)'
: user.allowed_agents.join(', ')
}
/>
<Row
label="可访问目录"
value={
user.allowed_paths.length === 0
? '不限(全部可用)'
: user.allowed_paths.join(', ')
}
/>
</dl>
</section>
)}
{/* 修改密码 */}
<section>
<h3 className="text-xs font-medium text-gray-500 mb-3 inline-flex items-center gap-1">
<LockIcon className="w-3.5 h-3.5" />
</h3>
<form onSubmit={changePw} className="space-y-3">
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"></label>
<input
type="password"
value={oldPw}
onChange={e => setOldPw(e.target.value)}
autoComplete="current-password"
className="w-full text-sm border border-gray-300 rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"> 8 </label>
<input
type="password"
value={newPw}
onChange={e => setNewPw(e.target.value)}
autoComplete="new-password"
className="w-full text-sm border border-gray-300 rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"></label>
<input
type="password"
value={confirmPw}
onChange={e => setConfirmPw(e.target.value)}
autoComplete="new-password"
className={`w-full text-sm border rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
mismatch ? 'border-red-300' : 'border-gray-300 focus:border-blue-400'
}`}
/>
{mismatch && <p className="mt-1 text-[10px] text-red-500"></p>}
</div>
{error && (
<p className="text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">
{error}
</p>
)}
{msg && (
<p className="text-xs text-green-600 bg-green-50 border border-green-100 rounded-md px-2.5 py-1.5">
{msg}
</p>
)}
<button
type="submit"
disabled={!canSubmit}
className="px-4 py-1.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{busy ? '保存中' : '保存'}
</button>
</form>
</section>
{/* 客户端连接密钥 */}
<section className="border-t border-gray-200 pt-6">
<KeyPanel
variant="user"
keys={keys}
loading={keyBusy}
error={keyError}
newToken={newToken}
onCreate={createKey}
onDelete={deleteKey}
onDismissToken={() => setNewToken(null)}
/>
</section>
</div>
</div>
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-baseline gap-3">
<dt className="w-20 shrink-0 text-xs text-gray-400">{label}</dt>
<dd className={`text-sm text-gray-900 break-all ${mono ? 'font-mono' : ''}`}>{value}</dd>
</div>
);
}

View File

@ -0,0 +1,176 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import * as api from '../api/client';
/**
* 三段式地址输入name -> @path -> .session
* 每段都向 /contacts/suggest 询问候选,未命中时也允许自由输入。
* 值本身始终是完整字符串 name@path.session。
*/
export default function AddressInput({
value,
onChange,
placeholder,
allowMultiple = false,
autoFocus = false
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
/** 抄送场景:允许逗号分隔多个地址,补全只作用于最后一段 */
allowMultiple?: boolean;
autoFocus?: boolean;
}) {
const [open, setOpen] = useState(false);
const [items, setItems] = useState<string[]>([]);
const [kind, setKind] = useState<'name' | 'path' | 'session'>('name');
const [active, setActive] = useState(0);
const boxRef = useRef<HTMLDivElement>(null);
// 当前正在编辑的那一段(多地址时取最后一段)
const { head, editing } = useMemo(() => {
if (!allowMultiple) return { head: '', editing: value };
const idx = Math.max(value.lastIndexOf(','), value.lastIndexOf(';'));
if (idx < 0) return { head: '', editing: value };
return { head: value.slice(0, idx + 1), editing: value.slice(idx + 1).trimStart() };
}, [value, allowMultiple]);
// 把编辑段拆成 name / path / session 三部分
const parts = useMemo(() => parseParts(editing), [editing]);
useEffect(() => {
let cancelled = false;
const run = async () => {
try {
// 决定问哪一层:还没写 @ -> 问 name写了 @ 没写 . -> 问 path写了 . -> 问 session
const res = parts.hasDot
? await api.suggestAddress(parts.name, parts.path)
: parts.hasAt
? await api.suggestAddress(parts.name)
: await api.suggestAddress();
if (cancelled) return;
const frag = parts.hasDot ? parts.session : parts.hasAt ? parts.path : parts.name;
const filtered = (res.suggestions || []).filter(s =>
s.toLowerCase().includes(frag.toLowerCase())
);
setKind(res.kind);
setItems(filtered);
setActive(0);
} catch {
if (!cancelled) setItems([]);
}
};
const t = setTimeout(run, 120);
return () => {
cancelled = true;
clearTimeout(t);
};
}, [parts.name, parts.path, parts.session, parts.hasAt, parts.hasDot]);
useEffect(() => {
const onDocClick = (e: MouseEvent) => {
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, []);
/** 选中一个候选后拼回完整地址 */
const apply = (choice: string) => {
let next: string;
if (kind === 'name') {
next = `${choice}@`;
} else if (kind === 'path') {
next = `${parts.name}@${choice}.`;
} else {
next = `${parts.name}@${parts.path}.${choice}`;
}
onChange(allowMultiple ? `${head}${head ? ' ' : ''}${next}` : next);
// name/path 选完仍停留在补全态,继续下一段
setOpen(kind !== 'session');
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!open || items.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive(i => (i + 1) % items.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive(i => (i - 1 + items.length) % items.length);
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
apply(items[active]);
} else if (e.key === 'Escape') {
setOpen(false);
}
};
const hint =
kind === 'name' ? 'Agent 名' : kind === 'path' ? '工作区路径' : '会话别名new 为新建)';
return (
<div ref={boxRef} className="relative">
<input
value={value}
autoFocus={autoFocus}
onChange={e => {
onChange(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
placeholder={placeholder}
spellCheck={false}
className="w-full text-sm font-mono border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
{open && items.length > 0 && (
<div className="absolute z-20 mt-1 w-full max-h-56 overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg">
<div className="px-2.5 py-1 text-[10px] text-gray-400 border-b border-gray-100">
{hint}
</div>
{items.map((s, i) => (
<button
key={s}
onMouseDown={e => {
e.preventDefault();
apply(s);
}}
onMouseEnter={() => setActive(i)}
className={`w-full text-left px-2.5 py-1.5 text-sm font-mono ${
i === active ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'
}`}
>
{s}
{kind === 'session' && s === 'new' && (
<span className="ml-2 text-[10px] text-gray-400 font-sans"></span>
)}
</button>
))}
</div>
)}
</div>
);
}
/** 把 name@path.session 拆段path 内允许 . 与 /,按最后一个 . 切 */
function parseParts(s: string) {
const at = s.indexOf('@');
if (at < 0) {
return { name: s, path: '', session: '', hasAt: false, hasDot: false };
}
const name = s.slice(0, at);
const rest = s.slice(at + 1);
const dot = rest.lastIndexOf('.');
if (dot < 0) {
return { name, path: rest, session: '', hasAt: true, hasDot: false };
}
return {
name,
path: rest.slice(0, dot),
session: rest.slice(dot + 1),
hasAt: true,
hasDot: true
};
}

View File

@ -0,0 +1,421 @@
import { useCallback, useEffect, useState } from 'react';
import * as api from '../api/client';
import type { AdminScopes, User } from '../types';
import { CheckIcon, LockIcon, UsersIcon, ChevronRightIcon, KeyIcon, BotIcon } from './icons';
import KeyPanel from './KeyPanel';
import QuotaPanel from './QuotaPanel';
type Tab = 'users' | 'keys' | 'quotas';
export default function AdminUsersPage() {
const [tab, setTab] = useState<Tab>('users');
const [users, setUsers] = useState<User[]>([]);
const [scopes, setScopes] = useState<AdminScopes>({ agents: [], paths: [] });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [editing, setEditing] = useState<string | null>(null);
// Agent 密钥面板
const [keys, setKeys] = useState<api.AgentKey[]>([]);
const [keyBusy, setKeyBusy] = useState(false);
const [keyError, setKeyError] = useState<string | null>(null);
const [newToken, setNewToken] = useState<string | null>(null);
const load = async () => {
setLoading(true);
setError(null);
try {
const [u, s] = await Promise.all([api.adminListUsers(), api.adminListScopes()]);
setUsers(u.users || []);
setScopes(s);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const loadKeys = useCallback(async () => {
try {
const r = await api.adminListAgentKeys();
setKeys(r.keys);
setKeyError(null);
} catch (err) {
setKeyError(err instanceof Error ? err.message : String(err));
}
}, []);
useEffect(() => {
if (tab === 'keys') loadKeys();
}, [tab, loadKeys]);
const createKey = async (payload: api.CreateKeyPayload) => {
setKeyBusy(true);
setKeyError(null);
try {
const r = await api.adminCreateAgentKey(payload);
// 登记客户端已有密钥时对方已经持有全文,无需再弹一次
setNewToken(payload.key_token ? null : r.key.key_token ?? null);
await loadKeys();
} catch (err) {
setKeyError(err instanceof Error ? err.message : String(err));
} finally {
setKeyBusy(false);
}
};
const deleteKey = async (id: string) => {
setKeyError(null);
try {
await api.adminDeleteAgentKey(id);
await loadKeys();
} catch (err) {
setKeyError(err instanceof Error ? err.message : String(err));
}
};
const bindKey = async (id: string, agentName: string) => {
setKeyError(null);
try {
await api.adminBindAgentKey(id, agentName);
await loadKeys();
} catch (err) {
setKeyError(err instanceof Error ? err.message : String(err));
}
};
const flash = (msg: string) => {
setNotice(msg);
setTimeout(() => setNotice(null), 2500);
};
return (
<div className="flex-1 min-w-0 flex flex-col bg-white">
<div className="px-6 py-3 border-b border-gray-200 flex items-center gap-1">
<TabButton active={tab === 'users'} onClick={() => setTab('users')}>
<UsersIcon className="w-4 h-4" />
<span className="text-xs text-gray-400">{users.length}</span>
</TabButton>
<TabButton active={tab === 'keys'} onClick={() => setTab('keys')}>
<KeyIcon className="w-4 h-4" />
Agent
</TabButton>
<TabButton active={tab === 'quotas'} onClick={() => setTab('quotas')}>
<BotIcon className="w-4 h-4" />
</TabButton>
<div className="flex-1" />
{notice && <span className="text-xs text-green-600">{notice}</span>}
{tab === 'users' && (
<button onClick={() => setCreating(v => !v)} className="px-3 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700">
{creating ? '收起' : '新建用户'}
</button>
)}
</div>
{error && <p className="mx-6 mt-3 text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">{error}</p>}
{tab === 'quotas' ? (
<div className="flex-1 overflow-y-auto px-6 py-4">
<QuotaPanel />
</div>
) : tab === 'keys' ? (
<div className="flex-1 overflow-y-auto px-6 py-4">
<KeyPanel
variant="agent"
keys={keys}
loading={keyBusy}
error={keyError}
newToken={newToken}
onCreate={createKey}
onDelete={deleteKey}
onBind={bindKey}
onDismissToken={() => setNewToken(null)}
/>
</div>
) : (
<>
{creating && <CreateUserForm scopes={scopes} onDone={() => { setCreating(false); flash('用户已创建'); load(); }} onError={setError} />}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-2">
{users.map(u => (
<UserCard key={u.user_id} user={u} scopes={scopes}
expanded={editing === u.user_id}
onToggle={() => setEditing(editing === u.user_id ? null : u.user_id)}
onSaved={flash} onReload={load} setError={setError} />
))}
{loading && users.length === 0 && <p className="text-xs text-gray-400 text-center py-6"></p>}
</div>
</>
)}
</div>
);
}
function TabButton({ active, onClick, children }: {
active: boolean; onClick: () => void; children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md ${
active ? 'bg-gray-900 text-white' : 'text-gray-600 hover:bg-gray-100'
}`}
>
{children}
</button>
);
}
/* ── UserCard ── */
function UserCard({ user, scopes, expanded, onToggle, onSaved, onReload, setError }: {
user: User; scopes: AdminScopes; expanded: boolean; onToggle: () => void;
onSaved: (msg: string) => void; onReload: () => void; setError: (msg: string) => void;
}) {
return (
<div className="rounded-lg border border-gray-200">
<div className="px-4 py-2.5 flex items-center gap-3">
<button onClick={onToggle} className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600">
<ChevronRightIcon className={`w-3 h-3 transition-transform ${expanded ? 'rotate-90' : ''}`} />
</button>
<span className="font-mono text-sm text-gray-900 min-w-[100px]">{user.username}</span>
<span className="text-[11px] text-gray-500">{user.display_name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${user.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'}`}>
{user.role === 'admin' ? '管理员' : '用户'}
</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${user.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-200 text-gray-500'}`}>
{user.status === 'active' ? '启用' : '禁用'}
</span>
{user.role !== 'admin' && (user.allowed_agents.length > 0 || user.allowed_paths.length > 0) && (
<span className="text-[10px] text-gray-400"></span>
)}
<div className="flex-1" />
<span className="text-[10px] text-gray-400">{user.last_login || '从未登录'}</span>
</div>
{expanded && <UserEditor user={user} scopes={scopes} onSaved={onSaved} onReload={onReload} setError={setError} />}
</div>
);
}
/* ── UserEditor ── */
function UserEditor({ user, scopes, onSaved, onReload, setError }: {
user: User; scopes: AdminScopes; onSaved: (msg: string) => void; onReload: () => void; setError: (msg: string) => void;
}) {
const [displayName, setDisplayName] = useState(user.display_name);
const [role, setRole] = useState<'admin' | 'user'>(user.role as 'admin' | 'user');
const [agents, setAgents] = useState<string[]>(user.allowed_agents);
const [paths, setPaths] = useState<string[]>(user.allowed_paths);
const [pw, setPw] = useState('');
const [busy, setBusy] = useState(false);
const toggleAgent = (a: string) => setAgents(p => p.includes(a) ? p.filter(x => x !== a) : [...p, a]);
const togglePath = (p: string) => setPaths(prev => prev.includes(p) ? prev.filter(x => x !== p) : [...prev, p]);
const save = async () => {
setBusy(true);
try {
await api.adminUpdateUser(user.user_id, { display_name: displayName, role, allowed_agents: agents, allowed_paths: paths });
onSaved('用户已更新'); await onReload();
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
finally { setBusy(false); }
};
const disableUser = async () => {
try { await api.adminDisableUser(user.user_id); onSaved('用户已禁用'); await onReload(); }
catch (err) { setError(err instanceof Error ? err.message : String(err)); }
};
const enableUser = async () => {
try { await api.adminUpdateUser(user.user_id, { status: 'active' }); onSaved('用户已启用'); await onReload(); }
catch (err) { setError(err instanceof Error ? err.message : String(err)); }
};
const resetPassword = async () => {
if (pw.length < 8) return;
setBusy(true);
try { await api.adminResetPassword(user.user_id, pw); onSaved('密码已重置'); setPw(''); }
catch (err) { setError(err instanceof Error ? err.message : String(err)); }
finally { setBusy(false); }
};
return (
<div className="border-t border-gray-100 bg-gray-50 px-4 py-3 space-y-4">
<div className="grid grid-cols-3 gap-3 text-sm">
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"></label>
<input value={displayName} onChange={e => setDisplayName(e.target.value)}
className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" />
</div>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"></label>
<select value={role} onChange={e => setRole(e.target.value as 'admin' | 'user')}
className="w-full text-sm border border-gray-300 rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400">
<option value="user"></option>
<option value="admin"></option>
</select>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"></label>
{user.status === 'active' ? (
<button onClick={disableUser} className="px-3 py-1.5 text-xs rounded-md border border-red-300 text-red-600 hover:bg-red-50 w-full"></button>
) : (
<button onClick={enableUser} className="px-3 py-1.5 text-xs rounded-md border border-green-300 text-green-700 hover:bg-green-50 w-full"></button>
)}
</div>
</div>
{user.role !== 'admin' && (
<>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1.5">
Agent {agents.length > 0 && <span className="text-gray-400">{agents.length} </span>}
<span className="ml-2 font-normal text-gray-400"> = </span>
</label>
<div className="flex flex-wrap gap-1.5">
{scopes.agents.map(a => (
<button key={a} onClick={() => toggleAgent(a)}
className={`px-2.5 py-1 text-xs font-mono rounded-md border transition-colors ${
agents.includes(a) ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-200 text-gray-500 hover:bg-gray-100'
}`}>{a}</button>
))}
</div>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1.5">
访 {paths.length > 0 && <span className="text-gray-400">{paths.length} </span>}
<span className="ml-2 font-normal text-gray-400"> = </span>
</label>
<div className="flex flex-wrap gap-1.5">
{scopes.paths.map(p => (
<button key={p} onClick={() => togglePath(p)}
className={`px-2.5 py-1 text-xs font-mono rounded-md border transition-colors ${
paths.includes(p) ? 'bg-green-50 border-green-300 text-green-700' : 'border-gray-200 text-gray-500 hover:bg-gray-100'
}`}>{p}</button>
))}
</div>
</div>
</>
)}
<div className="flex items-center gap-3">
<button onClick={save} disabled={busy} className="px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 transition-colors">
{busy ? '保存中' : '保存更改'}
</button>
<div className="flex items-center gap-1.5 ml-auto">
<LockIcon className="w-3 h-3 text-gray-400" />
<input type="password" value={pw} onChange={e => setPw(e.target.value)} placeholder="新密码(至少 8 位)"
className="w-40 text-xs border border-gray-300 rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" />
<button onClick={resetPassword} disabled={pw.length < 8 || busy}
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] rounded bg-gray-700 text-white hover:bg-gray-800 disabled:opacity-40">
<CheckIcon className="w-3 h-3" />
</button>
</div>
</div>
</div>
);
}
/* ── CreateUserForm ── */
function CreateUserForm({ scopes, onDone, onError }: {
scopes: AdminScopes; onDone: () => void; onError: (msg: string) => void;
}) {
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState<'admin' | 'user'>('user');
const [agents, setAgents] = useState<string[]>([]);
const [paths, setPaths] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const ok = username.trim().length >= 2 && password.length >= 8 && !busy;
const submit = async () => {
if (!ok) return;
setBusy(true);
try {
await api.adminCreateUser({
username: username.trim().toLowerCase(), password, display_name: displayName.trim(), role: role,
allowed_agents: role === 'admin' ? [] : agents, allowed_paths: role === 'admin' ? [] : paths,
});
setUsername(''); setDisplayName(''); setPassword(''); setRole('user'); setAgents([]); setPaths([]);
onDone();
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
finally { setBusy(false); }
};
return (
<div className="mx-6 mt-3 p-4 rounded-lg border border-gray-200 bg-gray-50 space-y-3">
<div className="grid grid-cols-4 gap-3">
<Field label="用户名" hint="小写字母数字 . _ -">
<input value={username} onChange={e => setUsername(e.target.value)} placeholder="alice" spellCheck={false}
className="w-full text-sm font-mono border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" />
</Field>
<Field label="显示名"><input value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder="Alice"
className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" /></Field>
<Field label="初始密码" hint="至少 8 位"><input type="password" value={password} onChange={e => setPassword(e.target.value)}
className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" /></Field>
<Field label="角色">
<select value={role} onChange={e => setRole(e.target.value as 'admin' | 'user')}
className="w-full text-sm border border-gray-300 rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400">
<option value="user"></option>
<option value="admin"></option>
</select>
</Field>
</div>
{role !== 'admin' && (
<>
<ScopePick label="可调用 Agent" items={scopes.agents} selected={agents} onToggle={a => setAgents(p => p.includes(a) ? p.filter(x => x !== a) : [...p, a])} color="blue" />
<ScopePick label="可访问目录" items={scopes.paths} selected={paths} onToggle={p => setPaths(prev => prev.includes(p) ? prev.filter(x => x !== p) : [...prev, p])} color="green" />
</>
)}
<div className="flex justify-end">
<button onClick={submit} disabled={!ok} className="px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40">
{busy ? '创建中' : '创建'}
</button>
</div>
</div>
);
}
function ScopePick({ label, items, selected, onToggle, color }: {
label: string; items: string[]; selected: string[]; onToggle: (item: string) => void; color: 'blue' | 'green';
}) {
const active = color === 'blue' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'bg-green-50 border-green-300 text-green-700';
return (
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1">
{label} <span className="font-normal text-gray-400"> = </span>
</label>
<div className="flex flex-wrap gap-1.5">
{items.map(i => (
<button key={i} onClick={() => onToggle(i)}
className={`px-2.5 py-1 text-xs font-mono rounded-md border transition-colors ${
selected.includes(i) ? active : 'border-gray-200 text-gray-500 hover:bg-gray-100'
}`}>{i}</button>
))}
</div>
</div>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div>
<div className="flex items-baseline gap-1.5 mb-1">
<label className="text-[11px] font-medium text-gray-500">{label}</label>
{hint && <span className="text-[10px] text-gray-400">{hint}</span>}
</div>
{children}
</div>
);
}

View File

@ -0,0 +1,168 @@
import { useRef, useState } from 'react';
import * as api from '../api/client';
import type { Attachment } from '../types';
import { PaperclipIcon, DownloadIcon, FileIcon, CloseIcon, SpinnerIcon } from './icons';
/** 已发出邮件的附件清单(只读,点击下载)。 */
export function AttachmentList({ items }: { items: Attachment[] }) {
if (!items || items.length === 0) return null;
return (
<div className="mt-4 border-t border-gray-100 pt-3">
<div className="flex items-center gap-1.5 mb-2">
<PaperclipIcon className="w-3.5 h-3.5 text-gray-400" />
<span className="text-[11px] font-medium text-gray-500">
{items.length}
</span>
</div>
<ul className="space-y-1">
{items.map(a => (
<li key={a.attachment_id}>
<a
href={api.attachmentURL(a.attachment_id)}
// download 让浏览器保存而非尝试渲染;服务端也已强制 octet-stream + attachment
download={a.filename}
className="group flex items-center gap-2 px-2 py-1.5 rounded border border-gray-200 hover:border-blue-300 hover:bg-blue-50 transition-colors"
>
<FileIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="text-xs text-gray-800 truncate flex-1">{a.filename}</span>
<span className="text-[10px] text-gray-400 shrink-0">
{api.formatSize(a.size_bytes)}
</span>
<DownloadIcon className="w-3.5 h-3.5 text-gray-300 group-hover:text-blue-500 shrink-0" />
</a>
</li>
))}
</ul>
</div>
);
}
/** 待发送的附件:已上传到服务器、等着随邮件发出。 */
export interface PendingAttachment {
id: string;
filename: string;
size: number;
}
/**
* 写信时的附件选择器。
*
* 上传是独立一步:选中即上传,拿到 attachment_id 后暂存,发信时一并提交。
* 之所以不等到点「发送」再传:大文件上传要时间,让用户在写正文时就完成上传体验更好,
* 而且上传失败能立刻反馈而不是卡在发送那一刻。
*/
export function AttachmentPicker({
items,
onChange,
disabled
}: {
items: PendingAttachment[];
onChange: (next: PendingAttachment[]) => void;
disabled?: boolean;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState<{ name: string; pct: number } | null>(null);
const [error, setError] = useState<string | null>(null);
const pick = () => inputRef.current?.click();
const handleFiles = async (files: FileList | null) => {
if (!files || files.length === 0) return;
setError(null);
// 逐个上传而非并发:并发时进度条只能显示其中一个,且大文件同时传更容易触发体积限制
const added: PendingAttachment[] = [];
for (const file of Array.from(files)) {
setUploading({ name: file.name, pct: 0 });
try {
const r = await api.uploadAttachment(file, pct => setUploading({ name: file.name, pct }));
added.push({
id: r.attachment.attachment_id,
filename: r.attachment.filename,
size: r.attachment.size_bytes
});
} catch (err) {
setError(`${file.name}${err instanceof Error ? err.message : String(err)}`);
break; // 一个失败就停下,避免连续弹同类错误
}
}
setUploading(null);
if (added.length > 0) onChange([...items, ...added]);
// 清空 input否则重复选同一个文件不会触发 change
if (inputRef.current) inputRef.current.value = '';
};
const remove = async (a: PendingAttachment) => {
// 从服务器删掉未挂载的附件,不然它会占着磁盘等 24 小时 GC
try {
await api.deleteAttachment(a.id);
} catch {
/* 删不掉也只是留给 GC不该阻塞用户移除操作 */
}
onChange(items.filter(x => x.id !== a.id));
};
return (
<div className="space-y-2">
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={e => handleFiles(e.target.files)}
/>
<div className="flex items-center gap-2">
<button
onClick={pick}
disabled={disabled || uploading !== null}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-40"
>
<PaperclipIcon className="w-3.5 h-3.5" />
</button>
{uploading && (
<span className="inline-flex items-center gap-1.5 text-[11px] text-gray-500">
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
{uploading.name} {uploading.pct}%
</span>
)}
{items.length > 0 && !uploading && (
<span className="text-[11px] text-gray-400">
{items.length} ·{' '}
{api.formatSize(items.reduce((sum, a) => sum + a.size, 0))}
</span>
)}
</div>
{error && <div className="text-[11px] text-red-600">{error}</div>}
{items.length > 0 && (
<ul className="space-y-1">
{items.map(a => (
<li
key={a.id}
className="flex items-center gap-2 px-2 py-1.5 rounded border border-gray-200 bg-gray-50"
>
<FileIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="text-xs text-gray-800 truncate flex-1">{a.filename}</span>
<span className="text-[10px] text-gray-400 shrink-0">{api.formatSize(a.size)}</span>
<button
onClick={() => remove(a)}
disabled={disabled}
title="移除"
className="shrink-0 text-gray-400 hover:text-red-600 disabled:opacity-40"
>
<CloseIcon className="w-3.5 h-3.5" />
</button>
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -0,0 +1,275 @@
import { useEffect, useState } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import * as api from '../api/client';
import { useUIStore } from '../stores/uiStore';
import { useMailStore } from '../stores/mailStore';
import { useSessionStore } from '../stores/sessionStore';
import { useContactStore } from '../stores/contactStore';
import AddressInput from './AddressInput';
import { AttachmentPicker, type PendingAttachment } from './Attachments';
import { ComposeIcon } from './icons';
/** 完整的写邮件页面,占据右侧整个区域 */
export default function ComposePage() {
const prefill = useUIStore(s => s.composePrefill);
const cancelCompose = useUIStore(s => s.cancelCompose);
const fetchInbox = useMailStore(s => s.fetchInbox);
const fetchSent = useMailStore(s => s.fetchSent);
const fetchSessions = useSessionStore(s => s.fetchSessions);
const fetchContacts = useContactStore(s => s.fetchContacts);
const [to, setTo] = useState(prefill?.to ?? '');
const [cc, setCc] = useState(prefill?.cc ?? '');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [sessionAlias, setSessionAlias] = useState('');
// 本任务的往返预算。空 = 不限。
// 配额的语义是「这件事值得多少个来回」—— 那是任务的属性,所以在派活这一刻给,
// 而不是事后到管理员页面去调某个 Agent 的全局配额。
const [maxRounds, setMaxRounds] = useState('');
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const [preview, setPreview] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [okMsg, setOkMsg] = useState<string | null>(null);
useEffect(() => {
setTo(prefill?.to ?? '');
setCc(prefill?.cc ?? '');
}, [prefill]);
// 会话别名只在新建会话(地址以 .new 结尾)时有意义;
// 命中已有会话或走默认会话时后端会忽略该字段。
const isNewSession = /\.new\s*$/.test(to.trim());
const aliasError =
isNewSession && sessionAlias.trim() !== '' && /[.\s/@]/.test(sessionAlias.trim())
? '别名不可含 . 空白 / 或 @'
: isNewSession && sessionAlias.trim() === 'new'
? '"new" 是寻址保留字'
: null;
const roundsError =
maxRounds.trim() !== '' && !/^\d+$/.test(maxRounds.trim())
? '预算必须是非负整数0 = 不限)'
: null;
const canSend =
roundsError === null &&
to.trim() !== '' &&
subject.trim() !== '' &&
body.trim() !== '' &&
aliasError === null &&
!sending;
const send = async () => {
if (!canSend) return;
setSending(true);
setError(null);
try {
const res = await api.sendMail(to.trim(), subject.trim(), body, {
cc: cc.trim(),
session_alias: isNewSession ? sessionAlias.trim() : '',
attachment_ids: attachments.map(a => a.id),
// 只在新建会话时提交:续谈已有会话若也带这个字段,
// 每封新信都会悄悄改掉对方正在遵守的预算
...(isNewSession && maxRounds.trim() !== ''
? { max_rounds: Number(maxRounds.trim()) }
: {})
});
const where = res.session_alias
? `会话别名 ${res.session_alias}`
: `会话 ${res.session_id.slice(0, 8)}`;
setOkMsg(
res.budget_max ? `已发送 · ${where} · 预算 ${res.budget_max} 个来回` : `已发送 · ${where}`
);
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
setTimeout(() => {
setOkMsg(null);
cancelCompose();
}, 900);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSending(false);
}
};
return (
<div className="flex-1 min-w-0 flex flex-col bg-white">
<div className="px-6 py-3 border-b border-gray-200 flex items-center gap-2">
<ComposeIcon className="w-4 h-4 text-blue-600" />
<h2 className="text-sm font-semibold text-gray-900"></h2>
<div className="flex-1" />
<button
onClick={() => {
setTo('');
setCc('');
setSubject('');
setBody('');
setSessionAlias('');
setMaxRounds('');
// 已上传的附件要从服务端删掉,否则留到 GC 才回收
attachments.forEach(a => void api.deleteAttachment(a.id).catch(() => {}));
setAttachments([]);
setError(null);
}}
className="text-xs text-gray-400 hover:text-gray-600"
>
</button>
</div>
<div className="px-6 py-4 space-y-3 border-b border-gray-200">
<Field label="收件人" hint="name@path.session省略=默认会话new=新建,别名=已有会话">
<AddressInput
value={to}
onChange={setTo}
autoFocus
placeholder="deepseekharness@/program.upadtefeature"
/>
</Field>
{isNewSession && (
<Field label="会话别名" hint="可选;命名后可用 name@path.别名 续谈,全局唯一">
<input
value={sessionAlias}
onChange={e => setSessionAlias(e.target.value)}
placeholder="refactor-auth"
className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
{aliasError && <span className="text-[10px] text-red-600">{aliasError}</span>}
</Field>
)}
{isNewSession && (
<Field
label="往返预算"
hint="可选;留空或 0 = 不限。之后可在对话页随时调整"
>
<div className="flex items-center gap-2">
<input
value={maxRounds}
onChange={e => setMaxRounds(e.target.value)}
inputMode="numeric"
placeholder="不限"
className="w-24 text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
<span className="text-[11px] text-gray-400">
Agent
</span>
</div>
{roundsError && <span className="text-[10px] text-red-600">{roundsError}</span>}
</Field>
)}
<Field label="抄送" hint="多个地址用逗号分隔">
<AddressInput value={cc} onChange={setCc} allowMultiple placeholder="pi@root.new" />
</Field>
<Field label="主题">
<input
value={subject}
onChange={e => setSubject(e.target.value)}
placeholder="更新特性分支"
className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
</Field>
</div>
<div className="flex-1 min-h-0 px-6 py-3 flex flex-col">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[11px] font-medium text-gray-500">Markdown</span>
<div className="flex-1" />
<Toggle active={!preview} onClick={() => setPreview(false)}>
</Toggle>
<Toggle active={preview} onClick={() => setPreview(true)}>
</Toggle>
</div>
{preview ? (
<div className="flex-1 min-h-0 overflow-y-auto border border-gray-200 rounded-md p-4 prose prose-sm max-w-none">
{body.trim() ? (
<Markdown remarkPlugins={[remarkGfm]}>{body}</Markdown>
) : (
<p className="text-gray-400 text-sm"></p>
)}
</div>
) : (
<textarea
value={body}
onChange={e => setBody(e.target.value)}
placeholder={'## 需求\n\n请在 /program 下推进 update feature…'}
className="flex-1 min-h-0 w-full text-sm font-mono border border-gray-300 rounded-md p-4 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
)}
</div>
<div className="px-6 pb-3">
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={sending} />
</div>
<div className="px-6 py-3 border-t border-gray-200 flex items-center gap-3">
{error && <span className="text-xs text-red-600">{error}</span>}
{okMsg && <span className="text-xs text-green-600">{okMsg}</span>}
<div className="flex-1" />
<button
onClick={cancelCompose}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900"
>
</button>
<button
onClick={send}
disabled={!canSend}
className="px-5 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{sending ? '发送中' : '发送'}
</button>
</div>
</div>
);
}
function Field({
label,
hint,
children
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div>
<div className="flex items-baseline gap-2 mb-1">
<label className="text-[11px] font-medium text-gray-500">{label}</label>
{hint && <span className="text-[10px] text-gray-400">{hint}</span>}
</div>
{children}
</div>
);
}
function Toggle({
active,
onClick,
children
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={`text-[11px] px-2 py-0.5 rounded ${
active ? 'bg-gray-900 text-white' : 'text-gray-500 hover:text-gray-800'
}`}
>
{children}
</button>
);
}

View File

@ -0,0 +1,217 @@
import { useEffect } from 'react';
import { useContactStore } from '../stores/contactStore';
import { useSessionStore } from '../stores/sessionStore';
import { useUIStore } from '../stores/uiStore';
import { useMailStore } from '../stores/mailStore';
import type { Contact } from '../types';
import { ArchiveIcon, ComposeIcon, CheckIcon, CloseIcon, ChevronRightIcon } from './icons';
/**
* 左侧联系人面板:列出所有 name@path.session支持
* - 点击进入该会话
* - 写信(预填收件人为该三维地址)
* - 归档Agent 侧会话归档 + 邮箱界面移除)
*/
export default function ContactPanel() {
const contacts = useContactStore(s => s.contacts);
const archivedContacts = useContactStore(s => s.archivedContacts);
const showArchived = useContactStore(s => s.showArchived);
const loading = useContactStore(s => s.loading);
const error = useContactStore(s => s.error);
const pendingArchive = useContactStore(s => s.pendingArchive);
const fetchContacts = useContactStore(s => s.fetchContacts);
const toggleArchivedView = useContactStore(s => s.toggleArchivedView);
const requestArchive = useContactStore(s => s.requestArchive);
const cancelArchive = useContactStore(s => s.cancelArchive);
const archive = useContactStore(s => s.archive);
const selectSession = useSessionStore(s => s.selectSession);
const currentSession = useSessionStore(s => s.currentSession);
const clearCurrentMail = useMailStore(s => s.clearCurrentMail);
const startCompose = useUIStore(s => s.startCompose);
const cancelCompose = useUIStore(s => s.cancelCompose);
useEffect(() => {
fetchContacts();
}, []);
const open = (c: Contact) => {
cancelCompose();
clearCurrentMail();
selectSession(c.session_id);
};
return (
<div className="w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col">
<div className="px-4 py-3 border-b border-gray-200 flex items-center">
<h2 className="text-sm font-semibold text-gray-800"></h2>
<span className="ml-2 text-xs text-gray-400">{contacts.length}</span>
<div className="flex-1" />
<button
onClick={toggleArchivedView}
className={`text-[11px] px-1.5 py-0.5 rounded ${
showArchived ? 'bg-gray-900 text-white' : 'text-gray-500 hover:text-gray-800'
}`}
>
</button>
</div>
{error && <p className="px-4 py-2 text-xs text-red-600">{error}</p>}
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{loading && contacts.length === 0 && (
<p className="text-xs text-gray-400 text-center py-6"></p>
)}
{contacts.map(c => (
<ContactRow
key={c.session_id}
contact={c}
active={currentSession?.session_id === c.session_id}
confirming={pendingArchive === c.address}
onOpen={() => open(c)}
onCompose={() => startCompose({ to: c.address })}
onRequestArchive={() => requestArchive(c.address)}
onCancelArchive={cancelArchive}
onConfirmArchive={() => archive(c)}
/>
))}
{!loading && contacts.length === 0 && (
<p className="text-xs text-gray-400 text-center py-6">
</p>
)}
{showArchived && (
<div className="pt-3 mt-2 border-t border-gray-200">
<p className="px-2 pb-1 text-[11px] font-medium text-gray-400">
{archivedContacts.length}
</p>
{archivedContacts.map(c => (
<div
key={c.session_id}
className="px-3 py-2 rounded-lg opacity-60 hover:opacity-100 hover:bg-gray-50"
>
<p className="text-xs font-mono text-gray-500 truncate">{c.address}</p>
<p className="text-[10px] text-gray-400 mt-0.5">
{c.mail_count} ·
</p>
</div>
))}
{archivedContacts.length === 0 && (
<p className="text-xs text-gray-400 text-center py-3"></p>
)}
</div>
)}
</div>
</div>
);
}
function ContactRow({
contact,
active,
confirming,
onOpen,
onCompose,
onRequestArchive,
onCancelArchive,
onConfirmArchive
}: {
contact: Contact;
active: boolean;
confirming: boolean;
onOpen: () => void;
onCompose: () => void;
onRequestArchive: () => void;
onCancelArchive: () => void;
onConfirmArchive: () => void;
}) {
const time = new Date(contact.last_activity).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
if (confirming) {
return (
<div className="px-3 py-2.5 rounded-lg border border-red-200 bg-red-50">
<p className="text-xs text-gray-800">
<span className="font-mono">{contact.address}</span>
</p>
<p className="text-[10px] text-gray-500 mt-0.5">
Agent session
</p>
<div className="flex gap-2 mt-2">
<button
onClick={onConfirmArchive}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-red-600 text-white text-[11px] font-medium hover:bg-red-700"
>
<CheckIcon className="w-3 h-3" />
</button>
<button
onClick={onCancelArchive}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md border border-gray-300 text-gray-600 text-[11px] hover:bg-white"
>
<CloseIcon className="w-3 h-3" />
</button>
</div>
</div>
);
}
return (
<div
className={`group px-3 py-2.5 rounded-lg border transition-colors ${
active ? 'bg-blue-50 border-blue-200' : 'border-transparent hover:bg-gray-50'
}`}
>
<button onClick={onOpen} className="w-full text-left">
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-gray-900 truncate">
{contact.agent_name}
</span>
<span className="text-[10px] text-gray-400 font-mono truncate">{contact.path}</span>
{contact.unread_count > 0 && (
<span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-500 text-white text-[9px] font-bold flex items-center justify-center">
{contact.unread_count}
</span>
)}
</div>
<div className="flex items-center gap-1 mt-0.5">
<ChevronRightIcon className="w-3 h-3 text-blue-400 shrink-0" />
<span className="text-[11px] text-blue-600 font-mono truncate">
{contact.session_alias || '(未命名会话)'}
</span>
</div>
<p className="text-[10px] text-gray-400 mt-0.5">
{contact.mail_count} · {time}
</p>
</button>
<div className="flex gap-1 mt-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={onCompose}
title="写信给该地址"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-white"
>
<ComposeIcon className="w-3 h-3" />
</button>
<button
onClick={onRequestArchive}
title="归档该 name@path.session"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-white hover:text-red-600 hover:border-red-300"
>
<ArchiveIcon className="w-3 h-3" />
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,311 @@
import { useState } from 'react';
import * as api from '../api/client';
import { KeyIcon, CopyIcon, TrashIcon, PlusIcon, CheckIcon } from './icons';
/** 密钥类型的中文说明,创建表单与列表共用一份文案 */
export const KEY_TYPE_LABEL: Record<api.KeyType, string> = {
permanent: '长期',
one_time: '一次性',
timed: '限时'
};
const KEY_TYPE_HINT: Record<api.KeyType, string> = {
permanent: '永不过期,可重复使用',
one_time: '首次使用后立即失效',
timed: '指定小时数后过期'
};
/** 一条密钥在列表里的状态:过期/已用完/可用 */
function keyState(k: { key_type: api.KeyType; expires_at: string | null; used_at: string | null }) {
if (k.key_type === 'one_time' && k.used_at) return { text: '已使用', cls: 'text-gray-400' };
if (k.key_type === 'timed' && k.expires_at && new Date(k.expires_at) < new Date())
return { text: '已过期', cls: 'text-red-500' };
return { text: '可用', cls: 'text-green-600' };
}
/**
* 新签发密钥的一次性展示条。
*
* 密钥全文只在创建响应里出现一次,服务端之后只返回前 8 位,
* 所以这里必须明确提示「关掉就再也看不到」,而不是让用户以为随时能回来复制。
*/
function NewKeyBanner({ token, onDismiss }: { token: string; onDismiss: () => void }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(token);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* 无剪贴板权限时用户可手动选中 */
}
};
return (
<div className="border border-amber-300 bg-amber-50 rounded-md p-3 space-y-2">
<div className="text-xs font-medium text-amber-900">
</div>
<div className="flex items-center gap-2">
<code className="flex-1 text-[11px] font-mono bg-white border border-amber-200 rounded px-2 py-1.5 break-all">
{token}
</code>
<button
onClick={copy}
className="shrink-0 flex items-center gap-1 text-xs px-2 py-1.5 border border-amber-300 rounded hover:bg-amber-100"
>
{copied ? <CheckIcon className="w-3.5 h-3.5" /> : <CopyIcon className="w-3.5 h-3.5" />}
{copied ? '已复制' : '复制'}
</button>
<button onClick={onDismiss} className="shrink-0 text-xs text-amber-800 hover:underline">
</button>
</div>
</div>
);
}
interface CreateFormProps {
/** Agent 密钥面板会多出「绑定 Agent」与「登记已有密钥」两项 */
variant: 'agent' | 'user';
busy: boolean;
onSubmit: (payload: api.CreateKeyPayload) => void;
}
function CreateForm({ variant, busy, onSubmit }: CreateFormProps) {
const [open, setOpen] = useState(false);
const [keyType, setKeyType] = useState<api.KeyType>('permanent');
const [label, setLabel] = useState('');
const [hours, setHours] = useState(24);
const [agentName, setAgentName] = useState('');
const [keyToken, setKeyToken] = useState('');
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50"
>
<PlusIcon className="w-3.5 h-3.5" />
</button>
);
}
const submit = () => {
const payload: api.CreateKeyPayload = { key_type: keyType, label: label.trim() };
if (keyType === 'timed') payload.expires_hours = hours;
if (variant === 'agent') {
if (agentName.trim()) payload.agent_name = agentName.trim();
if (keyToken.trim()) payload.key_token = keyToken.trim();
}
onSubmit(payload);
setOpen(false);
setLabel('');
setAgentName('');
setKeyToken('');
};
return (
<div className="border border-gray-200 rounded-md p-3 space-y-2.5 bg-gray-50">
<div className="grid grid-cols-3 gap-2">
{(Object.keys(KEY_TYPE_LABEL) as api.KeyType[]).map(t => (
<button
key={t}
onClick={() => setKeyType(t)}
className={`text-left px-2.5 py-2 rounded border text-xs ${
keyType === t
? 'border-blue-400 bg-white ring-2 ring-blue-100'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="font-medium text-gray-900">{KEY_TYPE_LABEL[t]}</div>
<div className="text-[10px] text-gray-500 mt-0.5">{KEY_TYPE_HINT[t]}</div>
</button>
))}
</div>
<div className="flex gap-2">
<input
value={label}
onChange={e => setLabel(e.target.value)}
placeholder="备注(如 我的笔记本 / CI 机器)"
className="flex-1 text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
{keyType === 'timed' && (
<div className="flex items-center gap-1">
<input
type="number"
min={1}
value={hours}
onChange={e => setHours(Math.max(1, Number(e.target.value) || 1))}
className="w-20 text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
<span className="text-[11px] text-gray-500"></span>
</div>
)}
</div>
{variant === 'agent' && (
<div className="space-y-2">
<input
value={agentName}
onChange={e => setAgentName(e.target.value)}
placeholder="绑定到 Agent留空 = 首次注册时自动落定)"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
<input
value={keyToken}
onChange={e => setKeyToken(e.target.value)}
placeholder="登记插件本地生成的密钥(留空 = 由服务器生成)"
className="w-full text-xs font-mono border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
</div>
)}
<div className="flex items-center gap-2">
<div className="flex-1" />
<button onClick={() => setOpen(false)} className="text-xs text-gray-600 hover:text-gray-900">
</button>
<button
onClick={submit}
disabled={busy}
className="text-xs px-3 py-1.5 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
>
</button>
</div>
</div>
);
}
/**
* 密钥面板。Agent 密钥(管理员)与用户连接密钥共用同一套渲染,
* 差异用 variant 表达:只有 Agent 密钥能绑定 Agent 名、能登记客户端已生成的密钥。
*/
export default function KeyPanel({
variant,
keys,
loading,
error,
newToken,
onCreate,
onDelete,
onBind,
onDismissToken
}: {
variant: 'agent' | 'user';
keys: (api.AgentKey | api.UserKey)[];
loading: boolean;
error: string | null;
newToken: string | null;
onCreate: (payload: api.CreateKeyPayload) => void;
onDelete: (id: string) => void;
onBind?: (id: string, agentName: string) => void;
onDismissToken: () => void;
}) {
const [bindingID, setBindingID] = useState<string | null>(null);
const [bindName, setBindName] = useState('');
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<KeyIcon className="w-4 h-4 text-gray-500" />
<h3 className="text-sm font-semibold text-gray-900">
{variant === 'agent' ? 'Agent 接入密钥' : '客户端连接密钥'}
</h3>
<div className="flex-1" />
<CreateForm variant={variant} busy={loading} onSubmit={onCreate} />
</div>
<p className="text-[11px] text-gray-500">
{variant === 'agent'
? 'Agent 用该密钥注册、收发邮件与订阅通知。插件首次安装会在本地生成一把密钥并打印出来,把它填到「登记」框即可。'
: '第三方客户端用该密钥访问自己的邮箱Authorization: Bearer。它不能用于注册 Agent。'}
</p>
{error && <div className="text-xs text-red-600">{error}</div>}
{newToken && <NewKeyBanner token={newToken} onDismiss={onDismissToken} />}
{keys.length === 0 ? (
<div className="text-xs text-gray-400 py-3"></div>
) : (
<div className="border border-gray-200 rounded-md divide-y divide-gray-100">
{keys.map(k => {
const st = keyState(k);
const agentKey = variant === 'agent' ? (k as api.AgentKey) : null;
return (
<div key={k.key_id} className="px-3 py-2.5 flex items-center gap-3">
<code className="text-[11px] font-mono text-gray-700 w-24 shrink-0">
{k.token_hint}
</code>
<div className="min-w-0 flex-1">
<div className="text-xs text-gray-900 truncate">
{k.label || <span className="text-gray-400"></span>}
</div>
<div className="text-[10px] text-gray-500 mt-0.5">
{KEY_TYPE_LABEL[k.key_type]}
{k.expires_at && ` · ${new Date(k.expires_at).toLocaleString()} 过期`}
{agentKey &&
(agentKey.agent_name ? ` · ${agentKey.agent_name}` : ' · 待绑定')}
</div>
</div>
<span className={`text-[10px] shrink-0 ${st.cls}`}>{st.text}</span>
{agentKey && onBind && bindingID === k.key_id ? (
<div className="flex items-center gap-1 shrink-0">
<input
value={bindName}
onChange={e => setBindName(e.target.value)}
placeholder="Agent 名"
className="w-28 text-[11px] border border-gray-300 rounded px-1.5 py-1"
/>
<button
onClick={() => {
if (bindName.trim()) onBind(k.key_id, bindName.trim());
setBindingID(null);
setBindName('');
}}
className="text-[11px] text-blue-600 hover:underline"
>
</button>
<button
onClick={() => setBindingID(null)}
className="text-[11px] text-gray-500 hover:underline"
>
</button>
</div>
) : (
agentKey &&
onBind && (
<button
onClick={() => {
setBindingID(k.key_id);
setBindName(agentKey.agent_name ?? '');
}}
className="text-[11px] text-gray-500 hover:text-gray-900 shrink-0"
>
</button>
)
)}
<button
onClick={() => onDelete(k.key_id)}
title="吊销"
className="shrink-0 text-gray-400 hover:text-red-600"
>
<TrashIcon className="w-3.5 h-3.5" />
</button>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,108 @@
import { useEffect, useRef, useState } from 'react';
import { useAuthStore } from '../stores/authStore';
import { MailboxIcon, SpinnerIcon } from './icons';
export default function LoginPage() {
const login = useAuthStore(s => s.login);
const error = useAuthStore(s => s.error);
const retryAfter = useAuthStore(s => s.retryAfter);
const submitting = useAuthStore(s => s.submitting);
const clearError = useAuthStore(s => s.clearError);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [countdown, setCountdown] = useState(0);
const userRef = useRef<HTMLInputElement>(null);
useEffect(() => {
userRef.current?.focus();
}, []);
// 被限速时倒计时
useEffect(() => {
if (!retryAfter) return;
setCountdown(retryAfter);
const t = setInterval(() => {
setCountdown(c => {
if (c <= 1) {
clearInterval(t);
clearError();
return 0;
}
return c - 1;
});
}, 1000);
return () => clearInterval(t);
}, [retryAfter]);
const locked = countdown > 0;
const canSubmit = username.trim() !== '' && password !== '' && !submitting && !locked;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
const ok = await login(username, password);
if (!ok) setPassword('');
};
return (
<div className="h-full flex items-center justify-center bg-slate-100">
<div className="w-[380px] max-w-[92vw] bg-white rounded-xl shadow-sm border border-gray-200 p-8">
<div className="flex flex-col items-center mb-6">
<div className="w-12 h-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center">
<MailboxIcon className="w-6 h-6" />
</div>
<h1 className="mt-3 text-base font-semibold text-gray-900">AgentMail</h1>
<p className="mt-1 text-xs text-gray-500"></p>
</div>
<form onSubmit={submit} className="space-y-3">
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"></label>
<input
ref={userRef}
value={username}
onChange={e => {
setUsername(e.target.value);
if (error) clearError();
}}
autoComplete="username"
spellCheck={false}
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"></label>
<input
type="password"
value={password}
onChange={e => {
setPassword(e.target.value);
if (error) clearError();
}}
autoComplete="current-password"
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
</div>
{error && (
<p className="text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">
{error}
{locked && `${countdown} 秒后可重试)`}
</p>
)}
<button
type="submit"
disabled={!canSubmit}
className="w-full inline-flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{submitting && <SpinnerIcon className="w-3.5 h-3.5" />}
{submitting ? '登录中' : locked ? `已锁定 ${countdown}s` : '登录'}
</button>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,137 @@
import { useEffect } from 'react';
import { useMailStore } from '../stores/mailStore';
import { useSessionStore } from '../stores/sessionStore';
import { useUIStore } from '../stores/uiStore';
import type { Mail } from '../types';
import { ShieldIcon, PaperclipIcon } from './icons';
export default function MailList() {
const viewMode = useUIStore(s => s.viewMode);
const cancelCompose = useUIStore(s => s.cancelCompose);
const inbox = useMailStore(s => s.inbox);
const sent = useMailStore(s => s.sent);
const currentMail = useMailStore(s => s.currentMail);
const selectMail = useMailStore(s => s.selectMail);
const fetchInbox = useMailStore(s => s.fetchInbox);
const fetchSent = useMailStore(s => s.fetchSent);
const clearSession = useSessionStore(s => s.clearSession);
useEffect(() => {
if (viewMode === 'sent') fetchSent();
else if (viewMode === 'inbox') fetchInbox('all');
}, [viewMode]);
const isSent = viewMode === 'sent';
const list = isSent ? sent : inbox;
const pick = (m: Mail) => {
clearSession();
cancelCompose();
selectMail(m);
};
return (
<div className="w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col">
<div className="px-4 py-3 border-b border-gray-200 flex items-center">
<h2 className="text-sm font-semibold text-gray-800">{isSent ? '发件箱' : '收件箱'}</h2>
<span className="ml-2 text-xs text-gray-400">{list.length}</span>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-0.5">
{list.map(m => (
<MailItem
key={m.mail_id}
mail={m}
active={currentMail?.mail_id === m.mail_id}
showTo={isSent}
onClick={() => pick(m)}
/>
))}
{list.length === 0 && (
<p className="text-xs text-gray-400 text-center py-6"></p>
)}
</div>
</div>
);
}
function MailItem({
mail,
active,
showTo,
onClick
}: {
mail: Mail;
active: boolean;
showTo: boolean;
onClick: () => void;
}) {
const isPermission = mail.mail_type === 'permission_request';
const isUnread = mail.status === 'unread';
const ccCount = mail.cc_list?.length ?? 0;
const attachCount = mail.attachments?.length ?? 0;
const time = new Date(mail.created_at).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
const peer = showTo
? `${mail.to_name}${mail.to_workspace ? '@' + mail.to_workspace : ''}`
: `${mail.from_name}${mail.from_workspace ? '@' + mail.from_workspace : ''}`;
return (
<button
onClick={onClick}
className={`w-full text-left px-3 py-2.5 rounded-lg border transition-colors ${
active ? 'bg-blue-50 border-blue-200' : 'border-transparent hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2">
<span
className={`text-xs truncate flex-1 font-mono ${
isUnread ? 'font-semibold text-gray-900' : 'text-gray-600'
}`}
>
{showTo ? '→ ' : ''}
{peer}
</span>
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5">
{isUnread && <span className="w-1.5 h-1.5 rounded-full bg-blue-500 shrink-0" />}
{isPermission && (
<span className="shrink-0 inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px] font-medium">
<ShieldIcon className="w-2.5 h-2.5" />
</span>
)}
<span
className={`text-xs truncate ${
isUnread ? 'font-medium text-gray-900' : 'text-gray-500'
}`}
>
{mail.subject}
</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
{mail.session_alias && (
<span className="text-[10px] text-blue-500 font-mono">.{mail.session_alias}</span>
)}
{ccCount > 0 && (
<span className="text-[10px] text-gray-400"> {ccCount}</span>
)}
{attachCount > 0 && (
<span className="inline-flex items-center gap-0.5 text-[10px] text-gray-400">
<PaperclipIcon className="w-2.5 h-2.5" />
{attachCount}
</span>
)}
</div>
</button>
);
}

View File

@ -0,0 +1,578 @@
import { useEffect, useState } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useMailStore } from '../stores/mailStore';
import { useSessionStore } from '../stores/sessionStore';
import { useContactStore } from '../stores/contactStore';
import * as api from '../api/client';
import type { Mail } from '../types';
import { MailIcon, ShieldIcon, PersonIcon, BotIcon, CheckIcon, CloseIcon, ForwardIcon, TreeIcon, TagIcon, GaugeIcon } from './icons';
import AddressInput from './AddressInput';
import { AttachmentList, AttachmentPicker, type PendingAttachment } from './Attachments';
import ThreadView from './ThreadView';
export default function MailView() {
const currentMail = useMailStore(s => s.currentMail);
const markRead = useMailStore(s => s.markRead);
const currentSession = useSessionStore(s => s.currentSession);
const currentSessionMails = useSessionStore(s => s.currentSessionMails);
// 转发面板作用于哪封邮件null = 未打开
const [forwarding, setForwarding] = useState<Mail | null>(null);
// 正在看哪封邮件的对话树null = 看正常的邮件视图
const [threadOf, setThreadOf] = useState<string | null>(null);
// 切换邮件时关掉树视图:树是针对某封邮件的,留着会显示上一封的线索
const currentMailID = currentMail?.mail_id;
useEffect(() => {
setThreadOf(null);
}, [currentMailID]);
if (currentSession && currentSessionMails.length > 0) {
const last = currentSessionMails[currentSessionMails.length - 1];
return (
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
<div className="px-6 py-3 border-b border-gray-200 bg-white">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900 font-mono">
{currentSession.session_alias
? `.${currentSession.session_alias}`
: '(未命名会话)'}
</span>
<StatusBadge status={currentSession.status} />
<span className="text-xs text-gray-400">
{currentSessionMails.length}
</span>
<div className="flex-1" />
<BudgetEditor />
</div>
<p className="text-xs text-gray-500 mt-0.5">{currentSession.subject}</p>
</div>
<RenameProposalBar />
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-3">
{currentSessionMails.map(m => (
<ThreadCard key={m.mail_id} mail={m} />
))}
</div>
<ReplyBar replyTo={last} />
</div>
);
}
if (!currentMail) {
return (
<div className="flex-1 min-w-0 flex items-center justify-center bg-gray-50 text-gray-400">
<div className="text-center">
<MailIcon className="w-10 h-10 mx-auto text-gray-300" />
<p className="text-sm mt-3"></p>
</div>
</div>
);
}
if (threadOf) {
return <ThreadView mailID={threadOf} onClose={() => setThreadOf(null)} />;
}
return (
<div className="flex-1 min-w-0 flex flex-col bg-white">
<Header
mail={currentMail}
onRead={() => markRead(currentMail.mail_id)}
onForward={() => setForwarding(currentMail)}
onThread={() => setThreadOf(currentMail.mail_id)}
/>
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="prose prose-sm max-w-none">
<Markdown remarkPlugins={[remarkGfm]}>{currentMail.body}</Markdown>
</div>
<AttachmentList items={currentMail.attachments ?? []} />
{currentMail.mail_type === 'permission_request' && (
<PermissionPanel mail={currentMail} />
)}
</div>
{forwarding ? (
<ForwardBar mail={forwarding} onClose={() => setForwarding(null)} />
) : (
<ReplyBar replyTo={currentMail} />
)}
</div>
);
}
/**
* 本任务的往返预算编辑器(会话头部)。
*
* 配额最该被编辑的地方就是这里:人看着往来内容才知道这件事还值不值得再来几个回合。
* 放在管理员页面调某个 Agent 的全局配额是另一回事 —— 那管的是「这个 Agent 总共能发多少」,
* 而不是「这件事值得多少个来回」。
*/
function BudgetEditor() {
const budget = useSessionStore(s => s.budget);
const setBudget = useSessionStore(s => s.setBudget);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const [busy, setBusy] = useState(false);
if (!budget) return null;
const exhausted = !budget.unlimited && budget.remaining === 0;
const open = () => {
setDraft(budget.unlimited ? '' : String(budget.max_rounds));
setEditing(true);
};
const commit = async (patch: { max_rounds?: number; reset?: boolean }) => {
setBusy(true);
await setBudget(patch);
setBusy(false);
setEditing(false);
};
if (!editing) {
return (
<button
onClick={open}
title="本任务的往返预算Agent 主动发信的次数上限(自动转发的总结与权限询问不占用)"
className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded border transition-colors ${
exhausted
? 'border-red-200 bg-red-50 text-red-700'
: 'border-gray-200 text-gray-500 hover:border-blue-300 hover:text-blue-600'
}`}
>
<GaugeIcon className="w-3 h-3" />
{budget.unlimited
? '预算不限'
: `${budget.used_rounds}/${budget.max_rounds} 来回${exhausted ? ' · 已用尽' : ''}`}
</button>
);
}
const invalid = draft.trim() !== '' && !/^\d+$/.test(draft.trim());
return (
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-gray-500"></span>
<input
value={draft}
onChange={e => setDraft(e.target.value)}
inputMode="numeric"
placeholder="不限"
autoFocus
className={`w-16 text-xs border rounded px-1.5 py-1 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
invalid ? 'border-red-300' : 'border-gray-300'
}`}
/>
<button
disabled={busy || invalid}
onClick={() => commit({ max_rounds: draft.trim() === '' ? 0 : Number(draft.trim()) })}
className="text-[11px] px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
>
</button>
<button
disabled={busy || budget.used_rounds === 0}
onClick={() => commit({ reset: true })}
title="已用次数归零,上限不变"
className="text-[11px] text-gray-500 hover:text-gray-900 disabled:opacity-30"
>
</button>
<button
onClick={() => setEditing(false)}
className="text-[11px] text-gray-400 hover:text-gray-700"
>
</button>
</div>
);
}
/**
* Agent 提议改会话别名的提示条。
*
* 为什么要人点头而不是让 Agent 直接改:别名是**人**的寻址入口name@path.别名)。
* Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效。
* 提议 + 人确认,既让 Agent 表达意图,又保证寻址稳定性由人掌握。
*/
function RenameProposalBar() {
const proposal = useSessionStore(s => s.renameProposal);
const current = useSessionStore(s => s.currentSession);
const accept = useSessionStore(s => s.acceptRename);
const dismiss = useSessionStore(s => s.dismissRename);
const [busy, setBusy] = useState(false);
if (!proposal) return null;
const from = current?.session_alias ? `.${current.session_alias}` : '(未命名)';
return (
<div className="px-6 py-2.5 bg-blue-50 border-b border-blue-100">
<div className="flex items-start gap-2">
<TagIcon className="w-3.5 h-3.5 text-blue-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs text-blue-900">
Agent <span className="font-mono">{from}</span> {' '}
<span className="font-mono font-semibold">.{proposal.alias}</span>
</p>
{proposal.reason && (
<p className="text-[11px] text-blue-700 mt-0.5">{proposal.reason}</p>
)}
<p className="text-[10px] text-blue-500 mt-0.5">
name@path.{proposal.alias}
Agent
</p>
</div>
<button
disabled={busy}
onClick={async () => {
setBusy(true);
await accept();
setBusy(false);
}}
className="px-2.5 py-1 rounded bg-blue-500 text-white text-xs hover:bg-blue-600 disabled:opacity-50 shrink-0"
>
{busy ? '改名中' : '接受'}
</button>
<button
onClick={dismiss}
className="px-2.5 py-1 rounded border border-blue-200 text-blue-700 text-xs hover:bg-blue-100 shrink-0"
>
</button>
</div>
</div>
);
}
/**
* 转发面板。与回复并列,二者互斥显示 —— 同时开两个输入框会让人不知道自己在写哪个。
* 收件人用与写信页一致的三段式补全,正文引用由服务端生成(保证格式统一)。
*/
function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
const [to, setTo] = useState('');
const [comment, setComment] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchInbox = useMailStore(s => s.fetchInbox);
const fetchSent = useMailStore(s => s.fetchSent);
const fetchSessions = useSessionStore(s => s.fetchSessions);
const fetchContacts = useContactStore(s => s.fetchContacts);
const submit = async () => {
if (!to.trim() || busy) return;
setBusy(true);
setError(null);
try {
await api.forwardMail(mail.mail_id, { to: to.trim(), comment: comment.trim() });
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
return (
<div className="border-t border-gray-200 bg-white px-6 py-3 space-y-2">
<div className="flex items-center gap-2">
<ForwardIcon className="w-3.5 h-3.5 text-gray-500" />
<span className="text-[11px] font-medium text-gray-600">
{mail.subject}
</span>
<div className="flex-1" />
<span className="text-[10px] text-gray-400"></span>
</div>
<AddressInput value={to} onChange={setTo} autoFocus placeholder="新收件人pi@root.new" />
<textarea
value={comment}
onChange={e => setComment(e.target.value)}
placeholder="转发说明(可选,置于引用原文之前)"
className="w-full h-16 text-sm border border-gray-300 rounded-md p-2.5 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
<div className="flex items-center gap-2">
{error && <span className="text-xs text-red-600">{error}</span>}
<div className="flex-1" />
<button onClick={onClose} className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800">
</button>
<button
onClick={submit}
disabled={busy || !to.trim()}
className="px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
{busy ? '转发中' : '转发'}
</button>
</div>
</div>
);
}
function Header({
mail,
onRead,
onForward,
onThread
}: {
mail: Mail;
onRead: () => void;
onForward: () => void;
onThread: () => void;
}) {
const time = new Date(mail.created_at).toLocaleString('zh-CN');
const from = `${mail.from_name}${mail.from_workspace ? '@' + mail.from_workspace : ''}${
mail.session_alias ? '.' + mail.session_alias : ''
}`;
const to = `${mail.to_name}${mail.to_workspace ? '@' + mail.to_workspace : ''}`;
return (
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center gap-2 mb-1.5">
<h2 className="text-sm font-semibold text-gray-900">{mail.subject}</h2>
{mail.status === 'unread' && (
<span className="px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 text-[10px] font-medium">
</span>
)}
{mail.mail_type === 'permission_request' && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 text-[10px] font-medium">
<ShieldIcon className="w-3 h-3" />
</span>
)}
<div className="flex-1" />
{mail.status === 'unread' && (
<button onClick={onRead} className="text-xs text-blue-500 hover:underline">
</button>
)}
<button
onClick={onThread}
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
title="沿回复与转发关系展开整条线索"
>
<TreeIcon className="w-3.5 h-3.5" />
</button>
<button
onClick={onForward}
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
>
<ForwardIcon className="w-3.5 h-3.5" />
</button>
</div>
<dl className="text-xs text-gray-500 space-y-0.5">
<Row label="发件">{from}</Row>
<Row label="收件">{to}</Row>
{mail.cc_list?.length > 0 && (
<Row label="抄送">{mail.cc_list.map(a => a.raw).join('、')}</Row>
)}
<Row label="时间">{time}</Row>
</dl>
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex gap-2">
<dt className="w-8 shrink-0 text-gray-400">{label}</dt>
<dd className="font-mono text-gray-600 break-all">{children}</dd>
</div>
);
}
function ThreadCard({ mail }: { mail: Mail }) {
const isHuman = mail.from_name === 'human';
const isPermission = mail.mail_type === 'permission_request';
const time = new Date(mail.created_at).toLocaleString('zh-CN');
return (
<div
className={`rounded-lg border p-4 ${
isPermission
? 'border-orange-200 bg-orange-50'
: isHuman
? 'border-blue-200 bg-blue-50/60'
: 'border-gray-200 bg-white'
}`}
>
<div className="flex items-center gap-1.5 mb-2 text-xs">
{isHuman ? (
<PersonIcon className="w-3.5 h-3.5 text-blue-600" />
) : (
<BotIcon className="w-3.5 h-3.5 text-slate-600" />
)}
<span className="font-semibold text-gray-800 font-mono">
{isHuman ? 'human' : mail.from_name}
</span>
{isPermission && (
<span className="px-1 py-0.5 rounded bg-orange-200 text-orange-800 text-[9px] font-medium">
</span>
)}
{mail.cc_list?.length > 0 && (
<span className="text-[10px] text-gray-400"> {mail.cc_list.length}</span>
)}
<div className="flex-1" />
<span className="text-[10px] text-gray-400">{time}</span>
</div>
<div className="prose prose-sm max-w-none">
<Markdown remarkPlugins={[remarkGfm]}>{mail.body}</Markdown>
</div>
<AttachmentList items={mail.attachments ?? []} />
{isPermission && <PermissionPanel mail={mail} />}
</div>
);
}
function PermissionPanel({ mail }: { mail: Mail }) {
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
const [decided, setDecided] = useState(mail.permission_result || '');
const fetchInbox = useMailStore(s => s.fetchInbox);
const selectSession = useSessionStore(s => s.selectSession);
const options = mail.permission_options?.length ? mail.permission_options : ['同意', '拒绝'];
const isApprove = (s: string) => /同意|允许|批准|approve|yes/i.test(s);
const decide = async (choice: string) => {
setBusy(true);
try {
await api.decidePermission(mail.mail_id, choice, note || undefined);
setDecided(choice);
await fetchInbox('all');
if (mail.session_id) selectSession(mail.session_id);
} catch (err) {
console.error(err);
} finally {
setBusy(false);
}
};
if (decided) {
return (
<div className="mt-3 pt-2.5 border-t border-orange-200 text-xs text-gray-600">
<strong className="text-gray-800">{decided}</strong>
</div>
);
}
return (
<div className="mt-3 pt-3 border-t border-orange-200">
<div className="flex flex-wrap gap-2">
{options.map(opt => (
<button
key={opt}
onClick={() => decide(opt)}
disabled={busy}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-40 ${
isApprove(opt)
? 'bg-green-600 text-white hover:bg-green-700'
: 'bg-red-50 text-red-700 border border-red-200 hover:bg-red-100'
}`}
>
{isApprove(opt) ? (
<CheckIcon className="w-3.5 h-3.5" />
) : (
<CloseIcon className="w-3.5 h-3.5" />
)}
{opt}
</button>
))}
</div>
<input
value={note}
onChange={e => setNote(e.target.value)}
placeholder="备注(可选)"
className="mt-2 w-full text-xs border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
</div>
);
}
function ReplyBar({ replyTo }: { replyTo?: Mail }) {
const [body, setBody] = useState('');
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const [busy, setBusy] = useState(false);
const fetchInbox = useMailStore(s => s.fetchInbox);
const fetchSent = useMailStore(s => s.fetchSent);
const fetchSessions = useSessionStore(s => s.fetchSessions);
const fetchContacts = useContactStore(s => s.fetchContacts);
const selectSession = useSessionStore(s => s.selectSession);
if (!replyTo) return null;
// 回给对端若这封是我human发的则回给收件人否则回给发件人
const peerName = replyTo.from_name === 'human' ? replyTo.to_name : replyTo.from_name;
const peerPath = replyTo.from_name === 'human' ? replyTo.to_workspace : replyTo.from_workspace;
const target = `${peerName}@${peerPath || ''}${
replyTo.session_alias ? '.' + replyTo.session_alias : ''
}`;
const send = async () => {
if (!body.trim()) return;
setBusy(true);
try {
await api.sendMail(target, `Re: ${replyTo.subject}`, body, {
reply_to: replyTo.mail_id,
attachment_ids: attachments.map(a => a.id)
});
setBody('');
setAttachments([]);
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
if (replyTo.session_id) selectSession(replyTo.session_id);
} catch (err) {
console.error(err);
} finally {
setBusy(false);
}
};
return (
<div className="border-t border-gray-200 bg-white px-6 py-3">
<p className="text-[10px] text-gray-400 mb-1 font-mono"> {target}</p>
<textarea
value={body}
onChange={e => setBody(e.target.value)}
placeholder="回复内容Markdown"
className="w-full h-20 text-sm font-mono border border-gray-300 rounded-md p-3 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
<div className="mt-2">
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={busy} />
</div>
<div className="flex justify-end gap-2 mt-2">
<button
onClick={() => setBody('')}
className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800"
>
</button>
<button
onClick={send}
disabled={busy || !body.trim()}
className="px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
{busy ? '发送中' : '发送'}
</button>
</div>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, { label: string; cls: string }> = {
active: { label: '进行中', cls: 'bg-yellow-100 text-yellow-700' },
waiting: { label: '等待中', cls: 'bg-blue-100 text-blue-700' },
completed: { label: '已完成', cls: 'bg-green-100 text-green-700' },
archived: { label: '已归档', cls: 'bg-gray-200 text-gray-600' }
};
const b = map[status] || map.active;
return <span className={`text-[10px] px-1.5 py-0.5 rounded-full ${b.cls}`}>{b.label}</span>;
}

View File

@ -0,0 +1,130 @@
import { useCallback, useEffect, useState } from 'react';
import * as api from '../api/client';
import { BotIcon, CheckIcon } from './icons';
/**
* Agent 发信配额面板(管理员)。
*
* 配额限制的是 Agent 主动发信的次数,不限制收信 —— 卡住收信只会让邮件凭空消失,
* 卡住发信才能阻止 Agent 无限自我循环。上限 0 表示不限。
*/
export default function QuotaPanel() {
const [quotas, setQuotas] = useState<api.Quota[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const load = useCallback(async () => {
try {
const r = await api.adminListQuotas();
setQuotas(r.quotas);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, []);
useEffect(() => {
load();
}, [load]);
const apply = async (name: string, payload: { max_rounds?: number; reset?: boolean }) => {
setBusy(name);
setError(null);
try {
await api.adminSetQuota(name, payload);
await load();
setDrafts(d => {
const next = { ...d };
delete next[name];
return next;
});
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(null);
}
};
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<BotIcon className="w-4 h-4 text-gray-500" />
<h3 className="text-sm font-semibold text-gray-900">Agent </h3>
<span className="text-xs text-gray-400">{quotas.length}</span>
</div>
<p className="text-[11px] text-gray-500">
Agent 0
Agent
</p>
{error && <div className="text-xs text-red-600">{error}</div>}
{quotas.length === 0 ? (
<div className="text-xs text-gray-400 py-3"> Agent</div>
) : (
<div className="border border-gray-200 rounded-md divide-y divide-gray-100">
{quotas.map(q => {
const draft = drafts[q.agent_name] ?? String(q.max_rounds);
const dirty = draft !== String(q.max_rounds);
const exhausted = !q.unlimited && q.remaining === 0;
return (
<div key={q.agent_name} className="px-3 py-2.5 flex items-center gap-3">
<span className="text-xs font-mono text-gray-900 w-32 shrink-0 truncate">
{q.agent_name}
</span>
<div className="min-w-0 flex-1">
{q.unlimited ? (
<span className="text-xs text-gray-500"> {q.used_rounds}</span>
) : (
<div className="flex items-center gap-2">
<div className="w-28 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full ${exhausted ? 'bg-red-400' : 'bg-blue-400'}`}
style={{
width: `${Math.min(100, (q.used_rounds / Math.max(1, q.max_rounds)) * 100)}%`
}}
/>
</div>
<span
className={`text-[11px] ${exhausted ? 'text-red-600' : 'text-gray-500'}`}
>
{q.used_rounds}/{q.max_rounds}
{exhausted && ' · 已用尽'}
</span>
</div>
)}
</div>
<input
type="number"
min={0}
value={draft}
onChange={e => setDrafts(d => ({ ...d, [q.agent_name]: e.target.value }))}
className="w-16 text-xs border border-gray-300 rounded px-1.5 py-1 shrink-0 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
<button
onClick={() => apply(q.agent_name, { max_rounds: Math.max(0, Number(draft) || 0) })}
disabled={!dirty || busy === q.agent_name}
title="保存上限"
className="shrink-0 text-gray-400 hover:text-blue-600 disabled:opacity-30"
>
<CheckIcon className="w-4 h-4" />
</button>
<button
onClick={() => apply(q.agent_name, { reset: true })}
disabled={busy === q.agent_name || q.used_rounds === 0}
className="shrink-0 text-[11px] text-gray-500 hover:text-gray-900 disabled:opacity-30"
>
</button>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,134 @@
import { useEffect, useRef, useState } from 'react';
import { useAuthStore } from '../stores/authStore';
import * as api from '../api/client';
import { MailboxIcon, SpinnerIcon } from './icons';
/** 首次初始化向导:系统无任何用户时展示,创建首个管理员 */
export default function SetupPage({ onDone }: { onDone: () => void }) {
const bootstrap = useAuthStore(s => s.bootstrap);
const [username, setUsername] = useState('admin');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const nameRef = useRef<HTMLInputElement>(null);
useEffect(() => {
nameRef.current?.focus();
}, []);
const mismatch = confirm !== '' && password !== confirm;
const ok =
username.trim().length >= 2 &&
password.length >= 8 &&
!mismatch &&
!busy;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!ok) return;
setBusy(true);
setError(null);
try {
await api.setupAdmin({
username: username.trim().toLowerCase(),
password,
display_name: displayName.trim()
});
// 初始化后直接登录(后端已经种了 cookie拉取用户态
await bootstrap();
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setBusy(false);
}
};
return (
<div className="h-full flex items-center justify-center bg-slate-100">
<div className="w-[420px] max-w-[92vw] bg-white rounded-xl shadow-sm border border-gray-200 p-8">
<div className="flex flex-col items-center mb-6">
<div className="w-12 h-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center">
<MailboxIcon className="w-6 h-6" />
</div>
<h1 className="mt-3 text-base font-semibold text-gray-900"></h1>
<p className="mt-1 text-xs text-gray-500 text-center">
使 AgentMail
</p>
</div>
<form onSubmit={submit} className="space-y-3">
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1">
<span className="text-red-400"> name </span>
</label>
<input
ref={nameRef}
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="admin"
spellCheck={false}
className="w-full text-sm font-mono border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
<p className="mt-1 text-[10px] text-gray-400">
. _ -2-64 `admin@.new`
</p>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"></label>
<input
value={displayName}
onChange={e => setDisplayName(e.target.value)}
placeholder="系统管理员"
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1">
<span className="text-red-400"> 8 </span>
</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="new-password"
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"></label>
<input
type="password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
autoComplete="new-password"
className={`w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
mismatch ? 'border-red-300' : 'border-gray-300 focus:border-blue-400'
}`}
/>
{mismatch && <p className="mt-1 text-[10px] text-red-500"></p>}
</div>
{error && (
<p className="text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">
{error}
</p>
)}
<button
type="submit"
disabled={!ok}
className="w-full inline-flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{busy && <SpinnerIcon className="w-3.5 h-3.5" />}
{busy ? '初始化中' : '创建管理员并进入'}
</button>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,109 @@
import { useUIStore, type ViewMode } from '../stores/uiStore';
import { useMailStore } from '../stores/mailStore';
import { useContactStore } from '../stores/contactStore';
import { useAuthStore } from '../stores/authStore';
import {
InboxIcon,
SentIcon,
ContactsIcon,
ComposeIcon,
UsersIcon,
LogoutIcon
} from './icons';
const navItems: {
short: string;
title: string;
mode: ViewMode;
Icon: (p: { className?: string }) => JSX.Element;
adminOnly?: boolean;
}[] = [
{ short: '收件', title: '收件箱', mode: 'inbox', Icon: InboxIcon },
{ short: '发件', title: '发件箱', mode: 'sent', Icon: SentIcon },
{ short: '联系', title: '联系人', mode: 'contacts', Icon: ContactsIcon },
{ short: '用户', title: '用户管理', mode: 'admin', Icon: UsersIcon, adminOnly: true }
];
export default function Sidebar() {
const viewMode = useUIStore(s => s.viewMode);
const setViewMode = useUIStore(s => s.setViewMode);
const composing = useUIStore(s => s.composing);
const startCompose = useUIStore(s => s.startCompose);
const inbox = useMailStore(s => s.inbox);
const unread = inbox.filter(m => m.status === 'unread').length;
const contacts = useContactStore(s => s.contacts);
const user = useAuthStore(s => s.user);
const logout = useAuthStore(s => s.logout);
const isAdmin = user?.role === 'admin';
return (
<div className="w-[60px] shrink-0 flex flex-col items-center py-3 gap-1 bg-slate-900">
{navItems
.filter(n => !n.adminOnly || isAdmin)
.map(({ short, title, mode, Icon }) => {
const active = viewMode === mode && !composing;
const badge = mode === 'inbox' ? unread : mode === 'contacts' ? contacts.length : 0;
return (
<button
key={mode}
onClick={() => setViewMode(mode)}
title={title}
className={`relative w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 transition-colors ${
active
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
}`}
>
<Icon />
<span className="text-[9px] leading-none">{short}</span>
{badge > 0 && (
<span
className={`absolute top-0.5 right-1 min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${
mode === 'inbox' ? 'bg-red-500 text-white' : 'bg-slate-600 text-slate-100'
}`}
>
{badge > 99 ? '99+' : badge}
</span>
)}
</button>
);
})}
<div className="flex-1" />
<button
onClick={() => startCompose()}
title="新建邮件"
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 text-white transition-colors ${
composing ? 'bg-blue-500 ring-2 ring-blue-300' : 'bg-blue-600 hover:bg-blue-500'
}`}
>
<ComposeIcon />
<span className="text-[9px] leading-none"></span>
</button>
<div className="mt-2 pt-2 w-full flex flex-col items-center gap-1 border-t border-slate-700">
<button
onClick={() => setViewMode('account')}
title={`${user?.display_name || user?.username}(点击管理账号)`}
className={`w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-semibold transition-colors ${
viewMode === 'account' && !composing
? 'bg-blue-500 text-white'
: 'bg-slate-700 text-slate-200 hover:bg-slate-600'
}`}
>
{(user?.display_name || user?.username || '?').slice(0, 2)}
</button>
<button
onClick={logout}
title="退出登录"
className="w-9 h-7 rounded flex items-center justify-center text-slate-400 hover:text-white hover:bg-slate-800"
>
<LogoutIcon className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,291 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import * as api from '../api/client';
import { useMailStore } from '../stores/mailStore';
import type { ThreadNode } from '../types';
import { CloseIcon, PaperclipIcon, PersonIcon, BotIcon, ShieldIcon, SpinnerIcon } from './icons';
/**
* 对话树视图(按方向分块加载)。
*
* 树由服务端沿 parent_mail_id 展开,因此**可以跨会话** —— 转发把线索引到新会话,
* 但仍属同一条线索。这正是树视图比会话内平铺更有价值的地方:能看出线索分叉去了哪里。
*
* 加载策略:首屏取锚点附近一块,往上滑补祖先、往下滑补子孙,直到两端都取完。
* 一条线索可以有几百封,一次全取要把几 MB 预览塞给前端。
*
* 不用 react-d3-tree 之类的图形库:这里的树又浅又窄(邮件往来通常是一条主链
* 加几个转发分支),缩进 + 连接线足够表达层级,还能直接复用列表的交互与样式,
* 省掉一个渲染 SVG 的依赖和它带来的布局/缩放问题。
*/
export default function ThreadView({ mailID, onClose }: { mailID: string; onClose: () => void }) {
const [nodes, setNodes] = useState<ThreadNode[]>([]);
const [hidden, setHidden] = useState(0);
const [moreUp, setMoreUp] = useState(false);
const [moreDown, setMoreDown] = useState(false);
const [nextUp, setNextUp] = useState(0);
const [nextDown, setNextDown] = useState(0);
const [err, setErr] = useState('');
const [initial, setInitial] = useState(true);
const [loading, setLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const topSentinel = useRef<HTMLDivElement>(null);
const bottomSentinel = useRef<HTMLDivElement>(null);
// 请求代次mailID 变了就作废在飞的响应,避免慢请求后到覆盖新线索
const gen = useRef(0);
// loading 的同步副本。setState 是异步的,两个 sentinel 同时进入视口时
// 读 state 会双双看到 false 而并发发两个请求。
const busy = useRef(false);
const merge = useCallback((incoming: ThreadNode[]) => {
setNodes(prev => {
const seen = new Set(prev.map(n => n.mail_id));
const added = incoming.filter(n => !seen.has(n.mail_id));
// 按相对深度排;同深度保持服务端给的时间序
return [...prev, ...added].sort((a, b) => a.depth - b.depth);
});
}, []);
// 首屏
useEffect(() => {
const myGen = ++gen.current;
setNodes([]);
setHidden(0);
setErr('');
setInitial(true);
busy.current = true;
api
.getMailThread(mailID, { dir: 'around', limit: 40 })
.then(p => {
if (gen.current !== myGen) return;
setNodes(p.nodes.slice().sort((a, b) => a.depth - b.depth));
setHidden(p.hidden);
setMoreUp(p.has_more_up);
setMoreDown(p.has_more_down);
setNextUp(p.next_up);
setNextDown(p.next_down);
})
.catch(e => {
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
})
.finally(() => {
if (gen.current === myGen) {
setInitial(false);
busy.current = false;
}
});
}, [mailID]);
const loadMore = useCallback(
async (dir: 'up' | 'down') => {
if (busy.current) return;
if (dir === 'up' && !moreUp) return;
if (dir === 'down' && !moreDown) return;
const myGen = gen.current;
busy.current = true;
setLoading(true);
// 往上加载会在列表顶部插入内容,浏览器保持 scrollTop 不变 → 视觉上内容整体跳走。
// 记住加载前的「滚动高度」,加载后按增量补偿,让用户视线停在原处。
const el = scrollRef.current;
const beforeHeight = el?.scrollHeight ?? 0;
const beforeTop = el?.scrollTop ?? 0;
try {
const p = await api.getMailThread(mailID, {
dir,
offset: dir === 'up' ? nextUp : nextDown,
limit: 40
});
if (gen.current !== myGen) return;
merge(p.nodes);
setHidden(h => h + p.hidden);
if (dir === 'up') {
setMoreUp(p.has_more_up);
setNextUp(p.next_up);
} else {
setMoreDown(p.has_more_down);
setNextDown(p.next_down);
}
if (dir === 'up' && el) {
// 等这批节点真正渲染出来再补偿,否则读到的还是旧高度
requestAnimationFrame(() => {
el.scrollTop = beforeTop + (el.scrollHeight - beforeHeight);
});
}
} catch (e) {
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
} finally {
if (gen.current === myGen) setLoading(false);
busy.current = false;
}
},
[mailID, merge, moreUp, moreDown, nextUp, nextDown]
);
// 两端各一个哨兵,进入视口就加载对应方向。
// rootMargin 提前 200px 触发,让加载在用户滑到边界前完成。
useEffect(() => {
const root = scrollRef.current;
if (!root) return;
const obs = new IntersectionObserver(
entries => {
for (const e of entries) {
if (!e.isIntersecting) continue;
if (e.target === topSentinel.current) loadMore('up');
if (e.target === bottomSentinel.current) loadMore('down');
}
},
{ root, rootMargin: '200px' }
);
if (topSentinel.current) obs.observe(topSentinel.current);
if (bottomSentinel.current) obs.observe(bottomSentinel.current);
return () => obs.disconnect();
}, [loadMore]);
// 相对深度可能是负数(祖先);缩进按「最浅的那个」归零,
// 否则祖先未加载时首屏内容会整体缩进一大截。
const baseDepth = nodes.length > 0 ? Math.min(...nodes.map(n => n.depth)) : 0;
return (
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
<div className="px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900"></span>
<span className="text-xs text-gray-400">
{nodes.length}
{(moreUp || moreDown) && ',滑动加载更多'}
{hidden > 0 && `${hidden} 封无权查看`}
</span>
<div className="flex-1" />
{loading && <SpinnerIcon className="w-3.5 h-3.5 animate-spin text-gray-400" />}
<button
onClick={onClose}
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
>
<CloseIcon className="w-3.5 h-3.5" />
</button>
</div>
<div ref={scrollRef} className="flex-1 overflow-y-auto px-6 py-4">
{initial && (
<div className="flex items-center gap-2 text-xs text-gray-400">
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
</div>
)}
{err && <p className="text-xs text-red-600">{err}</p>}
{!initial && (
<>
<div ref={topSentinel} className="h-px" />
{moreUp && (
<button
onClick={() => loadMore('up')}
className="w-full mb-2 py-1.5 rounded border border-dashed border-gray-300 text-xs text-gray-500 hover:border-blue-300 hover:text-blue-600"
>
</button>
)}
<div className="space-y-1.5">
{nodes.map(n => (
<Node key={n.mail_id} node={n} baseDepth={baseDepth} anchorID={mailID} />
))}
</div>
{moreDown && (
<button
onClick={() => loadMore('down')}
className="w-full mt-2 py-1.5 rounded border border-dashed border-gray-300 text-xs text-gray-500 hover:border-blue-300 hover:text-blue-600"
>
</button>
)}
<div ref={bottomSentinel} className="h-px" />
</>
)}
</div>
</div>
);
}
function Node({
node,
baseDepth,
anchorID
}: {
node: ThreadNode;
baseDepth: number;
anchorID: string;
}) {
const openMailByID = useMailStore(s => s.openMailByID);
const isPermission = node.mail_type === 'permission_request';
const isAnchor = node.mail_id === anchorID;
// 缩进上限 8 级,再深就不缩了 —— 否则长链条会把卡片挤成竖条
const indent = Math.min(Math.max(node.depth - baseDepth, 0), 8) * 20;
const time = new Date(node.created_at).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
return (
<div className="flex items-stretch" style={{ paddingLeft: indent }}>
{indent > 0 && (
<div className="w-3 shrink-0 border-l border-b border-gray-200 rounded-bl mr-1.5 -mt-1.5 mb-3" />
)}
<button
onClick={() => openMailByID(node.mail_id)}
className={`flex-1 min-w-0 text-left px-3 py-2 rounded-lg border bg-white transition-colors ${
isAnchor ? 'border-blue-300 ring-1 ring-blue-100' : 'border-gray-200 hover:border-blue-300'
}`}
>
<div className="flex items-center gap-1.5">
{node.from_workspace ? (
<BotIcon className="w-3 h-3 text-gray-400 shrink-0" />
) : (
<PersonIcon className="w-3 h-3 text-gray-400 shrink-0" />
)}
<span className="text-xs font-mono text-gray-700 truncate">
{node.from_name}
{node.from_workspace && `@${node.from_workspace}`}
</span>
<span className="text-[10px] text-gray-400"></span>
<span className="text-xs font-mono text-gray-500 truncate">{node.to_name}</span>
<div className="flex-1" />
{node.parent_hidden && (
<span
className="px-1 py-0.5 rounded bg-gray-100 text-gray-500 text-[9px]"
title="上一封不在你的可见范围内"
>
</span>
)}
{isPermission && (
<span className="inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px]">
<ShieldIcon className="w-2.5 h-2.5" />
</span>
)}
{node.attachment_count > 0 && (
<span className="inline-flex items-center gap-0.5 text-[10px] text-gray-400">
<PaperclipIcon className="w-2.5 h-2.5" />
{node.attachment_count}
</span>
)}
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
</div>
<p className="text-xs text-gray-800 mt-0.5 truncate">{node.subject}</p>
{node.body_preview && (
<p className="text-[11px] text-gray-400 mt-0.5 line-clamp-2">{node.body_preview}</p>
)}
{node.session_alias && (
<span className="text-[10px] text-blue-500 font-mono">.{node.session_alias}</span>
)}
</button>
</div>
);
}

View File

@ -0,0 +1,289 @@
// 纯 SVG 图标,全站不使用 emoji
type P = { className?: string };
const D = 'w-5 h-5';
function Svg({ className = D, children }: P & { children: React.ReactNode }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
);
}
export function InboxIcon(p: P) {
return (
<Svg {...p}>
<path d="M22 12h-6l-2 3h-4l-2-3H2" />
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
</Svg>
);
}
export function SentIcon(p: P) {
return (
<Svg {...p}>
<path d="m22 2-7 20-4-9-9-4Z" />
<path d="M22 2 11 13" />
</Svg>
);
}
export function ContactsIcon(p: P) {
return (
<Svg {...p}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</Svg>
);
}
export function ComposeIcon(p: P) {
return (
<Svg {...p}>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</Svg>
);
}
export function MailIcon(p: P) {
return (
<Svg {...p}>
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</Svg>
);
}
export function ShieldIcon(p: P) {
return (
<Svg {...p}>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
</Svg>
);
}
export function PersonIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</Svg>
);
}
export function BotIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<rect width="18" height="10" x="3" y="11" rx="2" />
<circle cx="12" cy="5" r="2" />
<path d="M12 7v4" />
<path d="M8 16h.01" />
<path d="M16 16h.01" />
</Svg>
);
}
export function CheckIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M20 6 9 17l-5-5" />
</Svg>
);
}
export function CloseIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</Svg>
);
}
export function TrashIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M3 6h18" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</Svg>
);
}
export function ArchiveIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<rect width="20" height="5" x="2" y="3" rx="1" />
<path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" />
<path d="M10 12h4" />
</Svg>
);
}
export function ChevronRightIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="m9 18 6-6-6-6" />
</Svg>
);
}
export function LogoutIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
</Svg>
);
}
export function UsersIcon({ className = D }: P) {
return (
<Svg className={className}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</Svg>
);
}
export function LockIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<rect width="18" height="11" x="3" y="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</Svg>
);
}
export function MailboxIcon({ className = 'w-8 h-8' }: P) {
return (
<Svg className={className}>
<path d="M22 17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9.5C2 7 4 5 6.5 5H18c2.2 0 4 1.8 4 4z" />
<path d="M6 8h4" />
<path d="M12 19V5" />
</Svg>
);
}
export function SpinnerIcon({ className = 'w-4 h-4' }: P) {
return (
<svg className={`${className} animate-spin`} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" opacity="0.25" />
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
);
}
export function KeyIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<circle cx="7.5" cy="15.5" r="4.5" />
<path d="M10.7 12.3 21 2" />
<path d="m17 6 3 3" />
<path d="m14 9 3 3" />
</Svg>
);
}
export function CopyIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<rect width="12" height="12" x="9" y="9" rx="2" />
<path d="M5 15V5a2 2 0 0 1 2-2h10" />
</Svg>
);
}
export function PlusIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M12 5v14M5 12h14" />
</Svg>
);
}
export function ForwardIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="m15 17 5-5-5-5" />
<path d="M4 18v-2a4 4 0 0 1 4-4h12" />
</Svg>
);
}
export function PaperclipIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M13.2 2.8a5 5 0 0 1 7 7l-8.5 8.5a3.2 3.2 0 0 1-4.5-4.5l8-8a1.4 1.4 0 0 1 2 2l-7.5 7.5" />
</Svg>
);
}
export function DownloadIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M12 3v12" />
<path d="m7 11 5 5 5-5" />
<path d="M4 20h16" />
</Svg>
);
}
export function FileIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />
<path d="M14 3v5h5" />
</Svg>
);
}
export function TreeIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<rect x="9" y="3" width="6" height="4" rx="1" />
<rect x="3" y="17" width="6" height="4" rx="1" />
<rect x="15" y="17" width="6" height="4" rx="1" />
<path d="M12 7v4M6 17v-3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v3" />
</Svg>
);
}
export function TagIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M20.6 13.4 13.4 20.6a2 2 0 0 1-2.8 0l-7.2-7.2A2 2 0 0 1 3 12V4a1 1 0 0 1 1-1h8a2 2 0 0 1 1.4.6l7.2 7.2a2 2 0 0 1 0 2.8Z" />
<circle cx="7.5" cy="7.5" r="1" />
</Svg>
);
}
export function GaugeIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M12 14 15.5 9" />
<path d="M3.5 17a9 9 0 1 1 17 0" />
<circle cx="12" cy="14" r="1.2" />
</Svg>
);
}

13
web/src/index.css Normal file
View File

@ -0,0 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html, body, #root {
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
}

10
web/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,70 @@
import { create } from 'zustand';
import type { User } from '../types';
import * as api from '../api/client';
import { ApiError } from '../api/client';
type Phase = 'checking' | 'anonymous' | 'authenticated';
interface AuthState {
phase: Phase;
user: User | null;
error: string | null;
retryAfter: number | null;
submitting: boolean;
bootstrap: () => Promise<void>;
login: (username: string, password: string) => Promise<boolean>;
logout: () => Promise<void>;
clearError: () => void;
/** 401 时由 api client 回调 */
markAnonymous: () => void;
}
export const useAuthStore = create<AuthState>(set => ({
phase: 'checking',
user: null,
error: null,
retryAfter: null,
submitting: false,
bootstrap: async () => {
try {
const { user } = await api.me();
set({ phase: 'authenticated', user, error: null });
} catch {
set({ phase: 'anonymous', user: null });
}
},
login: async (username, password) => {
set({ submitting: true, error: null, retryAfter: null });
try {
const { user } = await api.login(username.trim(), password);
set({ phase: 'authenticated', user, submitting: false });
return true;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const retry = err instanceof ApiError ? err.retryAfter ?? null : null;
set({ error: msg, retryAfter: retry, submitting: false });
return false;
}
},
logout: async () => {
try {
await api.logout();
} catch {
/* 即使请求失败也在前端登出 */
}
set({ phase: 'anonymous', user: null, error: null });
},
clearError: () => set({ error: null, retryAfter: null }),
markAnonymous: () => set({ phase: 'anonymous', user: null })
}));
// 注册全局 401 处理:任何接口返回 401 即回到登录页
api.setUnauthorizedHandler(() => {
useAuthStore.getState().markAnonymous();
});

View File

@ -0,0 +1,81 @@
import { create } from 'zustand';
import type { Contact } from '../types';
import * as api from '../api/client';
interface ContactState {
contacts: Contact[];
archivedContacts: Contact[];
showArchived: boolean;
loading: boolean;
error: string | null;
/** 正在等待归档确认的地址 */
pendingArchive: string | null;
fetchContacts: () => Promise<void>;
fetchArchived: () => Promise<void>;
toggleArchivedView: () => void;
requestArchive: (address: string) => void;
cancelArchive: () => void;
archive: (contact: Contact) => Promise<void>;
/** SSE session_archived 到达时本地即时移除 */
removeSessionLocally: (sessionId: string) => void;
}
export const useContactStore = create<ContactState>((set, get) => ({
contacts: [],
archivedContacts: [],
showArchived: false,
loading: false,
error: null,
pendingArchive: null,
fetchContacts: async () => {
set({ loading: true, error: null });
try {
const { contacts } = await api.listContacts(false);
set({ contacts: contacts || [], loading: false });
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err), loading: false });
}
},
fetchArchived: async () => {
try {
const { contacts } = await api.listContacts(true);
set({ archivedContacts: contacts || [] });
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err) });
}
},
toggleArchivedView: () => {
const next = !get().showArchived;
set({ showArchived: next });
if (next) get().fetchArchived();
},
requestArchive: address => set({ pendingArchive: address }),
cancelArchive: () => set({ pendingArchive: null }),
archive: async contact => {
try {
await api.archiveContact({ session_id: contact.session_id });
// 本地即时移除,不等 SSE
set(state => ({
contacts: state.contacts.filter(c => c.session_id !== contact.session_id),
pendingArchive: null
}));
if (get().showArchived) get().fetchArchived();
} catch (err) {
set({
error: err instanceof Error ? err.message : String(err),
pendingArchive: null
});
}
},
removeSessionLocally: sessionId =>
set(state => ({
contacts: state.contacts.filter(c => c.session_id !== sessionId)
}))
}));

View File

@ -0,0 +1,91 @@
import { create } from 'zustand';
import type { Mail } from '../types';
import * as api from '../api/client';
interface MailState {
inbox: Mail[];
sent: Mail[];
currentMail: Mail | null;
loading: boolean;
error: string | null;
fetchInbox: (status?: string) => Promise<void>;
fetchSent: () => Promise<void>;
selectMail: (mail: Mail) => void;
/**
* 按 id 打开邮件。
*
* 列表与对话树里的邮件只带正文预览(整棵树带全文可能几百 KB
* 所以点开时得单取一次拿全文与附件清单。
*/
openMailByID: (id: string) => Promise<void>;
clearCurrentMail: () => void;
markRead: (id: string) => Promise<void>;
/** 某会话归档后,把它的邮件从列表与选中态里剔除 */
dropSession: (sessionId: string) => void;
}
export const useMailStore = create<MailState>((set, get) => ({
inbox: [],
sent: [],
currentMail: null,
loading: false,
error: null,
fetchInbox: async (status = 'all') => {
set({ loading: true, error: null });
try {
const { mails } = await api.getInbox(status);
set({ inbox: mails || [], loading: false });
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err), loading: false });
}
},
fetchSent: async () => {
set({ loading: true, error: null });
try {
const { mails } = await api.getSent();
set({ sent: mails || [], loading: false });
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err), loading: false });
}
},
selectMail: mail => set({ currentMail: mail }),
openMailByID: async id => {
try {
const mail = await api.getMail(id);
set({ currentMail: mail });
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err) });
}
},
clearCurrentMail: () => set({ currentMail: null }),
markRead: async id => {
try {
await api.markMailRead(id);
set(state => ({
inbox: state.inbox.map(m => (m.mail_id === id ? { ...m, status: 'read' as const } : m)),
currentMail:
state.currentMail?.mail_id === id
? { ...state.currentMail, status: 'read' as const }
: state.currentMail
}));
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err) });
}
},
dropSession: sessionId => {
const { currentMail } = get();
set(state => ({
inbox: state.inbox.filter(m => m.session_id !== sessionId),
sent: state.sent.filter(m => m.session_id !== sessionId),
currentMail: currentMail?.session_id === sessionId ? null : currentMail
}));
}
}));

View File

@ -0,0 +1,150 @@
import { create } from 'zustand';
import type { HumanSession, Mail, RenameProposal, Session, SessionBudget } from '../types';
import * as api from '../api/client';
interface SessionState {
sessions: HumanSession[];
currentSession: Session | null;
currentSessionMails: Mail[];
loading: boolean;
error: string | null;
/** Agent 在正文里提的改名建议null = 无待处理建议 */
renameProposal: RenameProposal | null;
fetchSessions: () => Promise<void>;
selectSession: (sessionId: string) => Promise<void>;
clearSession: () => void;
/** 重新拉取当前会话的改名建议(收到新邮件时调) */
refreshRenameProposal: () => Promise<void>;
/** 接受建议:改名成功后清掉提示条并刷新会话 */
acceptRename: () => Promise<void>;
/** 驳回建议:服务端记下来,不再反复弹同一个 */
dismissRename: () => Promise<void>;
/** 本任务的往返预算null = 尚未取到 */
budget: SessionBudget | null;
/** 改本会话预算对话页里随时调。reset 把已用次数归零。 */
setBudget: (patch: { max_rounds?: number; reset?: boolean }) => Promise<void>;
/** 重新拉取预算Agent 发信后剩余会变) */
refreshBudget: () => Promise<void>;
/** 归档后若正查看该会话则退出 */
dropSessionIfCurrent: (sessionId: string) => void;
}
export const useSessionStore = create<SessionState>((set, get) => ({
sessions: [],
currentSession: null,
currentSessionMails: [],
renameProposal: null,
budget: null,
loading: false,
error: null,
fetchSessions: async () => {
set({ loading: true, error: null });
try {
const { sessions } = await api.getHumanSessions();
set({ sessions: sessions || [], loading: false });
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err), loading: false });
}
},
selectSession: async sessionId => {
set({ loading: true, error: null, renameProposal: null, budget: null });
try {
const detail = await api.getSessionDetail(sessionId);
set({
currentSession: detail.session,
currentSessionMails: detail.mails || [],
loading: false
});
// 改名建议与预算单独取:拿不到不该让整个会话打不开
get().refreshRenameProposal();
get().refreshBudget();
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err), loading: false });
}
},
refreshRenameProposal: async () => {
const id = get().currentSession?.session_id;
if (!id) return;
try {
const { proposal } = await api.getRenameProposal(id);
// 期间用户可能已切走,别把上一会话的建议贴到新会话上
if (get().currentSession?.session_id === id) {
set({ renameProposal: proposal });
}
} catch {
// 建议是锦上添花,失败静默
}
},
acceptRename: async () => {
const s = get();
const id = s.currentSession?.session_id;
const alias = s.renameProposal?.alias;
if (!id || !alias) return;
try {
await api.updateSessionAlias(id, alias);
set({ renameProposal: null });
// 别名变了,会话详情与列表里的地址都要跟着更新
await get().selectSession(id);
await get().fetchSessions();
} catch (err) {
// 别名被别人占用409等情况要让用户看到不能默默失败
set({ error: err instanceof Error ? err.message : String(err) });
}
},
dismissRename: async () => {
const id = get().currentSession?.session_id;
if (!id) return;
// 先隐藏提示条再发请求:驳回是纯本地意图,等一个往返才消失显得迟钝
set({ renameProposal: null });
try {
await api.dismissRenameProposal(id);
} catch {
// 记不下来最坏的后果是下次打开又弹一次,不值得打扰用户
}
},
refreshBudget: async () => {
const id = get().currentSession?.session_id;
if (!id) return;
try {
const b = await api.getSessionBudget(id);
// 期间用户可能已切走,别把上一会话的预算贴到新会话上
if (get().currentSession?.session_id === id) set({ budget: b });
} catch {
// 预算读不到不影响看邮件,静默
}
},
setBudget: async patch => {
const id = get().currentSession?.session_id;
if (!id) return;
try {
const b = await api.updateSessionBudget(id, patch);
set({ budget: b });
// 列表里也显示预算,改完要跟着刷新
await get().fetchSessions();
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err) });
}
},
clearSession: () =>
set({ currentSession: null, currentSessionMails: [], renameProposal: null, budget: null }),
dropSessionIfCurrent: sessionId => {
if (get().currentSession?.session_id === sessionId) {
set({ currentSession: null, currentSessionMails: [], renameProposal: null, budget: null });
}
set(state => ({
sessions: state.sessions.filter(s => s.session_id !== sessionId)
}));
}
}));

29
web/src/stores/uiStore.ts Normal file
View File

@ -0,0 +1,29 @@
import { create } from 'zustand';
export type ViewMode = 'inbox' | 'sent' | 'contacts' | 'admin' | 'account';
interface UIState {
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
/** 右侧主区域是否处于「新建邮件」整页编写态 */
composing: boolean;
composePrefill: { to?: string; cc?: string } | null;
startCompose: (prefill?: { to?: string; cc?: string }) => void;
cancelCompose: () => void;
/** 登出后重置回默认视图 */
reset: () => void;
}
export const useUIStore = create<UIState>(set => ({
viewMode: 'inbox',
setViewMode: mode => set({ viewMode: mode, composing: false, composePrefill: null }),
composing: false,
composePrefill: null,
startCompose: prefill => set({ composing: true, composePrefill: prefill ?? null }),
cancelCompose: () => set({ composing: false, composePrefill: null }),
reset: () => set({ viewMode: 'inbox', composing: false, composePrefill: null })
}));

212
web/src/types/index.ts Normal file
View File

@ -0,0 +1,212 @@
export interface User {
user_id: string;
username: string;
display_name: string;
role: 'admin' | 'user';
status: 'active' | 'disabled';
allowed_agents: string[];
allowed_paths: string[];
last_login?: string;
created_at?: string;
}
export interface Workspace {
name: string;
path: string;
}
export interface Agent {
agent_id?: string;
agent_name: string;
workspaces: Workspace[];
platform: string;
status: string;
}
export interface Address {
name: string;
path: string;
session: string;
raw: string;
}
export interface Session {
session_id: string;
session_alias: string | null;
from_agent: string;
subject: string;
status: string;
created_at: string;
updated_at: string;
mail_count?: number;
/**
* 别名是谁定的:
* platform = Agent 平台自动同步来的,后续同步可以覆盖
* manual = 人显式指定(手工改名或接受了 Agent 的提议),平台同步不得覆盖
*/
alias_source?: 'platform' | 'manual';
/** 用户驳回过的改名提议 */
rename_dismissed?: string;
/**
* 本任务的往返预算0 = 本会话不限,仅受 Agent 全局配额约束)。
*
* 配额的语义是「这件事值得多少个来回」—— 那是任务的属性而非 Agent 的属性,
* 所以在写信时给、在对话页里随时调,而不是去管理员页面改某个 Agent 的全局配额。
*/
max_rounds?: number;
used_rounds?: number;
}
/** 会话往返预算快照 */
export interface SessionBudget {
session_id: string;
max_rounds: number;
used_rounds: number;
/** 不限时为 -1 */
remaining: number;
unlimited: boolean;
}
/** 附件元数据。内容存盘,按 sha256 内容寻址;同内容重复上传不占额外空间。 */
export interface Attachment {
attachment_id: string;
/** 为 null 表示已上传但尚未随邮件发出 */
mail_id: string | null;
uploader: string;
filename: string;
content_type: string;
size_bytes: number;
sha256: string;
created_at: string;
}
export interface Mail {
mail_id: string;
session_id: string;
parent_mail_id: string | null;
from_name: string;
from_workspace: string;
to_name: string;
to_workspace: string;
cc_list: Address[];
subject: string;
body: string;
mail_type: 'normal' | 'permission_request';
permission_options: string[] | null;
permission_result: string | null;
status: 'unread' | 'read' | 'archived';
created_at: string;
hop_limit?: number;
session_alias?: string;
body_preview?: string;
attachments?: Attachment[];
}
/**
* 对话树节点。
*
* 树由 mails.parent_mail_id 编码:回复指向来信,转发指向被转发的原件。
* 因此树可以跨会话 —— 转发把线索引到新会话,却仍属同一条线索。
*
* depth 是**相对锚点**的层级0 = 锚点,负数 = 祖先,正数 = 子孙。
* 分块加载时根可能还没取到,所以不用「距根深度」。
*/
export interface ThreadNode extends Omit<Mail, 'body'> {
depth: number;
attachment_count: number;
/** 父邮件不在当前已加载集合里(无权查看,或还没滑到) */
detached?: boolean;
/** 父邮件确实存在但无权查看(区别于「尚未加载」,后者会随上滑补齐) */
parent_hidden?: boolean;
body?: string;
}
export interface ThreadPage {
anchor_mail_id: string;
dir: 'around' | 'up' | 'down';
nodes: ThreadNode[];
total: number;
/** 因权限被过滤掉的节点数 */
hidden: number;
has_more_up: boolean;
has_more_down: boolean;
/** 下一页 offset原样回传即可 */
next_up: number;
next_down: number;
}
/**
* Agent 在邮件正文里提议的新会话别名。
*
* 为什么是提议而不是 Agent 直接改:别名是**人**的寻址入口
* name@path.别名。Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效。
* 提议 + 人点头,既让 Agent 表达意图,又保证寻址稳定性由人掌握。
*/
export interface RenameProposal {
/** 已由服务端规范化,可直接提交给 PUT /sessions/:id/alias */
alias: string;
reason?: string;
}
export interface Contact {
session_id: string;
agent_name: string;
path: string;
session_alias: string;
address: string;
status: string;
mail_count: number;
unread_count: number;
last_activity: string;
}
export interface PermissionRequest {
request_id: string;
mail_id: string;
session_id: string;
agent_name: string;
question: string;
options: string[];
context: string;
result: string | null;
decided_at: string | null;
created_at: string;
}
export interface SessionDetail {
session: Session;
mails: Mail[];
}
export interface HumanSession {
session_id: string;
session_alias: string | null;
from_agent: string;
subject: string;
status: string;
created_at: string;
updated_at: string;
mail_count: number;
unread_count: number;
/** 本任务的往返预算0 = 不限) */
max_rounds?: number;
used_rounds?: number;
}
export type SuggestKind = 'name' | 'path' | 'session';
export interface SuggestResult {
kind: SuggestKind;
suggestions: string[];
}
/** 系统初始化状态 */
export interface SetupStatus {
needs_setup: boolean;
}
/** 管理员可授权范围候选 */
export interface AdminScopes {
agents: string[];
paths: string[];
}

11
web/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
/// <reference types="vite/client" />
/** 构建期可注入的环境变量 */
interface ImportMetaEnv {
/** API 基地址;不设则用同源 /api/v1 */
readonly VITE_API_BASE?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

8
web/tailwind.config.js Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {}
},
plugins: []
};

View File

@ -0,0 +1,43 @@
// 回归测试:确认邮件正文的 Markdown 渲染不会执行注入的脚本。
// react-markdown 默认不解析 raw HTML无 rehype-raw且用 defaultUrlTransform
// 清空非 http(s)/mailto 协议的 URL —— 本测试守住这两个前提,防止日后有人
// 为了「支持 HTML 邮件」顺手加上 rehype-raw 而不自觉地开了 XSS 口子。
//
// 运行node test/markdown-xss.test.mjs
import { renderToStaticMarkup } from 'react-dom/server';
import React from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
// 只有【真实标签】里的危险内容才算漏。
// 注意不能直接搜 onerror=raw HTML 被转义成 &lt;img … onerror=&quot; 后,
// 文本里仍含该字样但已无执行能力,按标签边界匹配才不会误报。
const dangerous = /<(script|iframe|object|embed)\b|<[a-z][^>]*\son[a-z]+\s*=|<[a-z][^>]*(href|src)\s*=\s*"javascript:/i;
const payloads = [
'<script>alert(1)</script>',
'<img src=x onerror="alert(1)">',
'[click](javascript:alert(1))',
'<a href="javascript:alert(1)">x</a>',
'<iframe src="https://evil.com"></iframe>',
'![x](javascript:alert(1))',
'<div onmouseover="alert(1)">hover</div>',
'[ok](https://example.com)',
'**bold** `code`',
];
let leaks = 0;
for (const p of payloads) {
const html = renderToStaticMarkup(
React.createElement(Markdown, { remarkPlugins: [remarkGfm] }, p)
);
const bad = dangerous.test(html);
if (bad) leaks++;
console.log((bad ? 'LEAK ' : 'safe '), JSON.stringify(p), '->', html.slice(0, 80));
}
if (leaks > 0) {
console.error(`\n失败:${leaks} 处 XSS 泄漏`);
process.exit(1);
}
console.log('\n通过raw HTML 被转义javascript: URL 被清空');

20
web/tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

16
web/vite.config.ts Normal file
View File

@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
// Gateway 默认监听 8180与 config.Load() 的 PORT 默认值一致)
'/api': {
target: 'http://localhost:8180',
changeOrigin: true
}
}
}
});