chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
191
client/harmony/entry/src/main/ets/api/AccountManager.ets
Normal file
191
client/harmony/entry/src/main/ets/api/AccountManager.ets
Normal file
@ -0,0 +1,191 @@
|
||||
/*
|
||||
* AgentMail 鸿蒙客户端 — 多账号管理器
|
||||
* 支持多账号存储、切换、删除
|
||||
* 每个账号存储: server, username, token, displayName
|
||||
*/
|
||||
import { preferences } from '@kit.ArkData';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
|
||||
const DOMAIN = 0x0001;
|
||||
const TAG = 'AccountManager';
|
||||
const PREF_NAME = 'agentmail_accounts';
|
||||
const KEY_ACCOUNTS = 'accounts_json';
|
||||
const KEY_ACTIVE = 'active_account_id';
|
||||
|
||||
/** 单个账号信息 */
|
||||
export class AccountInfo {
|
||||
id: string = '';
|
||||
server: string = '';
|
||||
username: string = '';
|
||||
token: string = '';
|
||||
displayName: string = '';
|
||||
createdAt: number = 0;
|
||||
}
|
||||
|
||||
export class AccountManager {
|
||||
private static instance: AccountManager | null = null;
|
||||
private context: Context;
|
||||
private accounts: AccountInfo[] = [];
|
||||
private activeId: string = '';
|
||||
private loaded: boolean = false;
|
||||
|
||||
static getInstance(context: Context): AccountManager {
|
||||
if (AccountManager.instance === null || AccountManager.instance.context !== context) {
|
||||
AccountManager.instance = new AccountManager(context);
|
||||
}
|
||||
return AccountManager.instance;
|
||||
}
|
||||
|
||||
private constructor(context: Context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
/** 从 preferences 加载账号列表 */
|
||||
async load(): Promise<void> {
|
||||
if (this.loaded) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pref = await preferences.getPreferences(this.context, PREF_NAME);
|
||||
const accountsJson: string = pref.getSync(KEY_ACCOUNTS, '[]') as string;
|
||||
this.accounts = JSON.parse(accountsJson) as AccountInfo[];
|
||||
this.activeId = pref.getSync(KEY_ACTIVE, '') as string;
|
||||
// 如果有账号但没有活跃账号,默认选第一个
|
||||
if (this.accounts.length > 0 && this.activeId.length === 0) {
|
||||
this.activeId = this.accounts[0].id;
|
||||
}
|
||||
hilog.info(DOMAIN, TAG, 'loaded %{public}d accounts, active: %{public}s', this.accounts.length, this.activeId);
|
||||
} catch (e) {
|
||||
this.accounts = [];
|
||||
this.activeId = '';
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
/** 持久化账号列表 */
|
||||
private async persist(): Promise<void> {
|
||||
try {
|
||||
const pref = await preferences.getPreferences(this.context, PREF_NAME);
|
||||
pref.putSync(KEY_ACCOUNTS, JSON.stringify(this.accounts));
|
||||
pref.putSync(KEY_ACTIVE, this.activeId);
|
||||
await pref.flush();
|
||||
} catch (e) {
|
||||
hilog.error(DOMAIN, TAG, 'persist failed');
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取所有账号。返回副本,避免页面直接改动内部持久化数组。 */
|
||||
getAccounts(): AccountInfo[] {
|
||||
return this.accounts.slice();
|
||||
}
|
||||
|
||||
/** 获取当前活跃账号 */
|
||||
getActiveAccount(): AccountInfo | null {
|
||||
for (let i = 0; i < this.accounts.length; i++) {
|
||||
if (this.accounts[i].id === this.activeId) {
|
||||
return this.accounts[i];
|
||||
}
|
||||
}
|
||||
if (this.accounts.length > 0) {
|
||||
return this.accounts[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 获取当前活跃账号 ID */
|
||||
getActiveId(): string {
|
||||
return this.activeId;
|
||||
}
|
||||
|
||||
/** 按 ID 获取账号;路由携带来源账号时使用。 */
|
||||
getAccount(accountId: string): AccountInfo | null {
|
||||
for (let i = 0; i < this.accounts.length; i++) {
|
||||
if (this.accounts[i].id === accountId) {
|
||||
return this.accounts[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 添加新账号 */
|
||||
async addAccount(server: string, username: string, token: string, displayName: string): Promise<AccountInfo> {
|
||||
const account: AccountInfo = new AccountInfo();
|
||||
account.id = this.generateId();
|
||||
account.server = server;
|
||||
account.username = username;
|
||||
account.token = token;
|
||||
account.displayName = displayName.length > 0 ? displayName : username;
|
||||
account.createdAt = Date.now();
|
||||
|
||||
this.accounts.push(account);
|
||||
// 如果是第一个账号,自动设为活跃
|
||||
if (this.accounts.length === 1) {
|
||||
this.activeId = account.id;
|
||||
}
|
||||
await this.persist();
|
||||
hilog.info(DOMAIN, TAG, 'added account: %{public}s', account.username);
|
||||
return account;
|
||||
}
|
||||
|
||||
/** 删除账号 */
|
||||
async removeAccount(accountId: string): Promise<boolean> {
|
||||
const idx: number = this.findIndex(accountId);
|
||||
if (idx < 0) {
|
||||
return false;
|
||||
}
|
||||
this.accounts.splice(idx, 1);
|
||||
// 如果删除的是活跃账号,切换到第一个
|
||||
if (this.activeId === accountId) {
|
||||
this.activeId = this.accounts.length > 0 ? this.accounts[0].id : '';
|
||||
}
|
||||
await this.persist();
|
||||
hilog.info(DOMAIN, TAG, 'removed account: %{public}s', accountId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 切换活跃账号 */
|
||||
async switchAccount(accountId: string): Promise<boolean> {
|
||||
const idx: number = this.findIndex(accountId);
|
||||
if (idx < 0) {
|
||||
return false;
|
||||
}
|
||||
this.activeId = accountId;
|
||||
await this.persist();
|
||||
hilog.info(DOMAIN, TAG, 'switched to: %{public}s', accountId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 更新账号信息(如 token 过期重新登录) */
|
||||
async updateAccount(accountId: string, token: string, displayName: string): Promise<boolean> {
|
||||
const idx: number = this.findIndex(accountId);
|
||||
if (idx < 0) {
|
||||
return false;
|
||||
}
|
||||
this.accounts[idx].token = token;
|
||||
if (displayName.length > 0) {
|
||||
this.accounts[idx].displayName = displayName;
|
||||
}
|
||||
await this.persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 获取账号数量 */
|
||||
getCount(): number {
|
||||
return this.accounts.length;
|
||||
}
|
||||
|
||||
private findIndex(accountId: string): number {
|
||||
for (let i = 0; i < this.accounts.length; i++) {
|
||||
if (this.accounts[i].id === accountId) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
const now: number = Date.now();
|
||||
const rand: number = Math.floor(Math.random() * 10000);
|
||||
return 'acct_' + now.toString() + '_' + rand.toString();
|
||||
}
|
||||
}
|
||||
291
client/harmony/entry/src/main/ets/api/ApiClient.ets
Normal file
291
client/harmony/entry/src/main/ets/api/ApiClient.ets
Normal file
@ -0,0 +1,291 @@
|
||||
/*
|
||||
* AgentMail 鸿蒙客户端 — 统一 API 客户端
|
||||
* 对应 WebUI src/api/client.ts:base + headers + 错误归一化 + 401 统一回落登录
|
||||
*/
|
||||
import { http } from '@kit.NetworkKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { DEFAULT_API_BASE, PREF_KEY_API_BASE, PREF_KEY_TOKEN } from '../common/Config';
|
||||
import { preferences } from '@kit.ArkData';
|
||||
|
||||
const DOMAIN = 0x0001;
|
||||
const TAG = 'AgentMailClient';
|
||||
|
||||
/** 统一错误类型:code = HTTP 状态码,message = 服务端中文文案 */
|
||||
export class ApiError extends Error {
|
||||
code: number = 0;
|
||||
message: string = '';
|
||||
|
||||
constructor(code: number, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
|
||||
/** 空响应 2xx 的占位类型 */
|
||||
export class EmptyResult {
|
||||
ok: boolean = true;
|
||||
}
|
||||
|
||||
/** 请求选项 */
|
||||
export class RequestOptions {
|
||||
method: string = 'GET';
|
||||
path: string = '';
|
||||
body: string = '';
|
||||
query: string = '';
|
||||
useKeyAuth: boolean = true;
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private static instance: ApiClient | null = null;
|
||||
private apiBase: string = DEFAULT_API_BASE;
|
||||
private token: string = '';
|
||||
private context: Context;
|
||||
/** 复用的 HTTP 请求实例:保持 Cookie 会话(登录后跨请求有效) */
|
||||
private httpRequest: http.HttpRequest | null = null;
|
||||
|
||||
static getInstance(context: Context): ApiClient {
|
||||
if (ApiClient.instance === null || ApiClient.instance.context !== context) {
|
||||
if (ApiClient.instance !== null && ApiClient.instance.context !== context) {
|
||||
// 不同 context 重建(测试环境),否则复用
|
||||
}
|
||||
ApiClient.instance = new ApiClient(context);
|
||||
}
|
||||
return ApiClient.instance;
|
||||
}
|
||||
|
||||
constructor(context: Context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
/** 初始化:从 preferences 读 apiBase 与 token */
|
||||
async init(): Promise<void> {
|
||||
try {
|
||||
const pref = await preferences.getPreferences(this.context, 'agentmail');
|
||||
this.apiBase = pref.getSync(PREF_KEY_API_BASE, DEFAULT_API_BASE) as string;
|
||||
this.token = pref.getSync(PREF_KEY_TOKEN, '') as string;
|
||||
} catch (e) {
|
||||
this.apiBase = DEFAULT_API_BASE;
|
||||
this.token = '';
|
||||
}
|
||||
}
|
||||
|
||||
getBase(): string {
|
||||
return this.apiBase;
|
||||
}
|
||||
|
||||
setBase(base: string): void {
|
||||
this.apiBase = base;
|
||||
}
|
||||
|
||||
getToken(): string {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
setToken(token: string): void {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
/** 持久化凭证 */
|
||||
async persistToken(token: string): Promise<void> {
|
||||
this.token = token;
|
||||
try {
|
||||
const pref = await preferences.getPreferences(this.context, 'agentmail');
|
||||
pref.putSync(PREF_KEY_TOKEN, token);
|
||||
await pref.flush();
|
||||
} catch (e) {
|
||||
// 持久化失败不阻断登录
|
||||
}
|
||||
}
|
||||
|
||||
async persistBase(base: string): Promise<void> {
|
||||
this.apiBase = base;
|
||||
try {
|
||||
const pref = await preferences.getPreferences(this.context, 'agentmail');
|
||||
pref.putSync(PREF_KEY_API_BASE, base);
|
||||
await pref.flush();
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
/** 统一请求入口 */
|
||||
async request<T>(opts: RequestOptions): Promise<T> {
|
||||
const url: string = this.apiBase + opts.path + (opts.query.length > 0 ? '?' + opts.query : '');
|
||||
// 复用会话级 http 实例,保住 Cookie(login 后 set-cookie 才能用于后续请求)
|
||||
let httpRequest: http.HttpRequest;
|
||||
if (this.httpRequest === null) {
|
||||
httpRequest = http.createHttp();
|
||||
this.httpRequest = httpRequest;
|
||||
} else {
|
||||
httpRequest = this.httpRequest;
|
||||
}
|
||||
|
||||
try {
|
||||
const header: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (this.token.length > 0 && opts.useKeyAuth) {
|
||||
header['Authorization'] = 'Bearer ' + this.token;
|
||||
}
|
||||
|
||||
hilog.info(DOMAIN, TAG, '→ %{public}s %{public}s', opts.method, url);
|
||||
const response = await httpRequest.request(url, {
|
||||
method: opts.method as http.RequestMethod,
|
||||
header: header,
|
||||
extraData: opts.body.length > 0 ? opts.body : undefined,
|
||||
connectTimeout: 15000,
|
||||
readTimeout: 30000
|
||||
});
|
||||
|
||||
const code = response.responseCode;
|
||||
const rawText = response.result as string;
|
||||
|
||||
if (code >= 200 && code < 300) {
|
||||
if (rawText.length === 0) {
|
||||
const empty = new EmptyResult();
|
||||
return empty as T;
|
||||
}
|
||||
return JSON.parse(rawText) as T;
|
||||
}
|
||||
|
||||
// 错误归一化:从服务端 {"error": "..."} 取文案
|
||||
let message: string = 'HTTP ' + code;
|
||||
try {
|
||||
const parsed = JSON.parse(rawText) as Record<string, string>;
|
||||
if (parsed['error'] !== undefined) {
|
||||
message = parsed['error'];
|
||||
}
|
||||
} catch (e) {
|
||||
message = rawText.length > 0 ? rawText : ('HTTP ' + code);
|
||||
}
|
||||
|
||||
if (code === 401) {
|
||||
// 清除本地凭证,交由登录页处理
|
||||
this.clearAuth();
|
||||
}
|
||||
|
||||
throw new ApiError(code, message);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
throw e as ApiError;
|
||||
}
|
||||
const be = e as BusinessError;
|
||||
const msg: string = be.message !== undefined ? be.message : '网络错误';
|
||||
hilog.error(DOMAIN, TAG, '← %{public}s failed: %{public}s', url, msg);
|
||||
throw new ApiError(0, msg);
|
||||
}
|
||||
// 不复用销毁:会话级实例保留 Cookie
|
||||
}
|
||||
|
||||
/** GET 便捷 */
|
||||
async get<T>(path: string, query?: string): Promise<T> {
|
||||
const opts = new RequestOptions();
|
||||
opts.method = 'GET';
|
||||
opts.path = path;
|
||||
opts.query = query ?? '';
|
||||
return this.request<T>(opts);
|
||||
}
|
||||
|
||||
/** POST 便捷(JSON body) */
|
||||
async post<T>(path: string, bodyObj: Object, useKeyAuth?: boolean): Promise<T> {
|
||||
const opts = new RequestOptions();
|
||||
opts.method = 'POST';
|
||||
opts.path = path;
|
||||
opts.body = JSON.stringify(bodyObj);
|
||||
opts.useKeyAuth = useKeyAuth ?? true;
|
||||
return this.request<T>(opts);
|
||||
}
|
||||
|
||||
/** PUT 便捷 */
|
||||
async put<T>(path: string, bodyObj: Object): Promise<T> {
|
||||
const opts = new RequestOptions();
|
||||
opts.method = 'PUT';
|
||||
opts.path = path;
|
||||
opts.body = JSON.stringify(bodyObj);
|
||||
return this.request<T>(opts);
|
||||
}
|
||||
|
||||
/** DELETE 便捷 */
|
||||
async del<T>(path: string): Promise<T> {
|
||||
const opts = new RequestOptions();
|
||||
opts.method = 'DELETE';
|
||||
opts.path = path;
|
||||
return this.request<T>(opts);
|
||||
}
|
||||
|
||||
/** 上传文件(multipart/form-data)→ attachment_id */
|
||||
async uploadFile(path: string, filePath: string, fileName: string): Promise<string> {
|
||||
const url: string = this.apiBase + path;
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const header: Record<string, string> = {};
|
||||
const token: string = this.token;
|
||||
if (token.length > 0) {
|
||||
header['Authorization'] = 'Bearer ' + token;
|
||||
}
|
||||
|
||||
const multiFormData: http.MultiFormData = {
|
||||
name: 'file',
|
||||
contentType: 'application/octet-stream',
|
||||
remoteFileName: fileName,
|
||||
filePath: filePath
|
||||
};
|
||||
|
||||
const options: http.HttpRequestOptions = {
|
||||
method: http.RequestMethod.POST,
|
||||
header: header,
|
||||
multiFormDataList: [multiFormData],
|
||||
connectTimeout: 30000,
|
||||
readTimeout: 60000
|
||||
};
|
||||
|
||||
hilog.info(DOMAIN, TAG, '→ UPLOAD %{public}s', url);
|
||||
const response = await httpRequest.request(url, options);
|
||||
const code: number = response.responseCode;
|
||||
const rawText: string = response.result as string;
|
||||
|
||||
if (code >= 200 && code < 300) {
|
||||
// 服务端返回 {"attachment_id": "..."} 或直接返回 id 字符串
|
||||
if (rawText.length === 0) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(rawText) as Record<string, string>;
|
||||
if (parsed['attachment_id'] !== undefined) {
|
||||
return parsed['attachment_id'];
|
||||
}
|
||||
if (parsed['id'] !== undefined) {
|
||||
return parsed['id'];
|
||||
}
|
||||
} catch (e) {
|
||||
// 可能直接返回 id 字符串
|
||||
}
|
||||
return rawText;
|
||||
}
|
||||
|
||||
throw new ApiError(code, rawText.length > 0 ? rawText : 'Upload failed');
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
throw e;
|
||||
}
|
||||
const be = e as BusinessError;
|
||||
throw new ApiError(0, be.message !== undefined ? be.message : 'Upload error');
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除本地认证态(401 时调用) */
|
||||
clearAuth(): void {
|
||||
this.token = '';
|
||||
try {
|
||||
const pref = preferences.getPreferencesSync(this.context, { name: 'agentmail' });
|
||||
pref.putSync(PREF_KEY_TOKEN, '');
|
||||
pref.flush();
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
106
client/harmony/entry/src/main/ets/api/AuthApi.ets
Normal file
106
client/harmony/entry/src/main/ets/api/AuthApi.ets
Normal file
@ -0,0 +1,106 @@
|
||||
/*
|
||||
* AgentMail 鸿蒙客户端 — 认证 API
|
||||
* POST /auth/login / logout / GET /auth/me / POST /me/keys
|
||||
*/
|
||||
import { ApiClient, ApiError } from './ApiClient';
|
||||
import { Me, UserKey } from '../model/Models';
|
||||
|
||||
/** 登录请求体 */
|
||||
export class LoginPayload {
|
||||
username: string = '';
|
||||
password: string = '';
|
||||
}
|
||||
|
||||
/** 创建密钥请求体 */
|
||||
export class CreateKeyPayload {
|
||||
label: string = '';
|
||||
key_type: string = 'permanent';
|
||||
expires_hours: number = 0;
|
||||
}
|
||||
|
||||
/** 登录响应(含用户) */
|
||||
export class MeResponse {
|
||||
user: Me = new Me();
|
||||
}
|
||||
|
||||
/** 创建密钥响应 */
|
||||
export class CreateKeyResponse {
|
||||
key: UserKey = new UserKey();
|
||||
}
|
||||
|
||||
/** 密钥列表响应 */
|
||||
export class KeyListResponse {
|
||||
keys: UserKey[] = [];
|
||||
}
|
||||
|
||||
/** 空请求体(logout 等无 body 场景) */
|
||||
export class EmptyPayload {
|
||||
empty: boolean = true;
|
||||
}
|
||||
|
||||
export class AuthApi {
|
||||
private client: ApiClient;
|
||||
|
||||
constructor(client: ApiClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/** 账号密码登录(Cookie 模式) */
|
||||
async login(username: string, password: string): Promise<Me> {
|
||||
const payload: LoginPayload = new LoginPayload();
|
||||
payload.username = username;
|
||||
payload.password = password;
|
||||
// Cookie 由 http 模块自动管理;此处仍拿回 user
|
||||
const resp = await this.client.post<MeResponse>('/auth/login', payload, false);
|
||||
return resp.user;
|
||||
}
|
||||
|
||||
/** 用户密钥登录(Bearer 模式):直接用 key 调 /auth/me */
|
||||
async loginWithKey(key: string): Promise<Me> {
|
||||
this.client.setToken(key);
|
||||
try {
|
||||
const resp = await this.client.get<MeResponse>('/auth/me');
|
||||
await this.client.persistToken(key);
|
||||
return resp.user;
|
||||
} catch (e) {
|
||||
this.client.clearAuth();
|
||||
throw e as ApiError;
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前用户 */
|
||||
async me(): Promise<Me> {
|
||||
const resp = await this.client.get<MeResponse>('/auth/me');
|
||||
return resp.user;
|
||||
}
|
||||
|
||||
/** 退出登录 */
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
const empty: EmptyPayload = new EmptyPayload();
|
||||
await this.client.post<EmptyPayload>('/auth/logout', empty);
|
||||
} catch (e) {
|
||||
// 忽略退出失败,本地清 token
|
||||
}
|
||||
this.client.clearAuth();
|
||||
}
|
||||
|
||||
/** 创建客户端密钥 */
|
||||
async createKey(label: string): Promise<UserKey> {
|
||||
const payload: CreateKeyPayload = new CreateKeyPayload();
|
||||
payload.label = label;
|
||||
const resp = await this.client.post<CreateKeyResponse>('/me/keys', payload);
|
||||
return resp.key;
|
||||
}
|
||||
|
||||
/** 我的密钥列表 */
|
||||
async listKeys(): Promise<UserKey[]> {
|
||||
const resp = await this.client.get<KeyListResponse>('/me/keys');
|
||||
return resp.keys;
|
||||
}
|
||||
|
||||
/** 吊销密钥 */
|
||||
async revokeKey(keyId: string): Promise<void> {
|
||||
await this.client.del<Object>('/me/keys/' + keyId);
|
||||
}
|
||||
}
|
||||
155
client/harmony/entry/src/main/ets/api/MailApi.ets
Normal file
155
client/harmony/entry/src/main/ets/api/MailApi.ets
Normal file
@ -0,0 +1,155 @@
|
||||
/*
|
||||
* AgentMail 鸿蒙客户端 — 邮件与会话 API
|
||||
* GET /me/inbox, /me/sessions, /me/contacts, /me/mail/{id}, /sessions/{id}/thread
|
||||
* POST /me/mail/send, /me/mail/{id}/forward
|
||||
* PUT /sessions/{id}/permission, /sessions/{id}/alias, /sessions/{id}/budget
|
||||
*/
|
||||
|
||||
import { ApiClient } from './ApiClient';
|
||||
import { MailSummary, Session, Contact, MailDetail, ThreadResponse, AttachmentInfo, SendMailRequest, SendMailResult } from '../model/Models';
|
||||
|
||||
/** 收件箱响应 */
|
||||
export class InboxResponse {
|
||||
mails: MailSummary[] = [];
|
||||
total: number = 0;
|
||||
unread: number = 0;
|
||||
}
|
||||
|
||||
/** 会话列表响应 */
|
||||
export class SessionListResponse {
|
||||
sessions: Session[] = [];
|
||||
total: number = 0;
|
||||
}
|
||||
|
||||
/** 联系人列表响应 */
|
||||
export class ContactListResponse {
|
||||
contacts: Contact[] = [];
|
||||
total: number = 0;
|
||||
}
|
||||
|
||||
/** 邮件详情响应 */
|
||||
export class MailDetailResponse {
|
||||
mail: MailDetail = new MailDetail();
|
||||
}
|
||||
|
||||
/** 对话树响应 */
|
||||
export class ThreadApiResponse {
|
||||
thread: ThreadResponse = new ThreadResponse();
|
||||
}
|
||||
|
||||
/** 发信响应 */
|
||||
export class SendMailResponse {
|
||||
result: SendMailResult = new SendMailResult();
|
||||
}
|
||||
|
||||
/** 附件列表响应 */
|
||||
export class AttachmentListResponse {
|
||||
attachments: AttachmentInfo[] = [];
|
||||
}
|
||||
|
||||
/** 地址补全响应 */
|
||||
export class AddressSuggestionResponse {
|
||||
suggestions: string[] = [];
|
||||
}
|
||||
|
||||
/** 改权限请求体 */
|
||||
export class PermissionModePayload {
|
||||
permission_mode: string = 'workspace';
|
||||
}
|
||||
|
||||
/** 改别名请求体 */
|
||||
export class AliasPayload {
|
||||
alias: string = '';
|
||||
}
|
||||
|
||||
/** 改预算请求体 */
|
||||
export class BudgetPayload {
|
||||
max_rounds: number = 0;
|
||||
}
|
||||
|
||||
export class MailApi {
|
||||
private client: ApiClient;
|
||||
|
||||
constructor(client: ApiClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/** 收件箱(status + limit) */
|
||||
async inbox(status: string, limit: number): Promise<InboxResponse> {
|
||||
const query: string = 'status=' + status + '&limit=' + limit;
|
||||
return this.client.get<InboxResponse>('/me/mail/inbox', query);
|
||||
}
|
||||
|
||||
/** 会话列表 */
|
||||
async sessions(): Promise<SessionListResponse> {
|
||||
return this.client.get<SessionListResponse>('/me/sessions');
|
||||
}
|
||||
|
||||
/** 联系人列表 */
|
||||
async contacts(): Promise<ContactListResponse> {
|
||||
return this.client.get<ContactListResponse>('/contacts');
|
||||
}
|
||||
|
||||
/** 邮件详情(API 返回裸对象) */
|
||||
async mailDetail(mailId: string): Promise<MailDetail> {
|
||||
return this.client.get<MailDetail>('/mail/' + mailId);
|
||||
}
|
||||
|
||||
/** 对话树 */
|
||||
async thread(mailId: string, dir?: string, limit?: number): Promise<ThreadApiResponse> {
|
||||
let query: string = '';
|
||||
const parts: string[] = [];
|
||||
if (dir !== undefined) {
|
||||
parts.push('dir=' + dir);
|
||||
}
|
||||
if (limit !== undefined) {
|
||||
parts.push('limit=' + limit);
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
query = parts.join('&');
|
||||
}
|
||||
return this.client.get<ThreadApiResponse>('/mail/' + mailId + '/thread', query);
|
||||
}
|
||||
|
||||
/** 发信 */
|
||||
async send(req: SendMailRequest): Promise<SendMailResponse> {
|
||||
return this.client.post<SendMailResponse>('/me/mail/send', req);
|
||||
}
|
||||
|
||||
/** 转发 */
|
||||
async forward(mailId: string, req: SendMailRequest): Promise<SendMailResponse> {
|
||||
return this.client.post<SendMailResponse>('/me/mail/' + mailId + '/forward', req);
|
||||
}
|
||||
|
||||
/** 改权限档位 */
|
||||
async setPermissionMode(sessionId: string, mode: string): Promise<void> {
|
||||
const payload: PermissionModePayload = new PermissionModePayload();
|
||||
payload.permission_mode = mode;
|
||||
await this.client.put<Object>('/sessions/' + sessionId + '/permission', payload);
|
||||
}
|
||||
|
||||
/** 改会话别名 */
|
||||
async setAlias(sessionId: string, alias: string): Promise<void> {
|
||||
const payload: AliasPayload = new AliasPayload();
|
||||
payload.alias = alias;
|
||||
await this.client.put<Object>('/sessions/' + sessionId + '/alias', payload);
|
||||
}
|
||||
|
||||
/** 改预算 */
|
||||
async setBudget(sessionId: string, maxRounds: number): Promise<void> {
|
||||
const payload: BudgetPayload = new BudgetPayload();
|
||||
payload.max_rounds = maxRounds;
|
||||
await this.client.put<Object>('/sessions/' + sessionId + '/budget', payload);
|
||||
}
|
||||
|
||||
/** 归档联系人 */
|
||||
async archiveContact(name: string, path: string): Promise<void> {
|
||||
await this.client.del<Object>('/me/contacts/' + name + '/' + path);
|
||||
}
|
||||
|
||||
/** 上传附件(multipart/form-data,字段名 file)→ attachment_id */
|
||||
async uploadAttachment(filePath: string, fileName: string): Promise<string> {
|
||||
const resp = await this.client.uploadFile('/me/attachments', filePath, fileName);
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
308
client/harmony/entry/src/main/ets/api/SseService.ets
Normal file
308
client/harmony/entry/src/main/ets/api/SseService.ets
Normal file
@ -0,0 +1,308 @@
|
||||
/*
|
||||
* AgentMail 鸿蒙客户端 — SSE 多账号实时推送服务
|
||||
* 每个账号各建一条 SSE 连接(各带自己的 user_key)
|
||||
* AccountManager 维护连接集合,按 accountId 分发事件
|
||||
*/
|
||||
import { http } from '@kit.NetworkKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { AccountManager, AccountInfo } from './AccountManager';
|
||||
|
||||
const DOMAIN = 0x0001;
|
||||
const TAG = 'SseService';
|
||||
|
||||
/** SSE 事件数据 */
|
||||
export class SseEvent {
|
||||
type: string = '';
|
||||
data: string = '';
|
||||
accountId: string = '';
|
||||
}
|
||||
|
||||
/** SSE 回调 */
|
||||
export type SseListener = (event: SseEvent) => void;
|
||||
|
||||
/** SSE 连接状态 */
|
||||
export type SseStatus = 'disconnected' | 'connecting' | 'connected';
|
||||
|
||||
/** SSE 状态回调 */
|
||||
export type SseStatusListener = (status: SseStatus) => void;
|
||||
|
||||
/** 单个账号的 SSE 连接状态(纯数据) */
|
||||
export class SseConnection {
|
||||
accountId: string = '';
|
||||
server: string = '';
|
||||
token: string = '';
|
||||
httpRequest: http.HttpRequest | null = null;
|
||||
listeners: SseListener[] = [];
|
||||
status: SseStatus = 'disconnected';
|
||||
reconnectTimer: number = 0;
|
||||
buffer: string = '';
|
||||
connected: boolean = false;
|
||||
}
|
||||
|
||||
export class SseService {
|
||||
private static instance: SseService | null = null;
|
||||
private connections: Map<string, SseConnection> = new Map();
|
||||
private globalListeners: SseListener[] = [];
|
||||
private globalStatusListeners: SseStatusListener[] = [];
|
||||
|
||||
static getInstance(): SseService {
|
||||
if (SseService.instance === null) {
|
||||
SseService.instance = new SseService();
|
||||
}
|
||||
return SseService.instance;
|
||||
}
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/** 添加全局事件监听(所有账号的事件都会收到) */
|
||||
addListener(listener: SseListener): void {
|
||||
this.globalListeners.push(listener);
|
||||
}
|
||||
|
||||
/** 移除全局事件监听 */
|
||||
removeListener(listener: SseListener): void {
|
||||
const idx: number = this.globalListeners.indexOf(listener);
|
||||
if (idx >= 0) {
|
||||
this.globalListeners.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** 添加全局状态监听 */
|
||||
addStatusListener(listener: SseStatusListener): void {
|
||||
this.globalStatusListeners.push(listener);
|
||||
}
|
||||
|
||||
/** 移除全局状态监听 */
|
||||
removeStatusListener(listener: SseStatusListener): void {
|
||||
const idx: number = this.globalStatusListeners.indexOf(listener);
|
||||
if (idx >= 0) {
|
||||
this.globalStatusListeners.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** 为指定账号建立 SSE 连接 */
|
||||
connectForAccount(accountId: string, server: string, token: string): void {
|
||||
let conn: SseConnection | undefined = this.connections.get(accountId);
|
||||
if (conn !== undefined && conn.connected) {
|
||||
hilog.info(DOMAIN, TAG, 'account %{public}s already connected', accountId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (conn === undefined) {
|
||||
conn = new SseConnection();
|
||||
conn.accountId = accountId;
|
||||
this.connections.set(accountId, conn);
|
||||
}
|
||||
|
||||
conn.server = server;
|
||||
conn.token = token;
|
||||
conn.connected = true;
|
||||
this.setConnStatus(conn, 'connecting');
|
||||
this.doConnect(conn);
|
||||
}
|
||||
|
||||
/** 断开指定账号的 SSE 连接 */
|
||||
disconnectAccount(accountId: string): void {
|
||||
const conn: SseConnection | undefined = this.connections.get(accountId);
|
||||
if (conn === undefined) {
|
||||
return;
|
||||
}
|
||||
conn.connected = false;
|
||||
if (conn.reconnectTimer !== 0) {
|
||||
clearTimeout(conn.reconnectTimer);
|
||||
conn.reconnectTimer = 0;
|
||||
}
|
||||
if (conn.httpRequest !== null) {
|
||||
conn.httpRequest.destroy();
|
||||
conn.httpRequest = null;
|
||||
}
|
||||
this.setConnStatus(conn, 'disconnected');
|
||||
this.connections.delete(accountId);
|
||||
}
|
||||
|
||||
/** 断开所有连接 */
|
||||
disconnectAll(): void {
|
||||
const keys: string[] = [];
|
||||
this.connections.forEach((_conn: SseConnection, key: string) => {
|
||||
keys.push(key);
|
||||
});
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
this.disconnectAccount(keys[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据 AccountManager 连接所有账号 */
|
||||
async connectAll(acctMgr: AccountManager): Promise<void> {
|
||||
await acctMgr.load();
|
||||
const accounts: AccountInfo[] = acctMgr.getAccounts();
|
||||
for (let i = 0; i < accounts.length; i++) {
|
||||
const acct: AccountInfo = accounts[i];
|
||||
this.connectForAccount(acct.id, acct.server, acct.token);
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取指定账号的连接状态 */
|
||||
getStatusForAccount(accountId: string): SseStatus {
|
||||
const conn: SseConnection | undefined = this.connections.get(accountId);
|
||||
if (conn === undefined) {
|
||||
return 'disconnected';
|
||||
}
|
||||
return conn.status;
|
||||
}
|
||||
|
||||
/** 给指定账号添加事件监听 */
|
||||
addListenerForAccount(accountId: string, listener: SseListener): void {
|
||||
let conn: SseConnection | undefined = this.connections.get(accountId);
|
||||
if (conn === undefined) {
|
||||
conn = new SseConnection();
|
||||
conn.accountId = accountId;
|
||||
this.connections.set(accountId, conn);
|
||||
}
|
||||
conn.listeners.push(listener);
|
||||
}
|
||||
|
||||
/** 移除指定账号的事件监听 */
|
||||
removeListenerForAccount(accountId: string, listener: SseListener): void {
|
||||
const conn: SseConnection | undefined = this.connections.get(accountId);
|
||||
if (conn !== undefined) {
|
||||
const idx: number = conn.listeners.indexOf(listener);
|
||||
if (idx >= 0) {
|
||||
conn.listeners.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setConnStatus(conn: SseConnection, status: SseStatus): void {
|
||||
if (conn.status !== status) {
|
||||
conn.status = status;
|
||||
hilog.info(DOMAIN, TAG, 'status[%{public}s]: %{public}s', conn.accountId, status);
|
||||
for (let i = 0; i < this.globalStatusListeners.length; i++) {
|
||||
this.globalStatusListeners[i](status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private doConnect(conn: SseConnection): void {
|
||||
if (!conn.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const httpRequest = http.createHttp();
|
||||
conn.httpRequest = httpRequest;
|
||||
|
||||
const url: string = conn.server + '/events/stream';
|
||||
const header: Record<string, string> = {};
|
||||
if (conn.token.length > 0) {
|
||||
header['Authorization'] = 'Bearer ' + conn.token;
|
||||
}
|
||||
|
||||
hilog.info(DOMAIN, TAG, 'connecting account %{public}s to %{public}s', conn.accountId, url);
|
||||
|
||||
httpRequest.on('dataReceive', (data: ArrayBuffer) => {
|
||||
const text: string = this.arrayBufferToString(data);
|
||||
conn.buffer += text;
|
||||
this.processBuffer(conn);
|
||||
});
|
||||
|
||||
httpRequest.on('dataEnd', () => {
|
||||
hilog.info(DOMAIN, TAG, 'dataEnd for account %{public}s', conn.accountId);
|
||||
this.setConnStatus(conn, 'disconnected');
|
||||
this.scheduleReconnect(conn);
|
||||
});
|
||||
|
||||
httpRequest.on('headersReceive', (_headers: Object) => {
|
||||
hilog.info(DOMAIN, TAG, 'headers received for account %{public}s', conn.accountId);
|
||||
});
|
||||
|
||||
const options: http.HttpRequestOptions = {
|
||||
method: http.RequestMethod.GET,
|
||||
header: header,
|
||||
connectTimeout: 10000,
|
||||
readTimeout: 0
|
||||
};
|
||||
|
||||
httpRequest.requestInStream(url, options, (err: BusinessError, code: number) => {
|
||||
if (err !== undefined && err !== null) {
|
||||
hilog.error(DOMAIN, TAG, 'requestInStream error account %{public}s: %{public}s', conn.accountId, err.message);
|
||||
this.setConnStatus(conn, 'disconnected');
|
||||
this.scheduleReconnect(conn);
|
||||
return;
|
||||
}
|
||||
hilog.info(DOMAIN, TAG, 'requestInStream code=%{public}d account=%{public}s', code, conn.accountId);
|
||||
if (code === 200) {
|
||||
this.setConnStatus(conn, 'connected');
|
||||
} else {
|
||||
hilog.error(DOMAIN, TAG, 'SSE failed code=%{public}d account=%{public}s', code, conn.accountId);
|
||||
this.setConnStatus(conn, 'disconnected');
|
||||
this.scheduleReconnect(conn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private processBuffer(conn: SseConnection): void {
|
||||
const lines: string[] = conn.buffer.split('\n');
|
||||
conn.buffer = lines.pop() ?? '';
|
||||
|
||||
let eventType: string = '';
|
||||
let eventData: string = '';
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line: string = lines[i];
|
||||
|
||||
if (line.length === 0) {
|
||||
if (eventType.length > 0 || eventData.length > 0) {
|
||||
const event: SseEvent = new SseEvent();
|
||||
event.type = eventType.length > 0 ? eventType : 'message';
|
||||
event.data = eventData;
|
||||
event.accountId = conn.accountId;
|
||||
this.dispatchEvent(conn, event);
|
||||
}
|
||||
eventType = '';
|
||||
eventData = '';
|
||||
} else if (line.startsWith('event:')) {
|
||||
eventType = line.substring(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
const newData: string = line.substring(5).trim();
|
||||
if (eventData.length > 0) {
|
||||
eventData += '\n' + newData;
|
||||
} else {
|
||||
eventData = newData;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchEvent(conn: SseConnection, event: SseEvent): void {
|
||||
hilog.info(DOMAIN, TAG, 'event[%{public}s]: %{public}s data: %{public}s', conn.accountId, event.type,
|
||||
event.data.substring(0, 100));
|
||||
// 分发给账号级监听
|
||||
for (let i = 0; i < conn.listeners.length; i++) {
|
||||
conn.listeners[i](event);
|
||||
}
|
||||
// 分发给全局监听
|
||||
for (let i = 0; i < this.globalListeners.length; i++) {
|
||||
this.globalListeners[i](event);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(conn: SseConnection): void {
|
||||
if (!conn.connected) {
|
||||
return;
|
||||
}
|
||||
const timer: number | undefined = setTimeout(() => {
|
||||
conn.reconnectTimer = 0;
|
||||
this.doConnect(conn);
|
||||
}, 3000);
|
||||
conn.reconnectTimer = timer ?? 0;
|
||||
}
|
||||
|
||||
private arrayBufferToString(buffer: ArrayBuffer): string {
|
||||
const uint8Array: Uint8Array = new Uint8Array(buffer);
|
||||
let result: string = '';
|
||||
for (let i = 0; i < uint8Array.length; i++) {
|
||||
result += String.fromCharCode(uint8Array[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user