feat: 第五轮开发 - 14项未来路线图功能完整实现
W1-W14 全部完成: - W1: 消息搜索 (ILIKE全文检索 + SearchModal) - W2: 对话导出 (JSON/Markdown/TXT三格式) - W3: 记忆时间线 DevTools 可视化 - W4: 通知推送系统 (WebSocket + Browser Notification API) - W5: 定时提醒 (30s轮询 + 重复提醒 + WebSocket推送) - W6: 每日简报 (08:00自动生成: 天气+新闻+提醒+AI摘要) - W7: IoT场景自动化 (规则引擎 10s轮询 + 条件评估 + 场景执行) - W8: 语音输入 (浏览器 Speech Recognition API) - W9: STT服务 (voice-service + whisper.cpp) - W10: TTS服务 (浏览器 Speech Synthesis + edge-tts三档回退) - W11: 文件管理 (上传/下载/缩略图/纯Go bilinear缩放) - W12: 知识库RAG (PostgreSQL tsvector + 文档分块 + 检索) - W13: 多模态 (图片上传+分析: Vision API + 本地Go分析回退) - W14: PWA (Service Worker + 离线页 + install prompt) 总计: 6个Go微服务 + 10+前端组件 + 10+ PostgreSQL表 + 4个后台调度器
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
// 自动化规则和场景 API
|
||||
|
||||
import { request, type ApiResponse } from './client';
|
||||
|
||||
// ========== 类型定义 ==========
|
||||
|
||||
/** 自动化规则 */
|
||||
export interface AutomationRule {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
trigger_type: string;
|
||||
trigger_config: unknown;
|
||||
conditions: unknown;
|
||||
actions: unknown;
|
||||
enabled: boolean;
|
||||
last_triggered_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 自动化场景 */
|
||||
export interface AutomationScene {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
rule_ids: unknown;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 创建规则请求 */
|
||||
export interface CreateRuleRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
trigger_type: string;
|
||||
trigger_config?: unknown;
|
||||
conditions?: unknown;
|
||||
actions: unknown;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/** 更新规则请求 */
|
||||
export interface UpdateRuleRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
trigger_type?: string;
|
||||
trigger_config?: unknown;
|
||||
conditions?: unknown;
|
||||
actions?: unknown;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/** 创建场景请求 */
|
||||
export interface CreateSceneRequest {
|
||||
name: string;
|
||||
icon?: string;
|
||||
rule_ids?: string[];
|
||||
}
|
||||
|
||||
/** 更新场景请求 */
|
||||
export interface UpdateSceneRequest {
|
||||
name?: string;
|
||||
icon?: string;
|
||||
rule_ids?: string[];
|
||||
}
|
||||
|
||||
/** 规则列表响应 */
|
||||
export interface RuleListResponse {
|
||||
rules: AutomationRule[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** 场景列表响应 */
|
||||
export interface SceneListResponse {
|
||||
scenes: AutomationScene[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ========== 规则 API ==========
|
||||
|
||||
/**
|
||||
* 获取用户的所有规则
|
||||
*/
|
||||
export async function listRules(): Promise<ApiResponse<RuleListResponse>> {
|
||||
return request<RuleListResponse>('/automation/rules');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新规则
|
||||
*/
|
||||
export async function createRule(data: CreateRuleRequest): Promise<ApiResponse<{ success: boolean; rule: AutomationRule }>> {
|
||||
return request<{ success: boolean; rule: AutomationRule }>('/automation/rules', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单条规则
|
||||
*/
|
||||
export async function getRule(id: string): Promise<ApiResponse<{ rule: AutomationRule }>> {
|
||||
return request<{ rule: AutomationRule }>(`/automation/rules/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新规则
|
||||
*/
|
||||
export async function updateRule(id: string, data: UpdateRuleRequest): Promise<ApiResponse<{ success: boolean; rule: AutomationRule }>> {
|
||||
return request<{ success: boolean; rule: AutomationRule }>(`/automation/rules/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除规则
|
||||
*/
|
||||
export async function deleteRule(id: string): Promise<ApiResponse<{ success: boolean }>> {
|
||||
return request<{ success: boolean }>(`/automation/rules/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发规则
|
||||
*/
|
||||
export async function triggerRule(id: string): Promise<ApiResponse<{ success: boolean; message: string }>> {
|
||||
return request<{ success: boolean; message: string }>(`/automation/rules/${id}/trigger`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换规则启用状态
|
||||
*/
|
||||
export async function toggleRule(id: string, enabled: boolean): Promise<ApiResponse<{ success: boolean; rule: AutomationRule }>> {
|
||||
return updateRule(id, { enabled });
|
||||
}
|
||||
|
||||
// ========== 场景 API ==========
|
||||
|
||||
/**
|
||||
* 获取用户的所有场景
|
||||
*/
|
||||
export async function listScenes(): Promise<ApiResponse<SceneListResponse>> {
|
||||
return request<SceneListResponse>('/automation/scenes');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新场景
|
||||
*/
|
||||
export async function createScene(data: CreateSceneRequest): Promise<ApiResponse<{ success: boolean; scene: AutomationScene }>> {
|
||||
return request<{ success: boolean; scene: AutomationScene }>('/automation/scenes', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个场景
|
||||
*/
|
||||
export async function getScene(id: string): Promise<ApiResponse<{ scene: AutomationScene }>> {
|
||||
return request<{ scene: AutomationScene }>(`/automation/scenes/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新场景
|
||||
*/
|
||||
export async function updateScene(id: string, data: UpdateSceneRequest): Promise<ApiResponse<{ success: boolean; scene: AutomationScene }>> {
|
||||
return request<{ success: boolean; scene: AutomationScene }>(`/automation/scenes/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除场景
|
||||
*/
|
||||
export async function deleteScene(id: string): Promise<ApiResponse<{ success: boolean }>> {
|
||||
return request<{ success: boolean }>(`/automation/scenes/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动执行场景
|
||||
*/
|
||||
export async function executeScene(id: string): Promise<ApiResponse<{ success: boolean; message: string }>> {
|
||||
return request<{ success: boolean; message: string }>(`/automation/scenes/${id}/execute`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// 每日简报 API
|
||||
|
||||
import { request, type ApiResponse } from './client';
|
||||
|
||||
// ========== 类型定义 ==========
|
||||
|
||||
export interface WeatherData {
|
||||
location: string;
|
||||
temp: number;
|
||||
condition: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface NewsItem {
|
||||
title: string;
|
||||
url: string;
|
||||
source: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface BriefReminder {
|
||||
id: string;
|
||||
title: string;
|
||||
remind_at: string;
|
||||
}
|
||||
|
||||
export interface Briefing {
|
||||
id: string;
|
||||
user_id: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
weather?: WeatherData;
|
||||
news: NewsItem[];
|
||||
reminders: BriefReminder[];
|
||||
summary: string;
|
||||
status: 'pending' | 'generated' | 'delivered';
|
||||
generated_at?: string;
|
||||
delivered_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface GenerateBriefingResponse {
|
||||
success: boolean;
|
||||
briefing?: Briefing;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ========== API 方法 ==========
|
||||
|
||||
/** 获取指定日期简报 */
|
||||
export async function getBriefing(userId: string, date: string): Promise<ApiResponse<{ briefing: Briefing | null; message?: string }>> {
|
||||
return request(`/briefings?user_id=${encodeURIComponent(userId)}&date=${encodeURIComponent(date)}`);
|
||||
}
|
||||
|
||||
/** 获取最近简报列表 */
|
||||
export async function getLatestBriefings(userId: string, limit = 7): Promise<ApiResponse<{ briefings: Briefing[]; total: number }>> {
|
||||
return request(`/briefings/latest?user_id=${encodeURIComponent(userId)}&limit=${limit}`);
|
||||
}
|
||||
|
||||
/** 手动生成今日简报 */
|
||||
export async function generateBriefing(userId: string): Promise<ApiResponse<GenerateBriefingResponse>> {
|
||||
return request('/briefings/generate', {
|
||||
method: 'POST',
|
||||
body: { user_id: userId },
|
||||
});
|
||||
}
|
||||
|
||||
/** 格式化日期 */
|
||||
export function formatBriefingDate(date: string): string {
|
||||
try {
|
||||
const d = new Date(date + 'T00:00:00');
|
||||
return d.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long',
|
||||
});
|
||||
} catch {
|
||||
return date;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// 文件管理 API — 对接 Gateway REST API
|
||||
|
||||
import { request } from './client';
|
||||
|
||||
/** 文件元信息 */
|
||||
export interface FileInfo {
|
||||
id: string;
|
||||
user_id: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
hash: string;
|
||||
is_public: boolean;
|
||||
created_at: string; // UnixMilli
|
||||
url: string;
|
||||
thumbnail_url?: string;
|
||||
}
|
||||
|
||||
/** 文件列表响应 */
|
||||
interface FileListResponse {
|
||||
files: FileInfo[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/** 单文件响应 */
|
||||
interface FileResponse {
|
||||
id: string;
|
||||
user_id: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
hash: string;
|
||||
is_public: boolean;
|
||||
created_at: number;
|
||||
url: string;
|
||||
thumbnail_url?: string;
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
|
||||
/**
|
||||
* 获取带授权头的文件下载/缩略图 URL (用于 fetch 直接获取 blob)
|
||||
*/
|
||||
function authFetch(url: string, init?: RequestInit): Promise<Response> {
|
||||
const token = localStorage.getItem('token');
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.headers || {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* POST /api/v1/files/upload (multipart/form-data)
|
||||
*/
|
||||
export async function uploadFile(
|
||||
file: File,
|
||||
sessionId?: string,
|
||||
): Promise<FileInfo> {
|
||||
const token = localStorage.getItem('token');
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (sessionId) {
|
||||
formData.append('session_id', sessionId);
|
||||
}
|
||||
|
||||
const resp = await fetch(`${API_BASE}/files/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ error: '上传失败' }));
|
||||
throw new Error(err.error || `上传失败 (${resp.status})`);
|
||||
}
|
||||
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出用户的所有文件 (支持分页)
|
||||
* GET /api/v1/files?page=&limit=
|
||||
*/
|
||||
export async function listFiles(
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
): Promise<{ files: FileInfo[]; total: number }> {
|
||||
const resp = await request<FileListResponse>(
|
||||
`/files?page=${page}&limit=${limit}`,
|
||||
);
|
||||
if (resp.error) {
|
||||
console.error('[files] 获取文件列表失败:', resp.error);
|
||||
return { files: [], total: 0 };
|
||||
}
|
||||
const data = resp.data as FileListResponse;
|
||||
return { files: data?.files || [], total: data?.total || 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个文件元数据
|
||||
* GET /api/v1/files/:id
|
||||
*/
|
||||
export async function getFile(id: string): Promise<FileInfo | null> {
|
||||
const resp = await request<FileResponse>(`/files/${encodeURIComponent(id)}`);
|
||||
if (resp.error) {
|
||||
console.error('[files] 获取文件信息失败:', resp.error);
|
||||
return null;
|
||||
}
|
||||
const data = resp.data;
|
||||
if (!data) return null;
|
||||
return {
|
||||
...data,
|
||||
created_at: String(data.created_at),
|
||||
thumbnail_url: data.thumbnail_url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
* DELETE /api/v1/files/:id
|
||||
*/
|
||||
export async function deleteFile(id: string): Promise<boolean> {
|
||||
const resp = await request(`/files/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (resp.error) {
|
||||
console.error('[files] 删除文件失败:', resp.error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件下载URL
|
||||
*/
|
||||
export function getFileDownloadUrl(id: string): string {
|
||||
return `${API_BASE}/files/${encodeURIComponent(id)}/download`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件缩略图URL
|
||||
*/
|
||||
export function getFileThumbnailUrl(id: string): string {
|
||||
return `${API_BASE}/files/${encodeURIComponent(id)}/thumbnail`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 fetch 下载文件并触发浏览器下载
|
||||
*/
|
||||
export async function downloadFile(id: string, filename?: string): Promise<void> {
|
||||
const resp = await authFetch(getFileDownloadUrl(id));
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ error: '下载失败' }));
|
||||
throw new Error(err.error || `下载失败 (${resp.status})`);
|
||||
}
|
||||
|
||||
// 从 Content-Disposition 获取文件名
|
||||
const disposition = resp.headers.get('Content-Disposition');
|
||||
let downloadName = filename || 'download';
|
||||
if (disposition) {
|
||||
const match = disposition.match(/filename="?([^";\n]+)"?/);
|
||||
if (match) downloadName = match[1];
|
||||
}
|
||||
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = downloadName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缩略图 blob URL (用于 <img> 标签)
|
||||
*/
|
||||
export async function getThumbnailBlobUrl(id: string): Promise<string | null> {
|
||||
try {
|
||||
const resp = await authFetch(getFileThumbnailUrl(id));
|
||||
if (!resp.ok) return null;
|
||||
const blob = await resp.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// 知识库 API — 对接 Gateway REST API
|
||||
|
||||
import { request } from './client';
|
||||
|
||||
/** 知识库 */
|
||||
export interface KnowledgeBase {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
document_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 知识库文档 */
|
||||
export interface KnowledgeDocument {
|
||||
id: string;
|
||||
kb_id: string;
|
||||
title: string;
|
||||
content_type: string;
|
||||
source_type: string;
|
||||
source_ref: string;
|
||||
chunk_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** 搜索结果 */
|
||||
export interface SearchChunkResult {
|
||||
chunk_id: string;
|
||||
doc_id: string;
|
||||
kb_id: string;
|
||||
content: string;
|
||||
rank: number;
|
||||
headline: string;
|
||||
doc_title: string;
|
||||
kb_name: string;
|
||||
}
|
||||
|
||||
/** 知识库列表响应 */
|
||||
interface KBListResponse {
|
||||
bases: KnowledgeBase[];
|
||||
}
|
||||
|
||||
/** 文档列表响应 */
|
||||
interface DocListResponse {
|
||||
documents: KnowledgeDocument[];
|
||||
}
|
||||
|
||||
/** 搜索响应 */
|
||||
interface SearchResponse {
|
||||
results: SearchChunkResult[];
|
||||
total: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
// ========== 知识库 CRUD ==========
|
||||
|
||||
/** 创建知识库 */
|
||||
export async function createKB(name: string, description?: string): Promise<KnowledgeBase> {
|
||||
const res = await request<KnowledgeBase>(`/knowledge/bases`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description }),
|
||||
});
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
}
|
||||
|
||||
/** 列出知识库 */
|
||||
export async function listKBs(): Promise<KnowledgeBase[]> {
|
||||
const res = await request<KBListResponse>(`/knowledge/bases`);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data?.bases || [];
|
||||
}
|
||||
|
||||
/** 获取知识库 */
|
||||
export async function getKB(id: string): Promise<KnowledgeBase> {
|
||||
const res = await request<KnowledgeBase>(`/knowledge/bases/${id}`);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
}
|
||||
|
||||
/** 更新知识库 */
|
||||
export async function updateKB(id: string, name: string, description?: string): Promise<KnowledgeBase> {
|
||||
const res = await request<KnowledgeBase>(`/knowledge/bases/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name, description }),
|
||||
});
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
}
|
||||
|
||||
/** 删除知识库 */
|
||||
export async function deleteKB(id: string): Promise<boolean> {
|
||||
const res = await request<{ message: string }>(`/knowledge/bases/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return !res.error;
|
||||
}
|
||||
|
||||
// ========== 文档管理 ==========
|
||||
|
||||
/** 添加文档 (文本内容) */
|
||||
export async function addDocument(
|
||||
kbId: string,
|
||||
title: string,
|
||||
content: string,
|
||||
sourceType: 'text' | 'url' = 'text',
|
||||
): Promise<KnowledgeDocument> {
|
||||
const res = await request<KnowledgeDocument>(`/knowledge/bases/${kbId}/documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, content, source_type: sourceType }),
|
||||
});
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
}
|
||||
|
||||
/** 从文件添加文档 */
|
||||
export async function addDocumentFromFile(
|
||||
kbId: string,
|
||||
title: string,
|
||||
fileId: string,
|
||||
): Promise<KnowledgeDocument> {
|
||||
const res = await request<KnowledgeDocument>(`/knowledge/bases/${kbId}/documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, source_type: 'file', file_id: fileId }),
|
||||
});
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
}
|
||||
|
||||
/** 列出文档 */
|
||||
export async function listDocuments(kbId: string): Promise<KnowledgeDocument[]> {
|
||||
const res = await request<DocListResponse>(`/knowledge/bases/${kbId}/documents`);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data?.documents || [];
|
||||
}
|
||||
|
||||
/** 获取文档 */
|
||||
export async function getDocument(id: string): Promise<KnowledgeDocument> {
|
||||
const res = await request<KnowledgeDocument>(`/knowledge/documents/${id}`);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
}
|
||||
|
||||
/** 删除文档 */
|
||||
export async function deleteDocument(id: string): Promise<boolean> {
|
||||
const res = await request<{ message: string }>(`/knowledge/documents/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return !res.error;
|
||||
}
|
||||
|
||||
// ========== 搜索 ==========
|
||||
|
||||
/** 搜索知识库 */
|
||||
export async function searchKnowledge(
|
||||
query: string,
|
||||
kbIds?: string[],
|
||||
limit?: number,
|
||||
): Promise<SearchResponse> {
|
||||
const res = await request<SearchResponse>(`/knowledge/search`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query, kb_ids: kbIds, limit: limit || 10 }),
|
||||
});
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// 提醒 API
|
||||
|
||||
import { request, type ApiResponse } from './client';
|
||||
|
||||
/** 提醒类型 */
|
||||
export interface Reminder {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
remind_at: string; // ISO 8601
|
||||
status: 'pending' | 'completed' | 'cancelled';
|
||||
created_at: string;
|
||||
completed_at?: string | null;
|
||||
repeat_type: 'none' | 'daily' | 'weekly' | 'monthly';
|
||||
session_id: string;
|
||||
notified: boolean;
|
||||
}
|
||||
|
||||
/** 创建提醒请求 */
|
||||
export interface CreateReminderRequest {
|
||||
title: string;
|
||||
description?: string;
|
||||
remind_at: string; // ISO 8601
|
||||
repeat_type?: string;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
/** 更新提醒请求 */
|
||||
export interface UpdateReminderRequest {
|
||||
title?: string;
|
||||
description?: string;
|
||||
remind_at?: string;
|
||||
status?: 'pending' | 'completed' | 'cancelled';
|
||||
repeat_type?: string;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
/** 提醒列表响应 */
|
||||
export interface ReminderListResponse {
|
||||
reminders: Reminder[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的提醒列表
|
||||
* @param userId 用户 ID (可选,不传则用当前登录用户)
|
||||
* @param status 状态筛选 (pending/completed/cancelled)
|
||||
* @param limit 分页大小
|
||||
*/
|
||||
export async function listReminders(
|
||||
userId?: string,
|
||||
status?: string,
|
||||
limit = 50
|
||||
): Promise<ApiResponse<ReminderListResponse>> {
|
||||
const params = new URLSearchParams();
|
||||
if (userId) params.set('user_id', userId);
|
||||
if (status) params.set('status', status);
|
||||
params.set('limit', String(limit));
|
||||
|
||||
return request<ReminderListResponse>(`/reminders?${params.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新提醒
|
||||
*/
|
||||
export async function createReminder(data: CreateReminderRequest): Promise<ApiResponse<{ success: boolean; reminder: Reminder }>> {
|
||||
return request<{ success: boolean; reminder: Reminder }>('/reminders', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新提醒
|
||||
*/
|
||||
export async function updateReminder(
|
||||
id: string,
|
||||
data: UpdateReminderRequest
|
||||
): Promise<ApiResponse<{ success: boolean; reminder: Reminder }>> {
|
||||
return request<{ success: boolean; reminder: Reminder }>(`/reminders/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除提醒
|
||||
*/
|
||||
export async function deleteReminder(id: string): Promise<ApiResponse<{ success: boolean }>> {
|
||||
return request<{ success: boolean }>(`/reminders/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消提醒 (等同于更新状态为 cancelled)
|
||||
*/
|
||||
export async function cancelReminder(id: string): Promise<ApiResponse<{ success: boolean; reminder: Reminder }>> {
|
||||
return updateReminder(id, { status: 'cancelled' });
|
||||
}
|
||||
@@ -108,3 +108,97 @@ export async function clearSessionMessages(sessionId: string): Promise<boolean>
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ========== 消息搜索 ==========
|
||||
|
||||
/** 单条搜索结果 */
|
||||
export interface SearchResult {
|
||||
message_id: number;
|
||||
session_id: string;
|
||||
session_title: string;
|
||||
role: string;
|
||||
content: string;
|
||||
created_at: number; // UnixMilli
|
||||
}
|
||||
|
||||
/** 搜索响应 */
|
||||
export interface SearchResponse {
|
||||
results: SearchResult[];
|
||||
total: number;
|
||||
query: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全文搜索消息
|
||||
* GET /api/v1/messages/search?q={query}&user_id={userId}&limit={limit}&offset={offset}
|
||||
*/
|
||||
export async function searchMessages(
|
||||
query: string,
|
||||
userId: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<SearchResponse> {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
user_id: userId,
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
});
|
||||
const resp = await request<SearchResponse>(
|
||||
`/messages/search?${params.toString()}`
|
||||
);
|
||||
if (resp.error) {
|
||||
console.error('[sessions] 搜索消息失败:', resp.error);
|
||||
return { results: [], total: 0, query, limit, offset };
|
||||
}
|
||||
return (resp.data as SearchResponse) || { results: [], total: 0, query, limit, offset };
|
||||
}
|
||||
|
||||
// ========== 导出 ==========
|
||||
|
||||
export type ExportFormat = 'json' | 'markdown' | 'txt';
|
||||
|
||||
/**
|
||||
* 导出会话为指定格式,触发浏览器下载
|
||||
* GET /api/v1/sessions/{sessionId}/export?format={format}
|
||||
*/
|
||||
export async function exportSession(
|
||||
sessionId: string,
|
||||
format: ExportFormat = 'json'
|
||||
): Promise<void> {
|
||||
const token = localStorage.getItem('token');
|
||||
const resp = await fetch(
|
||||
`${import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1'}/sessions/${encodeURIComponent(sessionId)}/export?format=${format}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ error: '导出失败' }));
|
||||
throw new Error(err.error || `导出失败 (${resp.status})`);
|
||||
}
|
||||
|
||||
// 从 Content-Disposition 获取文件名,或生成默认名
|
||||
const disposition = resp.headers.get('Content-Disposition');
|
||||
let filename = `session_${sessionId}.${format}`;
|
||||
if (disposition) {
|
||||
const match = disposition.match(/filename="?([^";\n]+)"?/);
|
||||
if (match) filename = match[1];
|
||||
}
|
||||
|
||||
// 触发浏览器下载
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// 语音识别 + TTS API
|
||||
import { request, type ApiResponse } from './client';
|
||||
|
||||
interface TranscribeResult {
|
||||
success: boolean;
|
||||
text: string;
|
||||
language: string;
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
interface STTStatus {
|
||||
service: string;
|
||||
stt: {
|
||||
available: boolean;
|
||||
binary_available: boolean;
|
||||
model_loaded: boolean;
|
||||
binary_path: string;
|
||||
model_path: string;
|
||||
model_name: string;
|
||||
default_language: string;
|
||||
supported_languages: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface TTSVoice {
|
||||
name: string;
|
||||
display_name: string;
|
||||
gender: string;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
interface TTSSynthesizeRequest {
|
||||
text: string;
|
||||
voice?: string;
|
||||
rate?: string;
|
||||
}
|
||||
|
||||
interface TTSStatus {
|
||||
service: string;
|
||||
tts: {
|
||||
available: boolean;
|
||||
edge_tts: boolean;
|
||||
espeak_ng: boolean;
|
||||
engine: string;
|
||||
default_voice: string;
|
||||
builtin_voices: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface VoiceFullStatus {
|
||||
service: string;
|
||||
stt: STTStatus['stt'];
|
||||
tts: TTSStatus['tts'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 音频文件转文字
|
||||
* @param audioBlob 音频文件 Blob
|
||||
* @param language 语言代码 (zh, en, ja, ko, auto),默认 zh
|
||||
*/
|
||||
async function transcribeAudio(audioBlob: Blob, language?: string): Promise<ApiResponse<TranscribeResult>> {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', audioBlob, 'audio.wav');
|
||||
if (language) {
|
||||
formData.append('language', language);
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
// 不设置 Content-Type,让浏览器自动处理 multipart boundary
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:8080/api/v1/voice/transcribe', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: data?.error || `请求失败 (${response.status})`,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
return { data: data as TranscribeResult, status: response.status };
|
||||
} catch (err) {
|
||||
return {
|
||||
error: err instanceof Error ? err.message : '网络错误',
|
||||
status: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端 TTS 合成(返回 audio blob URL)
|
||||
* @returns 音频文件的 Object URL
|
||||
*/
|
||||
async function synthesizeSpeech(req: TTSSynthesizeRequest): Promise<string> {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch('http://localhost:8080/api/v1/voice/tts', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.error || `TTS 合成失败 (${response.status})`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用 TTS 语音列表
|
||||
*/
|
||||
async function getTTSVoices(): Promise<TTSVoice[]> {
|
||||
const response = await request<{ voices: TTSVoice[]; count: number }>('/voice/tts/voices');
|
||||
if (response.error) throw new Error(response.error);
|
||||
return response.data?.voices ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 TTS 服务状态
|
||||
*/
|
||||
async function getTTSStatus(): Promise<TTSStatus> {
|
||||
const response = await request<TTSStatus>('/voice/tts/status');
|
||||
if (response.error) throw new Error(response.error);
|
||||
return response.data!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 STT 服务状态
|
||||
*/
|
||||
async function getSTTStatus(): Promise<ApiResponse<STTStatus>> {
|
||||
return request<STTStatus>('/voice/status');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取语音服务完整状态(STT + TTS)
|
||||
*/
|
||||
async function getVoiceFullStatus(): Promise<VoiceFullStatus> {
|
||||
const response = await request<VoiceFullStatus>('/voice/status');
|
||||
if (response.error) throw new Error(response.error);
|
||||
return response.data!;
|
||||
}
|
||||
|
||||
export { transcribeAudio, synthesizeSpeech, getSTTStatus, getTTSStatus, getTTSVoices, getVoiceFullStatus };
|
||||
export type { TranscribeResult, STTStatus, TTSVoice, TTSSynthesizeRequest, TTSStatus, VoiceFullStatus };
|
||||
Reference in New Issue
Block a user