chore: directory migration - gateway→server, web→client/electron

This commit is contained in:
2026-09-08 19:16:35 +08:00
parent fd9f99a3f9
commit f9d757b5e5
243 changed files with 5095 additions and 228 deletions

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,113 @@
import { create } from 'zustand';
import type { Contact } from '../types';
import * as api from '../api/client';
/** 中间栏的呈现方式:列表(紧凑,答"跟谁在聊")或卡片(答"在聊什么、进展如何") */
export type ContactView = 'list' | 'card';
const VIEW_KEY = 'agentmail.contactView';
/**
* 视图偏好存 localStorage。
*
* 这是纯展示偏好,不值得为它建一张表、加一个 API —— 而每次刷新都退回默认视图
* 会让人反复点同一个按钮。读取时容错:localStorage 在隐私模式下可能抛异常。
*/
function loadView(): ContactView {
try {
return localStorage.getItem(VIEW_KEY) === 'card' ? 'card' : 'list';
} catch {
return 'list';
}
}
interface ContactState {
contacts: Contact[];
archivedContacts: Contact[];
showArchived: boolean;
loading: boolean;
error: string | null;
/** 正在等待归档确认的地址 */
pendingArchive: string | null;
/** 中间栏呈现方式(持久化到 localStorage) */
view: ContactView;
setView: (v: ContactView) => void;
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,
view: loadView(),
setView: v => {
set({ view: v });
try {
localStorage.setItem(VIEW_KEY, v);
} catch {
/* 存不下只是下次回到默认视图,不该让切换本身失败 */
}
},
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,168 @@
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 把已用次数归零。 */
/** 改本会话预算(对话页里随时调)。reset 把已用次数归零。 */
setBudget: (patch: { max_rounds?: number; reset?: boolean }) => Promise<void>;
/** 改本会话权限档位(对话页里随时调)。人是权限的源头,可以任改三档。 */
setPermissionMode: (mode: string) => 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) });
}
},
setPermissionMode: async mode => {
const id = get().currentSession?.session_id;
if (!id) return;
try {
const res = await api.updateSessionPermission(id, mode);
const cur = get().currentSession;
if (cur && cur.session_id === id) {
set({ currentSession: { ...cur, permission_mode: res.permission_mode, permission_enforcement: res.permission_enforcement } });
}
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)
}));
}
}));

View File

@ -0,0 +1,114 @@
import { create } from 'zustand';
/**
* 主题偏好。
*
* 三态而不是「开/关」:`system` 是有意义的第三个值,不是 light 的别名。
* 只给开关的话,用户在白天设成浅色之后,晚上系统切深色时应用不会跟着变 ——
* 而那恰恰是大多数人想要的默认行为。
*/
export type ThemePref = 'light' | 'dark' | 'system';
/** 实际生效的主题(system 解析之后的结果)。 */
export type ResolvedTheme = 'light' | 'dark';
const STORAGE_KEY = 'agentmail.theme';
const DARK_QUERY = '(prefers-color-scheme: dark)';
function readStored(): ThemePref {
try {
const v = localStorage.getItem(STORAGE_KEY);
if (v === 'light' || v === 'dark' || v === 'system') return v;
} catch {
// 隐私模式下 localStorage 抛异常。跟随系统是最安全的退路 ——
// 硬编码 light 会让深色偏好的用户每次开页面都被闪一下白屏
}
return 'system';
}
function systemPrefersDark(): boolean {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia(DARK_QUERY).matches;
}
export function resolveTheme(pref: ThemePref): ResolvedTheme {
if (pref === 'system') return systemPrefersDark() ? 'dark' : 'light';
return pref;
}
/**
* 把主题写进 DOM。
*
* 类名挂在 `<html>` 而不是 `<body>`:tailwind 的 darkMode:'class' 默认
* 从根元素找,而且 `<html>` 上的 background-color 才管得到 overscroll
* 露出的那一片。
*/
function apply(resolved: ResolvedTheme) {
if (typeof document === 'undefined') return;
const root = document.documentElement;
root.classList.toggle('dark', resolved === 'dark');
// 让浏览器把滚动条、表单控件、autofill 背景一并切换。
// 不设的话深色页面上会出现一条浅色滚动条与白底的自动填充输入框。
root.style.colorScheme = resolved;
}
interface ThemeState {
pref: ThemePref;
resolved: ResolvedTheme;
setPref: (p: ThemePref) => void;
/** 在 light / dark 间直接翻转(顶栏那个按钮用)。 */
toggle: () => void;
}
export const useThemeStore = create<ThemeState>((set, get) => ({
pref: readStored(),
resolved: resolveTheme(readStored()),
setPref: p => {
const resolved = resolveTheme(p);
apply(resolved);
try {
localStorage.setItem(STORAGE_KEY, p);
} catch {
// 存不下不影响本次会话
}
set({ pref: p, resolved });
},
/**
* 翻转。
*
* 从 `system` 翻转时落到「与当前生效值相反」的显式值,而不是回到
* system —— 人点这个按钮的意图是「现在换个样子」,把它变成
* system→light(可能毫无变化)会让按钮看起来坏了。
*/
toggle: () => {
const next: ThemePref = get().resolved === 'dark' ? 'light' : 'dark';
get().setPref(next);
}
}));
/**
* 启动时立刻套用主题,并订阅系统变化。
*
* 在 main.tsx 里于 render 之前调用:晚一步就会让深色偏好的用户
* 看到一帧白色闪屏。
*
* 返回取消订阅函数(实际不会用到 —— 应用生命周期内一直需要监听)。
*/
export function initTheme(): () => void {
const store = useThemeStore.getState();
apply(store.resolved);
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mq = window.matchMedia(DARK_QUERY);
const onChange = () => {
// 只有 pref 为 system 时才跟随系统。显式选了 light/dark 的人
// 不该因为日落而被切换主题。
const { pref, setPref } = useThemeStore.getState();
if (pref === 'system') setPref('system');
};
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}

View File

@ -0,0 +1,61 @@
import { create } from 'zustand';
export type ViewMode = 'inbox' | 'sent' | 'permissions' | 'calendar' | '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;
/**
* 窄屏下当前显示哪一栏。
*
* 宽屏是「列表 + 详情」并排,窄屏放不下,只能一次显示一栏:
* 选中邮件 → 切到 detail,点返回 → 回 list。
*
* 这个状态在宽屏下**也维护**(只是不影响渲染):否则从窄屏拖宽再拖回来,
* 用户会发现自己回到了列表页,刚打开的邮件不见了。
*/
narrowPane: 'list' | 'detail';
showDetail: () => void;
showList: () => void;
/** 登出后重置回默认视图 */
reset: () => void;
}
export const useUIStore = create<UIState>(set => ({
viewMode: 'inbox',
// 切换主视图时回到列表栏:窄屏下停在上一封邮件的详情页会让人不知道自己在哪
setViewMode: mode =>
set({
viewMode: mode,
composing: false,
composePrefill: null,
narrowPane: 'list'
}),
composing: false,
composePrefill: null,
// 写信占满整个主区域,窄屏下等价于切到 detail 栏
startCompose: prefill =>
set({ composing: true, composePrefill: prefill ?? null, narrowPane: 'detail' }),
cancelCompose: () => set({ composing: false, composePrefill: null, narrowPane: 'list' }),
narrowPane: 'list',
showDetail: () => set({ narrowPane: 'detail' }),
showList: () => set({ narrowPane: 'list' }),
reset: () =>
set({
viewMode: 'inbox',
composing: false,
composePrefill: null,
narrowPane: 'list'
})
}));