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:
2026-05-19 12:01:09 +08:00
parent 78e3f450c2
commit bcf4d4e621
69 changed files with 14599 additions and 150 deletions
+5 -1
View File
@@ -2,8 +2,12 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1.png" />
<link rel="manifest" href="/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#ec4899" />
<meta name="description" content="你的 AI 生活伴侣,支持 IoT 控制、知识库、语音交互" />
<link rel="apple-touch-icon" href="/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1.png" />
<title>昔涟 - Cyrene</title>
</head>
<body>
+39
View File
@@ -0,0 +1,39 @@
{
"name": "Cyrene - AI 智能助手",
"short_name": "Cyrene",
"description": "你的 AI 生活伴侣,支持 IoT 控制、知识库、语音交互",
"start_url": "/",
"display": "standalone",
"background_color": "#fdf2f8",
"theme_color": "#ec4899",
"orientation": "any",
"icons": [
{
"src": "/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["productivity", "utilities"],
"lang": "zh-CN",
"dir": "ltr",
"scope": "/",
"prefer_related_applications": false,
"shortcuts": [
{
"name": "新对话",
"url": "/#/new",
"description": "开始新对话"
}
],
"share_target": {
"action": "/#/share",
"method": "GET",
"params": {
"title": "title",
"text": "text",
"url": "url"
}
}
}
+130
View File
@@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>昔涟 - 离线页面</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: linear-gradient(135deg, #fdf2f8 0%, #fce7f3 50%, #fbcfe8 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #374151;
}
.container {
text-align: center;
padding: 2rem;
max-width: 420px;
width: 100%;
}
.avatar {
width: 120px;
height: 120px;
border-radius: 28px;
object-fit: cover;
box-shadow: 0 8px 32px rgba(236, 72, 153, 0.3);
margin-bottom: 1.5rem;
border: 3px solid rgba(236, 72, 153, 0.3);
}
.avatar-placeholder {
width: 120px;
height: 120px;
border-radius: 28px;
background: linear-gradient(135deg, #ec4899, #f472b6);
margin: 0 auto 1.5rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 64px;
box-shadow: 0 8px 32px rgba(236, 72, 153, 0.3);
border: 3px solid rgba(236, 72, 153, 0.3);
}
h1 {
font-size: 1.5rem;
font-weight: 700;
color: #be185d;
margin-bottom: 0.5rem;
}
p {
font-size: 0.95rem;
color: #9ca3af;
margin-bottom: 2rem;
line-height: 1.6;
}
.status-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: linear-gradient(135deg, #ec4899, #f472b6);
color: white;
border: none;
padding: 0.75rem 2rem;
border-radius: 9999px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 16px rgba(236, 72, 153, 0.3);
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 6px 24px rgba(236, 72, 153, 0.4);
}
button:active {
transform: translateY(0);
}
.auto-retry {
margin-top: 1.5rem;
font-size: 0.8rem;
color: #9ca3af;
}
.auto-retry.connected {
color: #059669;
font-weight: 600;
}
</style>
</head>
<body>
<div class="container">
<!-- Cyrene Avatar -->
<img
class="avatar"
src="/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1.png"
alt="昔涟"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';"
/>
<div class="avatar-placeholder" style="display:none;">🌸</div>
<div class="status-icon">📡</div>
<h1>哎呀,网络连接断开了</h1>
<p>请检查网络连接后重试<br />昔涟正在等你回来~</p>
<button onclick="retry()">🔄 重新连接</button>
<div id="autoRetry" class="auto-retry">正在监听网络恢复...</div>
</div>
<script>
function retry() {
window.location.reload();
}
const autoRetryEl = document.getElementById('autoRetry');
window.addEventListener('online', function() {
autoRetryEl.textContent = '✅ 网络已恢复,正在重新加载...';
autoRetryEl.classList.add('connected');
setTimeout(function() {
window.location.reload();
}, 500);
});
</script>
</body>
</html>
+107
View File
@@ -0,0 +1,107 @@
const CACHE_NAME = 'cyrene-v1';
const ASSETS_TO_CACHE = [
'/',
'/index.html',
];
// Install: 缓存核心资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(ASSETS_TO_CACHE);
})
);
self.skipWaiting();
});
// Activate: 清理旧缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key))
);
})
);
self.clients.claim();
});
// Fetch: 缓存优先策略(对 API 请求使用网络优先)
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// API 请求:网络优先,失败时返回离线 JSON
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(event.request));
} else if (url.pathname.startsWith('/ws')) {
// WebSocket 连接不缓存
event.respondWith(fetch(event.request));
} else {
// 静态资源:缓存优先
event.respondWith(cacheFirst(event.request));
}
});
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch (e) {
// 返回离线页面(HTML 请求)
if (request.headers.get('Accept')?.includes('text/html')) {
return caches.match('/offline.html');
}
throw e;
}
}
async function networkFirst(request) {
try {
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
} catch (e) {
const cached = await caches.match(request);
if (cached) return cached;
return new Response(JSON.stringify({ error: '离线状态,请检查网络连接' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
}
// Push 通知
self.addEventListener('push', (event) => {
const data = event.data?.json() || { title: 'Cyrene', body: '新消息' };
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1.png',
badge: '/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1.png',
data: data.data || {},
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const sessionId = event.notification.data?.session_id;
const targetUrl = sessionId ? `/#/session/${sessionId}` : '/';
event.waitUntil(
clients.matchAll({ type: 'window' }).then((clientList) => {
for (const client of clientList) {
if (client.url.includes(targetUrl) && 'focus' in client) {
return client.focus();
}
}
if (clients.openWindow) return clients.openWindow(targetUrl);
})
);
});
+195
View File
@@ -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',
});
}
+81
View File
@@ -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;
}
}
+196
View File
@@ -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;
}
}
+168
View File
@@ -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!;
}
+101
View File
@@ -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' });
}
+94
View File
@@ -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);
}
+163
View File
@@ -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 };
+389 -61
View File
@@ -1,28 +1,111 @@
import { useState, useRef, useCallback } from 'react';
import type { ChatMode } from '@/types/chat';
import { useState, useRef, useCallback, useEffect } from 'react';
import type { ChatMode, MessageAttachment } from '@/types/chat';
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition';
import { uploadFile } from '@/api/files';
interface ChatInputProps {
onSend: (content: string, mode: ChatMode) => void;
onSend: (content: string, mode: ChatMode, attachments?: MessageAttachment[]) => void;
disabled?: boolean;
}
interface PendingImage {
file: File;
previewUrl: string;
id: string; // 临时 ID
}
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
const SUPPORTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'];
const MAX_IMAGES = 5;
export function ChatInput({ onSend, disabled }: ChatInputProps) {
const [content, setContent] = useState('');
const [mode, setMode] = useState<ChatMode>('text');
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleSend = useCallback(() => {
const {
isListening,
isSupported,
interimText,
finalText,
error,
startListening,
stopListening,
resetText,
} = useSpeechRecognition();
// 当 finalText 更新时,追加到输入框
useEffect(() => {
if (finalText) {
setContent((prev) => {
const trimmed = prev.trimEnd();
return (trimmed ? trimmed + ' ' : '') + finalText;
});
resetText();
}
}, [finalText, resetText]);
const handleSend = useCallback(async () => {
const trimmed = content.trim();
if (!trimmed || disabled) return;
const hasImages = pendingImages.length > 0;
if ((!trimmed && !hasImages) || disabled || uploading) return;
onSend(trimmed, mode);
let attachments: MessageAttachment[] | undefined;
if (hasImages) {
setUploading(true);
setUploadError('');
try {
const uploadedAttachments: MessageAttachment[] = [];
for (const img of pendingImages) {
try {
const result = await uploadFile(img.file);
uploadedAttachments.push({
type: 'image',
url: result.url,
thumbnail_url: result.thumbnail_url,
filename: result.filename,
size: result.size,
});
} catch (err) {
console.error('[ChatInput] 图片上传失败:', img.file.name, err);
// 使用 data URL 作为降级
uploadedAttachments.push({
type: 'image',
url: img.previewUrl,
filename: img.file.name,
size: img.file.size,
});
}
}
if (uploadedAttachments.length > 0) {
attachments = uploadedAttachments;
}
} catch (err) {
setUploadError('图片上传失败,请重试');
setUploading(false);
return;
}
setUploading(false);
}
onSend(trimmed, mode, attachments);
setContent('');
setPendingImages([]);
// 重置文本框高度
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
}, [content, mode, disabled, onSend]);
}, [content, mode, disabled, onSend, pendingImages, uploading]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -30,8 +113,92 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
e.preventDefault();
handleSend();
}
// Ctrl+Shift+V 触发语音输入
if (e.key === 'V' && e.ctrlKey && e.shiftKey) {
e.preventDefault();
if (isListening) {
stopListening();
} else {
startListening();
}
}
},
[handleSend]
[handleSend, isListening, startListening, stopListening]
);
// 粘贴图片
const handlePaste = useCallback(
(e: React.ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
if (file) {
addImageFile(file);
}
}
}
},
[]
);
// 添加图片文件
const addImageFile = useCallback(
(file: File) => {
setUploadError('');
// 检查文件大小
if (file.size > MAX_IMAGE_SIZE) {
setUploadError(`图片 "${file.name}" 超过 10MB 限制`);
return;
}
// 检查文件类型
if (!SUPPORTED_IMAGE_TYPES.includes(file.type)) {
setUploadError(`不支持的图片格式: ${file.type}`);
return;
}
// 检查数量限制
setPendingImages((prev) => {
if (prev.length >= MAX_IMAGES) {
setUploadError(`最多同时上传 ${MAX_IMAGES} 张图片`);
return prev;
}
const previewUrl = URL.createObjectURL(file);
return [...prev, { file, previewUrl, id: `img_${Date.now()}_${Math.random().toString(36).slice(2)}` }];
});
},
[]
);
// 移除待上传图片
const removeImage = useCallback((id: string) => {
setPendingImages((prev) => {
const img = prev.find((p) => p.id === id);
if (img) {
URL.revokeObjectURL(img.previewUrl);
}
return prev.filter((p) => p.id !== id);
});
}, []);
// 文件选择
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
for (let i = 0; i < files.length; i++) {
addImageFile(files[i]);
}
// 重置 input 以便再次选择相同文件
e.target.value = '';
},
[addImageFile]
);
const handleInput = useCallback(() => {
@@ -42,70 +209,231 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
}
}, []);
const handleVoiceToggle = useCallback(() => {
if (isListening) {
stopListening();
} else {
startListening();
}
}, [isListening, startListening, stopListening]);
return (
<div className="border-t border-pink-100 dark:border-pink-900 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm px-4 py-3">
<div className="flex items-end gap-2 max-w-3xl mx-auto">
{/* 模式切换 */}
<div className="flex gap-1">
<button
onClick={() => setMode('text')}
className={`p-2 rounded-lg text-xs transition-colors ${
mode === 'text'
? 'bg-pink-100 text-pink-600 dark:bg-pink-900 dark:text-pink-300'
: 'text-gray-400 hover:text-gray-600'
}`}
title="文字模式"
<div className="flex flex-col gap-2 max-w-3xl mx-auto">
{/* 实时识别文本提示 */}
{isListening && interimText && (
<div
className="interim-text text-sm text-pink-500 dark:text-pink-400 italic px-1"
aria-live="polite"
aria-atomic="true"
>
💬
{interimText}
</div>
)}
{/* 错误提示 */}
{error && (
<div
className="text-xs text-red-500 dark:text-red-400 px-1"
role="alert"
>
{error}
</div>
)}
{/* 上传错误提示 */}
{uploadError && (
<div
className="text-xs text-red-500 dark:text-red-400 px-1"
role="alert"
>
{uploadError}
</div>
)}
{/* 图片预览区 */}
{pendingImages.length > 0 && (
<div className="flex gap-2 flex-wrap px-1">
{pendingImages.map((img) => (
<div
key={img.id}
className="relative group w-16 h-16 rounded-lg overflow-hidden border border-pink-200 dark:border-pink-800 flex-shrink-0"
>
<img
src={img.previewUrl}
alt={img.file.name}
className="w-full h-full object-cover"
/>
{uploading && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<svg className="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
)}
{!uploading && (
<button
onClick={() => removeImage(img.id)}
className="absolute top-0.5 right-0.5 w-5 h-5 rounded-full bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
aria-label="移除图片"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-3.5 h-3.5">
<path fillRule="evenodd" d="M5.47 5.47a.75.75 0 011.06 0L12 10.94l5.47-5.47a.75.75 0 111.06 1.06L13.06 12l5.47 5.47a.75.75 0 11-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 01-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 010-1.06z" clipRule="evenodd" />
</svg>
</button>
)}
</div>
))}
</div>
)}
<div className="flex items-end gap-2">
{/* 模式切换 */}
<div className="flex gap-1">
<button
onClick={() => setMode('text')}
className={`p-2 rounded-lg text-xs transition-colors ${
mode === 'text'
? 'bg-pink-100 text-pink-600 dark:bg-pink-900 dark:text-pink-300'
: 'text-gray-400 hover:text-gray-600'
}`}
title="文字模式"
>
💬
</button>
<button
onClick={() => setMode('voice_msg')}
className={`p-2 rounded-lg text-xs transition-colors ${
mode === 'voice_msg'
? 'bg-pink-100 text-pink-600 dark:bg-pink-900 dark:text-pink-300'
: 'text-gray-400 hover:text-gray-600'
}`}
title="语音消息"
>
🎤
</button>
</div>
{/* 图片上传按钮 */}
<button
onClick={() => fileInputRef.current?.click()}
disabled={disabled || uploading}
className="p-2 rounded-lg text-xs transition-colors text-gray-400 hover:text-pink-500 hover:bg-pink-50 dark:hover:bg-pink-900/30 disabled:opacity-40 disabled:cursor-not-allowed"
title="上传图片"
aria-label="上传图片"
>
📷
</button>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp,image/bmp"
multiple
onChange={handleFileSelect}
className="hidden"
aria-hidden="true"
/>
{/* 输入框 */}
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onInput={handleInput}
placeholder="和昔涟说点什么吧... 支持粘贴图片"
disabled={disabled || uploading}
rows={1}
className="flex-1 resize-none rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 px-4 py-2 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400 focus:border-transparent disabled:opacity-50"
/>
{/* 语音输入按钮 (仅浏览器支持时显示) */}
{isSupported && (
<button
onClick={handleVoiceToggle}
disabled={disabled || uploading}
aria-label={isListening ? '停止语音输入' : '开始语音输入'}
aria-pressed={isListening}
title={isListening ? '停止聆听 (Ctrl+Shift+V)' : '语音输入 (Ctrl+Shift+V)'}
className={`p-2 rounded-xl transition-all flex-shrink-0 border-2 ${
isListening
? 'voice-btn-active bg-red-500 border-red-500 text-white'
: 'bg-gray-100 dark:bg-gray-700 border-gray-200 dark:border-gray-600 text-gray-500 hover:text-red-500 hover:border-red-300'
} disabled:opacity-40 disabled:cursor-not-allowed`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5"
>
<path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M7.5 11a4.5 4.5 0 0 0 9 0h1.5a6 6 0 0 1-5.25 5.95V20h3.75v1.5h-9v-1.5h3.75v-2.05A6 6 0 0 1 6 11h1.5Z" />
</svg>
</button>
)}
{/* 不支持时显示禁用按钮 */}
{!isSupported && (
<button
disabled
title="您的浏览器不支持语音识别"
className="p-2 rounded-xl bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-300 dark:text-gray-600 flex-shrink-0 cursor-not-allowed"
aria-label="语音输入不可用"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5"
>
<path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M7.5 11a4.5 4.5 0 0 0 9 0h1.5a6 6 0 0 1-5.25 5.95V20h3.75v1.5h-9v-1.5h3.75v-2.05A6 6 0 0 1 6 11h1.5Z" />
<line x1="4" y1="4" x2="20" y2="20" stroke="currentColor" strokeWidth="2" />
</svg>
</button>
)}
{/* 发送按钮 */}
<button
onClick={() => setMode('voice_msg')}
className={`p-2 rounded-lg text-xs transition-colors ${
mode === 'voice_msg'
? 'bg-pink-100 text-pink-600 dark:bg-pink-900 dark:text-pink-300'
: 'text-gray-400 hover:text-gray-600'
}`}
title="语音消息"
onClick={handleSend}
disabled={disabled || uploading || (!content.trim() && pendingImages.length === 0)}
className="p-2 rounded-xl bg-pink-400 text-white hover:bg-pink-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex-shrink-0"
>
🎤
{uploading ? (
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5"
>
<path d="M3.478 2.404a.75.75 0 0 0-.926.941l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519 0 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.404Z" />
</svg>
)}
</button>
</div>
{/* 输入框 */}
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder="和昔涟说点什么吧..."
disabled={disabled}
rows={1}
className="flex-1 resize-none rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 px-4 py-2 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400 focus:border-transparent disabled:opacity-50"
/>
{/* 语音输入状态提示 */}
{isListening && (
<p className="text-xs text-red-400 text-center animate-pulse">
🎤 ...
<span className="text-gray-400 ml-2">(Ctrl+Shift+V )</span>
</p>
)}
{/* 发送按钮 */}
<button
onClick={handleSend}
disabled={disabled || !content.trim()}
className="p-2 rounded-xl bg-pink-400 text-white hover:bg-pink-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex-shrink-0"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5"
>
<path d="M3.478 2.404a.75.75 0 0 0-.926.941l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519 0 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.404Z" />
</svg>
</button>
{mode !== 'text' && !isListening && (
<p className="text-xs text-gray-400 text-center">
{mode === 'voice_msg' ? '语音消息功能即将上线 ♪' : ''}
</p>
)}
</div>
{mode !== 'text' && (
<p className="text-xs text-gray-400 text-center mt-2">
{mode === 'voice_msg' ? '语音消息功能即将上线 ♪' : ''}
</p>
)}
</div>
);
}
@@ -0,0 +1,216 @@
import { useEffect, useCallback, useRef, useState } from 'react';
import type { MessageAttachment } from '@/types/chat';
interface ImageLightboxProps {
attachments: MessageAttachment[];
currentIndex: number;
onClose: () => void;
}
export function ImageLightbox({ attachments, currentIndex, onClose }: ImageLightboxProps) {
const [index, setIndex] = useState(currentIndex);
const [imgLoaded, setImgLoaded] = useState(false);
const [imgError, setImgError] = useState(false);
const overlayRef = useRef<HTMLDivElement>(null);
const current = attachments[index];
const hasMultiple = attachments.length > 1;
// 键盘导航
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
switch (e.key) {
case 'Escape':
onClose();
break;
case 'ArrowLeft':
if (hasMultiple) {
setIndex((prev) => (prev - 1 + attachments.length) % attachments.length);
setImgLoaded(false);
setImgError(false);
}
break;
case 'ArrowRight':
if (hasMultiple) {
setIndex((prev) => (prev + 1) % attachments.length);
setImgLoaded(false);
setImgError(false);
}
break;
}
},
[onClose, hasMultiple, attachments.length]
);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [handleKeyDown]);
// 点击背景关闭
const handleOverlayClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === overlayRef.current) {
onClose();
}
},
[onClose]
);
// 下载当前图片
const handleDownload = useCallback(async () => {
if (!current?.url) return;
try {
const token = localStorage.getItem('token');
const resp = await fetch(current.url, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!resp.ok) throw new Error('Download failed');
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = current.filename || 'image';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error('[ImageLightbox] 下载失败:', err);
// 降级:在新窗口打开
window.open(current.url, '_blank');
}
}, [current]);
if (!current) return null;
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-sm animate-fade-in"
role="dialog"
aria-modal="true"
aria-label="图片预览"
>
{/* 关闭按钮 */}
<button
onClick={onClose}
className="absolute top-4 right-4 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
aria-label="关闭"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* 左箭头 */}
{hasMultiple && (
<button
onClick={() => {
setIndex((prev) => (prev - 1 + attachments.length) % attachments.length);
setImgLoaded(false);
setImgError(false);
}}
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 p-3 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
aria-label="上一张"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
)}
{/* 右箭头 */}
{hasMultiple && (
<button
onClick={() => {
setIndex((prev) => (prev + 1) % attachments.length);
setImgLoaded(false);
setImgError(false);
}}
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 p-3 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
aria-label="下一张"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
)}
{/* 图片容器 */}
<div className="flex flex-col items-center max-w-[90vw] max-h-[90vh] gap-4">
{/* 加载中状态 */}
{!imgLoaded && !imgError && (
<div className="flex items-center justify-center w-64 h-64 text-white/60">
<svg className="animate-spin h-10 w-10" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
)}
{/* 加载失败 */}
{imgError && (
<div className="flex flex-col items-center gap-2 text-white/60">
<span className="text-4xl">🖼</span>
<span className="text-sm"></span>
</div>
)}
{/* 图片 */}
<img
src={current.url}
alt={current.filename || '图片'}
className={`max-w-[85vw] max-h-[70vh] object-contain rounded-lg shadow-2xl ${imgLoaded ? 'block' : 'hidden'}`}
onLoad={() => setImgLoaded(true)}
onError={() => setImgError(true)}
/>
{/* 底部信息栏 */}
<div className="flex flex-col items-center gap-1 text-white/80 text-sm">
{/* 计数器 */}
{hasMultiple && (
<span className="text-white/50 text-xs">
{index + 1} / {attachments.length}
</span>
)}
{/* 文件名 */}
{current.filename && (
<span className="text-white/70 text-xs">{current.filename}</span>
)}
{/* 尺寸信息 */}
{current.width && current.height && (
<span className="text-white/50 text-xs">
{current.width} × {current.height}
</span>
)}
{/* AI 描述 */}
{current.description && (
<p className="text-white/80 text-xs text-center max-w-lg mt-1 px-4">
{current.description}
</p>
)}
{/* 下载按钮 */}
<button
onClick={handleDownload}
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/20 text-white text-xs transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4">
<path fillRule="evenodd" d="M12 2.25a.75.75 0 01.75.75v11.69l3.22-3.22a.75.75 0 111.06 1.06l-4.5 4.5a.75.75 0 01-1.06 0l-4.5-4.5a.75.75 0 111.06-1.06l3.22 3.22V3a.75.75 0 01.75-.75zm-9 13.5a.75.75 0 01.75.75v2.25a1.5 1.5 0 001.5 1.5h13.5a1.5 1.5 0 001.5-1.5V16.5a.75.75 0 011.5 0v2.25a3 3 0 01-3 3H5.25a3 3 0 01-3-3V16.5a.75.75 0 01.75-.75z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
);
}
@@ -1,12 +1,16 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
import { useAuthStore } from '@/store/authStore';
import { useSpeechSynthesis } from '@/hooks/useSpeechSynthesis';
import type { MessageAttachment } from '@/types/chat';
import { ImageLightbox } from './ImageLightbox';
interface MessageBubbleProps {
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
isStreaming?: boolean;
attachments?: MessageAttachment[];
}
/**
@@ -72,8 +76,70 @@ function useTypewriter(content: string, isStreaming: boolean): string {
return displayed;
}
export function MessageBubble({ role, content, timestamp, isStreaming }: MessageBubbleProps) {
/**
* AI 消息的操作栏 — 包含 TTS 朗读按钮
*/
function AIMessageActions({ content }: { content: string }) {
const { isSpeaking, isSupported, speak, stop } = useSpeechSynthesis();
const [isThisSpeaking, setIsThisSpeaking] = useState(false);
const contentRef = useRef(content);
// 当全局 TTS 正在朗读且对应的是本条消息时,设置 isThisSpeaking
useEffect(() => {
if (!isSpeaking) {
setIsThisSpeaking(false);
}
}, [isSpeaking]);
const handleToggleTTS = useCallback(() => {
if (isThisSpeaking) {
stop();
setIsThisSpeaking(false);
} else {
setIsThisSpeaking(true);
speak(content, { lang: 'zh-CN' });
// 朗读结束后重置
const checkEnd = setInterval(() => {
if (!window.speechSynthesis.speaking) {
setIsThisSpeaking(false);
clearInterval(checkEnd);
}
}, 200);
}
}, [content, isThisSpeaking, speak, stop]);
if (!isSupported) return null;
return (
<div className="flex items-center gap-1 mt-1.5">
<button
onClick={handleToggleTTS}
className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full transition-all duration-200 ${
isThisSpeaking
? 'bg-pink-100 text-pink-600 tts-playing'
: 'text-gray-400 hover:text-pink-500 hover:bg-pink-50'
}`}
title={isThisSpeaking ? '停止朗读' : '朗读此消息'}
>
{isThisSpeaking ? (
<>
<span className="text-sm"></span>
<span></span>
</>
) : (
<>
<span className="text-sm">🔊</span>
<span></span>
</>
)}
</button>
</div>
);
}
export function MessageBubble({ role, content, timestamp, isStreaming, attachments }: MessageBubbleProps) {
const isUser = role === 'user';
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
@@ -84,6 +150,9 @@ export function MessageBubble({ role, content, timestamp, isStreaming }: Message
// 判断是否还有未显示完的字符
const hasMoreChars = isStreaming && displayedContent.length < content.length;
// 图片附件
const imageAttachments = attachments?.filter((a) => a.type === 'image') ?? [];
return (
<div className={`flex px-4 py-2 gap-3 ${isUser ? 'flex-row-reverse' : ''}`}>
{/* 头像 */}
@@ -110,23 +179,114 @@ export function MessageBubble({ role, content, timestamp, isStreaming }: Message
<span className="animate-streaming-cursor" />
)}
</p>
{!isStreaming && (
<p
className={`text-xs mt-1 ${
isUser ? 'text-pink-100' : 'text-gray-400'
{/* 图片附件网格 */}
{!isStreaming && imageAttachments.length > 0 && (
<div
className={`grid gap-1.5 mt-2 ${
imageAttachments.length === 1
? 'grid-cols-1'
: imageAttachments.length === 2
? 'grid-cols-2'
: 'grid-cols-3'
}`}
>
{time}
</p>
{imageAttachments.map((att, idx) => (
<ImageThumbnail
key={idx}
attachment={att}
onClick={() => setLightboxIndex(idx)}
/>
))}
</div>
)}
{!isStreaming && (
<>
{/* AI 消息操作栏(朗读按钮) */}
{!isUser && <AIMessageActions content={content} />}
<p
className={`text-xs mt-1 ${
isUser ? 'text-pink-100' : 'text-gray-400'
}`}
>
{time}
</p>
</>
)}
</div>
{/* 用户头像 */}
{isUser && <UserAvatar />}
{/* Lightbox */}
{lightboxIndex !== null && (
<ImageLightbox
attachments={imageAttachments}
currentIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
/>
)}
</div>
);
}
/** 图片缩略图组件 */
function ImageThumbnail({
attachment,
onClick,
}: {
attachment: MessageAttachment;
onClick: () => void;
}) {
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
const url = attachment.thumbnail_url || attachment.url;
return (
<button
onClick={onClick}
className="relative w-full aspect-square rounded-lg overflow-hidden border border-pink-100 dark:border-pink-800 bg-gray-100 dark:bg-gray-700 hover:ring-2 hover:ring-pink-400 transition-all cursor-pointer group"
aria-label={`查看图片: ${attachment.filename || '未命名图片'}`}
>
{/* 加载中 */}
{!loaded && !error && (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
)}
{/* 加载失败 */}
{error && (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
<span className="text-2xl">🖼</span>
</div>
)}
{/* 图片 */}
<img
src={url}
alt={attachment.filename || '图片'}
className={`w-full h-full object-cover transition-transform group-hover:scale-105 ${loaded ? 'block' : 'hidden'}`}
onLoad={() => setLoaded(true)}
onError={() => setError(true)}
/>
{/* 悬停遮罩 */}
{loaded && (
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-opacity">
<path fillRule="evenodd" d="M15 3.75a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0V5.56l-3.97 3.97a.75.75 0 11-1.06-1.06l3.97-3.97h-3.69a.75.75 0 010-1.5h4.5zM5.25 6.75a3 3 0 00-3 3v7.5a3 3 0 003 3h13.5a3 3 0 003-3v-7.5a3 3 0 00-3-3H15a.75.75 0 000 1.5h3.75a1.5 1.5 0 011.5 1.5v7.5a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-7.5a1.5 1.5 0 011.5-1.5H9a.75.75 0 000-1.5H5.25z" clipRule="evenodd" />
</svg>
</div>
)}
</button>
);
}
/** 用户头像组件:管理员使用 Admin_Avatar.jpg,普通用户使用 Default_Avatar.png */
function UserAvatar() {
const [imgError, setImgError] = useState(false);
@@ -39,6 +39,7 @@ export function MessageList({ messages, isTyping }: MessageListProps) {
content={msg.content}
timestamp={msg.timestamp}
isStreaming={msg.isStreaming}
attachments={msg.attachments}
/>
))}
{isTyping && !messages.some((m) => m.isStreaming) && <TypingIndicator />}
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Sidebar } from './Sidebar';
import { Header } from './Header';
import { SearchModal } from './SearchModal';
import { useAuth } from '@/hooks/useAuth';
interface AppLayoutProps {
@@ -9,6 +10,7 @@ interface AppLayoutProps {
export function AppLayout({ children }: AppLayoutProps) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const { isLoggedIn } = useAuth();
return (
@@ -36,9 +38,17 @@ export function AppLayout({ children }: AppLayoutProps) {
{/* 主内容区 */}
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
{isLoggedIn && <Header onMenuClick={() => setSidebarOpen(!sidebarOpen)} />}
{isLoggedIn && (
<Header
onMenuClick={() => setSidebarOpen(!sidebarOpen)}
onSearchClick={() => setSearchOpen(true)}
/>
)}
<main className="flex-1 min-h-0 overflow-hidden">{children}</main>
</div>
{/* 搜索弹窗 */}
<SearchModal isOpen={searchOpen} onClose={() => setSearchOpen(false)} />
</div>
);
}
@@ -0,0 +1,841 @@
import { useState, useEffect, useCallback } from 'react';
import {
listRules,
listScenes,
createRule,
createScene,
updateRule,
updateScene,
deleteRule,
deleteScene,
triggerRule,
executeScene,
toggleRule,
type AutomationRule,
type AutomationScene,
} from '@/api/automation';
interface AutomationPanelProps {
onClose: () => void;
}
/** 触发类型中文映射 */
const TRIGGER_LABELS: Record<string, string> = {
schedule: '⏰ 定时',
device_state: '📡 设备状态',
manual: '🖐️ 手动',
};
/** 触发类型颜色 */
const TRIGGER_COLORS: Record<string, string> = {
schedule: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
device_state: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
manual: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
};
/** JSON 安全格式化 */
function safeJSON(raw: unknown, fallback = '-'): string {
if (!raw) return fallback;
try {
if (typeof raw === 'string') return raw;
return JSON.stringify(raw, null, 1);
} catch {
return fallback;
}
}
/** 格式化时间 */
function formatTime(ts: string): string {
try {
const d = new Date(ts);
return d.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return ts;
}
}
/** 默认动作模板 */
const DEFAULT_ACTIONS = [
{
type: 'set_device',
device_id: '',
property: '',
value: '',
},
];
/** 默认定时触发配置模板 */
const DEFAULT_SCHEDULE_TRIGGER = {
time: '08:00',
days: ['mon', 'tue', 'wed', 'thu', 'fri'],
};
export function AutomationPanel({ onClose }: AutomationPanelProps) {
const [activeTab, setActiveTab] = useState<'rules' | 'scenes'>('rules');
const [viewMode, setViewMode] = useState<'list' | 'create' | 'edit'>('list');
// 数据
const [rules, setRules] = useState<AutomationRule[]>([]);
const [scenes, setScenes] = useState<AutomationScene[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [successMsg, setSuccessMsg] = useState('');
// 规则表单
const [editingRuleId, setEditingRuleId] = useState<string | null>(null);
const [formName, setFormName] = useState('');
const [formDesc, setFormDesc] = useState('');
const [formTriggerType, setFormTriggerType] = useState('schedule');
const [formTriggerConfig, setFormTriggerConfig] = useState(JSON.stringify(DEFAULT_SCHEDULE_TRIGGER, null, 2));
const [formConditions, setFormConditions] = useState('');
const [formActions, setFormActions] = useState(JSON.stringify(DEFAULT_ACTIONS, null, 2));
const [submitting, setSubmitting] = useState(false);
// 场景表单
const [editingSceneId, setEditingSceneId] = useState<string | null>(null);
const [sceneFormName, setSceneFormName] = useState('');
const [sceneFormIcon, setSceneFormIcon] = useState('🏠');
const [sceneFormRuleIds, setSceneFormRuleIds] = useState('');
const [sceneSubmitting, setSceneSubmitting] = useState(false);
// 展开的规则详情
const [expandedRuleId, setExpandedRuleId] = useState<string | null>(null);
const showSuccess = (msg: string) => {
setSuccessMsg(msg);
setTimeout(() => setSuccessMsg(''), 2000);
};
// 加载数据
const loadData = useCallback(async () => {
setLoading(true);
setError('');
const [rulesResp, scenesResp] = await Promise.all([listRules(), listScenes()]);
if (rulesResp.error) {
setError(rulesResp.error);
} else {
setRules(rulesResp.data?.rules ?? []);
}
if (scenesResp.error) {
// 不影响规则列表显示
} else {
setScenes(scenesResp.data?.scenes ?? []);
}
setLoading(false);
}, []);
useEffect(() => {
loadData();
}, [loadData]);
// 重置规则表单
const resetRuleForm = () => {
setEditingRuleId(null);
setFormName('');
setFormDesc('');
setFormTriggerType('schedule');
setFormTriggerConfig(JSON.stringify(DEFAULT_SCHEDULE_TRIGGER, null, 2));
setFormConditions('');
setFormActions(JSON.stringify(DEFAULT_ACTIONS, null, 2));
};
// 打开规则编辑
const openRuleEdit = (rule: AutomationRule) => {
setEditingRuleId(rule.id);
setFormName(rule.name);
setFormDesc(rule.description || '');
setFormTriggerType(rule.trigger_type);
setFormTriggerConfig(safeJSON(rule.trigger_config, '{}'));
setFormConditions(safeJSON(rule.conditions, ''));
setFormActions(safeJSON(rule.actions, '[]'));
setViewMode('edit');
};
// 提交规则表单
const handleRuleSubmit = async () => {
if (!formName.trim()) return;
setSubmitting(true);
setError('');
let triggerConfig: unknown = undefined;
let conditions: unknown = undefined;
let actions: unknown = undefined;
try {
if (formTriggerConfig.trim()) triggerConfig = JSON.parse(formTriggerConfig);
} catch {
setError('触发配置 JSON 格式错误');
setSubmitting(false);
return;
}
try {
if (formConditions.trim()) conditions = JSON.parse(formConditions);
} catch {
setError('条件配置 JSON 格式错误');
setSubmitting(false);
return;
}
try {
actions = JSON.parse(formActions);
} catch {
setError('动作配置 JSON 格式错误');
setSubmitting(false);
return;
}
if (editingRuleId) {
const resp = await updateRule(editingRuleId, {
name: formName.trim(),
description: formDesc.trim(),
trigger_type: formTriggerType,
trigger_config: triggerConfig,
conditions,
actions,
});
if (resp.error) {
setError(resp.error);
} else {
showSuccess('规则已更新');
resetRuleForm();
setViewMode('list');
loadData();
}
} else {
const resp = await createRule({
name: formName.trim(),
description: formDesc.trim(),
trigger_type: formTriggerType,
trigger_config: triggerConfig,
conditions,
actions,
});
if (resp.error) {
setError(resp.error);
} else {
showSuccess('规则已创建');
resetRuleForm();
setViewMode('list');
loadData();
}
}
setSubmitting(false);
};
// 删除规则
const handleDeleteRule = async (id: string) => {
if (!confirm('确定要删除这条规则吗?')) return;
const resp = await deleteRule(id);
if (resp.error) {
setError(resp.error);
} else {
showSuccess('规则已删除');
loadData();
}
};
// 手动触发规则
const handleTriggerRule = async (id: string) => {
const resp = await triggerRule(id);
if (resp.error) {
setError(resp.error);
} else {
showSuccess('规则已触发');
}
};
// 切换规则启用状态
const handleToggleRule = async (rule: AutomationRule) => {
const resp = await toggleRule(rule.id, !rule.enabled);
if (resp.error) {
setError(resp.error);
} else {
showSuccess(rule.enabled ? '规则已禁用' : '规则已启用');
loadData();
}
};
// 重置场景表单
const resetSceneForm = () => {
setEditingSceneId(null);
setSceneFormName('');
setSceneFormIcon('🏠');
setSceneFormRuleIds('');
};
// 打开场景编辑
const openSceneEdit = (scene: AutomationScene) => {
setEditingSceneId(scene.id);
setSceneFormName(scene.name);
setSceneFormIcon(scene.icon || '🏠');
setSceneFormRuleIds(safeJSON(scene.rule_ids, ''));
setViewMode('edit');
};
// 提交场景表单
const handleSceneSubmit = async () => {
if (!sceneFormName.trim()) return;
setSceneSubmitting(true);
setError('');
let ruleIds: string[] | undefined = undefined;
try {
if (sceneFormRuleIds.trim()) {
ruleIds = JSON.parse(sceneFormRuleIds);
}
} catch {
setError('规则 ID 列表 JSON 格式错误');
setSceneSubmitting(false);
return;
}
if (editingSceneId) {
const resp = await updateScene(editingSceneId, {
name: sceneFormName.trim(),
icon: sceneFormIcon,
rule_ids: ruleIds,
});
if (resp.error) {
setError(resp.error);
} else {
showSuccess('场景已更新');
resetSceneForm();
setViewMode('list');
loadData();
}
} else {
const resp = await createScene({
name: sceneFormName.trim(),
icon: sceneFormIcon,
rule_ids: ruleIds,
});
if (resp.error) {
setError(resp.error);
} else {
showSuccess('场景已创建');
resetSceneForm();
setViewMode('list');
loadData();
}
}
setSceneSubmitting(false);
};
// 删除场景
const handleDeleteScene = async (id: string) => {
if (!confirm('确定要删除这个场景吗?')) return;
const resp = await deleteScene(id);
if (resp.error) {
setError(resp.error);
} else {
showSuccess('场景已删除');
loadData();
}
};
// 执行场景
const handleExecuteScene = async (id: string) => {
const resp = await executeScene(id);
if (resp.error) {
setError(resp.error);
} else {
showSuccess('场景已执行');
}
};
// 获取规则名称映射
const ruleNameMap: Record<string, string> = {};
rules.forEach((r) => { ruleNameMap[r.id] = r.name; });
// 解析场景中的规则列表
const getSceneRuleNames = (scene: AutomationScene): string[] => {
if (!scene.rule_ids) return [];
try {
const ids: string[] = typeof scene.rule_ids === 'string'
? JSON.parse(scene.rule_ids)
: (scene.rule_ids as string[]);
return ids.map((id) => ruleNameMap[id] || id);
} catch {
return [];
}
};
return (
<div className="w-full max-h-full flex flex-col">
{/* 顶部 Tab 切换 */}
<div className="flex items-center border-b border-gray-100 dark:border-gray-700">
<button
onClick={() => { setActiveTab('rules'); setViewMode('list'); }}
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
activeTab === 'rules'
? 'text-pink-500 border-b-2 border-pink-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
>
</button>
<button
onClick={() => { setActiveTab('scenes'); setViewMode('list'); }}
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
activeTab === 'scenes'
? 'text-pink-500 border-b-2 border-pink-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
>
</button>
</div>
{/* 消息提示 */}
{successMsg && (
<div className="px-3 py-2 text-xs text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400">
{successMsg}
</div>
)}
{error && (
<div className="px-3 py-2 text-xs text-red-500 bg-red-50 dark:bg-red-900/20">
{error}
<button
onClick={() => setError('')}
className="ml-2 underline hover:no-underline"
>
</button>
</div>
)}
{/* ========== 规则面板 ========== */}
{activeTab === 'rules' && (
<>
{/* 列表模式 */}
{viewMode === 'list' && (
<>
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-50 dark:border-gray-700">
<span className="text-[10px] text-gray-400">
{rules.length}
</span>
<button
onClick={() => { resetRuleForm(); setViewMode('create'); }}
className="px-2.5 py-1 text-[11px] bg-pink-500 text-white rounded-full hover:bg-pink-600 transition-colors"
>
+
</button>
</div>
<div className="overflow-y-auto max-h-72">
{loading ? (
<div className="px-4 py-8 text-center text-sm text-gray-400">
...
</div>
) : rules.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-gray-400">
+
</div>
) : (
<div className="divide-y divide-gray-50 dark:divide-gray-700">
{rules.map((rule) => (
<div key={rule.id} className="px-3 py-2.5">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<button
onClick={() => handleToggleRule(rule)}
className={`relative inline-flex h-4 w-8 items-center rounded-full transition-colors flex-shrink-0 ${
rule.enabled ? 'bg-pink-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-3 w-3 rounded-full bg-white transition-transform ${
rule.enabled ? 'translate-x-[18px]' : 'translate-x-[2px]'
}`}
/>
</button>
<span className={`text-sm truncate ${rule.enabled ? 'text-gray-800 dark:text-gray-200' : 'text-gray-400'}`}>
{rule.name}
</span>
</div>
<div className="flex items-center gap-2 mt-1">
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${TRIGGER_COLORS[rule.trigger_type] || 'bg-gray-100 text-gray-600'}`}>
{TRIGGER_LABELS[rule.trigger_type] || rule.trigger_type}
</span>
{rule.last_triggered_at && (
<span className="text-[10px] text-gray-400">
: {formatTime(rule.last_triggered_at)}
</span>
)}
</div>
</div>
<div className="flex items-center gap-0.5 flex-shrink-0">
{rule.trigger_type === 'manual' && (
<button
onClick={() => handleTriggerRule(rule.id)}
title="手动触发"
className="p-1 text-gray-300 hover:text-green-500 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
)}
<button
onClick={() => openRuleEdit(rule)}
title="编辑"
className="p-1 text-gray-300 hover:text-blue-500 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDeleteRule(rule.id)}
title="删除"
className="p-1 text-gray-300 hover:text-red-500 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
{/* 展开详情 */}
{expandedRuleId === rule.id && (
<div className="mt-2 pl-8 space-y-1 text-[11px] text-gray-500 dark:text-gray-400">
{rule.description && (
<p>📝 {rule.description}</p>
)}
<p className="whitespace-pre-wrap font-mono text-[10px] bg-gray-50 dark:bg-gray-750 p-1 rounded">
: {safeJSON(rule.trigger_config)}
</p>
{rule.conditions != null && (
<p className="whitespace-pre-wrap font-mono text-[10px] bg-gray-50 dark:bg-gray-750 p-1 rounded">
: {safeJSON(rule.conditions)}
</p>
)}
<p className="whitespace-pre-wrap font-mono text-[10px] bg-gray-50 dark:bg-gray-750 p-1 rounded">
: {safeJSON(rule.actions)}
</p>
</div>
)}
<button
onClick={() => setExpandedRuleId(expandedRuleId === rule.id ? null : rule.id)}
className="mt-1 ml-8 text-[10px] text-pink-400 hover:text-pink-500"
>
{expandedRuleId === rule.id ? '收起 ▲' : '展开 ▼'}
</button>
</div>
))}
</div>
)}
</div>
</>
)}
{/* 创建/编辑规则表单 */}
{(viewMode === 'create' || viewMode === 'edit') && (
<div className="p-3 space-y-3 overflow-y-auto max-h-80">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
{editingRuleId ? '编辑规则' : '新建规则'}
</span>
<button
onClick={() => { setViewMode('list'); resetRuleForm(); }}
className="text-[10px] text-gray-400 hover:text-gray-600"
>
</button>
</div>
<div>
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1"> *</label>
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="例如:夜间自动关灯"
maxLength={200}
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400"
/>
</div>
<div>
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1"></label>
<textarea
value={formDesc}
onChange={(e) => setFormDesc(e.target.value)}
placeholder="规则的用途说明"
rows={2}
maxLength={500}
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400 resize-none"
/>
</div>
<div>
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1"></label>
<select
value={formTriggerType}
onChange={(e) => setFormTriggerType(e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400"
>
<option value="schedule"> (schedule)</option>
<option value="device_state"> (device_state)</option>
<option value="manual"> (manual)</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1"> (JSON)</label>
<textarea
value={formTriggerConfig}
onChange={(e) => setFormTriggerConfig(e.target.value)}
placeholder='{"time":"08:00","days":["mon","tue"]}'
rows={3}
className="w-full px-3 py-1.5 text-xs font-mono border border-gray-200 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-750 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400 resize-none"
/>
</div>
<div>
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1"> (JSON, )</label>
<textarea
value={formConditions}
onChange={(e) => setFormConditions(e.target.value)}
placeholder='[{"type":"time_range","start":"22:00","end":"06:00"}]'
rows={3}
className="w-full px-3 py-1.5 text-xs font-mono border border-gray-200 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-750 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400 resize-none"
/>
</div>
<div>
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1"> (JSON) *</label>
<textarea
value={formActions}
onChange={(e) => setFormActions(e.target.value)}
placeholder='[{"type":"set_device","device_id":"","property":"","value":""}]'
rows={4}
className="w-full px-3 py-1.5 text-xs font-mono border border-gray-200 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-750 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400 resize-none"
/>
</div>
<div className="flex gap-2">
<button
onClick={() => { setViewMode('list'); resetRuleForm(); }}
className="flex-1 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded-lg text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={handleRuleSubmit}
disabled={submitting || !formName.trim() || !formActions.trim()}
className="flex-1 py-1.5 text-xs bg-pink-500 text-white rounded-lg hover:bg-pink-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{submitting ? '保存中...' : editingRuleId ? '更新规则' : '创建规则'}
</button>
</div>
</div>
)}
</>
)}
{/* ========== 场景面板 ========== */}
{activeTab === 'scenes' && (
<>
{viewMode === 'list' && (
<>
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-50 dark:border-gray-700">
<span className="text-[10px] text-gray-400">
{scenes.length}
</span>
<button
onClick={() => { resetSceneForm(); setViewMode('create'); }}
className="px-2.5 py-1 text-[11px] bg-pink-500 text-white rounded-full hover:bg-pink-600 transition-colors"
>
+
</button>
</div>
<div className="overflow-y-auto max-h-72">
{loading ? (
<div className="px-4 py-8 text-center text-sm text-gray-400">
...
</div>
) : scenes.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-gray-400">
🎬 +
</div>
) : (
<div className="divide-y divide-gray-50 dark:divide-gray-700">
{scenes.map((scene) => {
const ruleNames = getSceneRuleNames(scene);
return (
<div key={scene.id} className="px-3 py-2.5">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-lg">{scene.icon || '🏠'}</span>
<span className="text-sm text-gray-800 dark:text-gray-200 truncate">
{scene.name}
</span>
</div>
{ruleNames.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1 ml-8">
{ruleNames.map((name, i) => (
<span
key={i}
className="text-[10px] px-1.5 py-0.5 bg-pink-50 dark:bg-pink-900/20 text-pink-600 dark:text-pink-400 rounded-full"
>
{name}
</span>
))}
</div>
)}
<span className="text-[10px] text-gray-400 ml-8 mt-1 block">
{formatTime(scene.created_at)}
</span>
</div>
<div className="flex items-center gap-0.5 flex-shrink-0">
<button
onClick={() => handleExecuteScene(scene.id)}
title="执行场景"
className="p-1 text-gray-300 hover:text-green-500 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<button
onClick={() => openSceneEdit(scene)}
title="编辑"
className="p-1 text-gray-300 hover:text-blue-500 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDeleteScene(scene.id)}
title="删除"
className="p-1 text-gray-300 hover:text-red-500 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</>
)}
{/* 创建/编辑场景表单 */}
{(viewMode === 'create' || viewMode === 'edit') && (
<div className="p-3 space-y-3 overflow-y-auto max-h-80">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
{editingSceneId ? '编辑场景' : '新建场景'}
</span>
<button
onClick={() => { setViewMode('list'); resetSceneForm(); }}
className="text-[10px] text-gray-400 hover:text-gray-600"
>
</button>
</div>
<div>
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1"> *</label>
<input
type="text"
value={sceneFormName}
onChange={(e) => setSceneFormName(e.target.value)}
placeholder="例如:回家模式"
maxLength={200}
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400"
/>
</div>
<div>
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1"></label>
<input
type="text"
value={sceneFormIcon}
onChange={(e) => setSceneFormIcon(e.target.value)}
placeholder="🏠"
maxLength={10}
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400"
/>
</div>
<div>
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
ID (JSON )
</label>
<textarea
value={sceneFormRuleIds}
onChange={(e) => setSceneFormRuleIds(e.target.value)}
placeholder='["rule_id_1","rule_id_2"]'
rows={3}
className="w-full px-3 py-1.5 text-xs font-mono border border-gray-200 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-750 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400 resize-none"
/>
{rules.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
<span className="text-[10px] text-gray-400"></span>
{rules.map((r) => (
<button
key={r.id}
onClick={() => {
try {
const ids: string[] = sceneFormRuleIds.trim()
? JSON.parse(sceneFormRuleIds)
: [];
if (!ids.includes(r.id)) {
setSceneFormRuleIds(JSON.stringify([...ids, r.id]));
}
} catch {
setSceneFormRuleIds(JSON.stringify([r.id]));
}
}}
className="text-[10px] px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded hover:bg-pink-100 dark:hover:bg-pink-900/30 transition-colors"
title={r.id}
>
{r.name}
</button>
))}
</div>
)}
</div>
<div className="flex gap-2">
<button
onClick={() => { setViewMode('list'); resetSceneForm(); }}
className="flex-1 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded-lg text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={handleSceneSubmit}
disabled={sceneSubmitting || !sceneFormName.trim()}
className="flex-1 py-1.5 text-xs bg-pink-500 text-white rounded-lg hover:bg-pink-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{sceneSubmitting ? '保存中...' : editingSceneId ? '更新场景' : '创建场景'}
</button>
</div>
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,317 @@
import { useState, useEffect } from 'react';
import { getBriefing, getLatestBriefings, generateBriefing, formatBriefingDate, type Briefing } from '@/api/briefings';
interface BriefingPanelProps {
userId: string;
onClose: () => void;
}
/** 天气 emoji 映射 */
function weatherIcon(icon: string): string {
return icon || '🌤️';
}
/** 格式化提醒时间 */
function formatRemindTime(ts: string): string {
try {
const d = new Date(ts);
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
} catch {
return ts;
}
}
/** 获取今天的日期字符串 */
function todayStr(): string {
const d = new Date();
return d.toISOString().slice(0, 10);
}
export function BriefingPanel({ userId, onClose }: BriefingPanelProps) {
const [briefing, setBriefing] = useState<Briefing | null>(null);
const [history, setHistory] = useState<Briefing[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [error, setError] = useState('');
const [selectedDate, setSelectedDate] = useState(todayStr());
const [viewMode, setViewMode] = useState<'today' | 'history'>('today');
// 加载今日简报
useEffect(() => {
loadBriefing(todayStr());
loadHistory();
}, [userId]);
const loadBriefing = async (date: string) => {
setLoading(true);
setError('');
try {
const resp = await getBriefing(userId, date);
if (resp.error) {
setError(resp.error);
setBriefing(null);
} else if (resp.data?.briefing) {
setBriefing(resp.data.briefing);
} else {
setBriefing(null);
}
} catch {
setError('获取简报失败');
}
setLoading(false);
};
const loadHistory = async () => {
try {
const resp = await getLatestBriefings(userId, 7);
if (resp.data?.briefings) {
setHistory(resp.data.briefings);
}
} catch {
// 静默失败
}
};
const handleGenerate = async () => {
setGenerating(true);
setError('');
try {
const resp = await generateBriefing(userId);
if (resp.error) {
setError(resp.error);
} else if (resp.data?.success && resp.data.briefing) {
setBriefing(resp.data.briefing);
// 刷新历史
loadHistory();
} else {
setError(resp.data?.error || '生成简报失败');
}
} catch {
setError('生成简报请求失败');
}
setGenerating(false);
};
const handleDateChange = (date: string) => {
setSelectedDate(date);
loadBriefing(date);
};
return (
<div className="flex flex-col h-full max-h-full">
{/* 标签页切换 */}
<div className="flex border-b border-gray-100 dark:border-gray-700 shrink-0">
<button
onClick={() => setViewMode('today')}
className={`flex-1 py-2 text-xs font-medium transition-colors ${
viewMode === 'today'
? 'text-pink-500 border-b-2 border-pink-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
>
📋
</button>
<button
onClick={() => setViewMode('history')}
className={`flex-1 py-2 text-xs font-medium transition-colors ${
viewMode === 'history'
? 'text-pink-500 border-b-2 border-pink-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
>
📅
</button>
</div>
{/* 今日简报视图 */}
{viewMode === 'today' && (
<div className="flex-1 overflow-y-auto p-3 space-y-3">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin w-5 h-5 border-2 border-pink-500 border-t-transparent rounded-full" />
</div>
) : error ? (
<div className="text-center py-4">
<p className="text-sm text-red-400 mb-2">{error}</p>
<button
onClick={loadBriefing.bind(null, todayStr())}
className="text-xs text-pink-500 hover:text-pink-600"
>
</button>
</div>
) : briefing ? (
<BriefingCard briefing={briefing} />
) : (
<div className="text-center py-8">
<p className="text-gray-400 text-sm mb-3"></p>
<button
onClick={handleGenerate}
disabled={generating}
className="px-4 py-2 bg-pink-500 hover:bg-pink-600 disabled:opacity-50 text-white text-sm rounded-lg transition-colors"
>
{generating ? '生成中...' : '✨ 生成今日简报'}
</button>
<p className="text-[10px] text-gray-400 mt-2">
</p>
</div>
)}
{/* 已生成时可手动重新生成 */}
{briefing && (
<div className="pt-2 border-t border-gray-100 dark:border-gray-700">
<button
onClick={handleGenerate}
disabled={generating}
className="w-full py-1.5 text-xs text-pink-500 hover:text-pink-600 disabled:opacity-50 transition-colors"
>
{generating ? '生成中...' : '🔄 重新生成简报'}
</button>
</div>
)}
</div>
)}
{/* 历史简报视图 */}
{viewMode === 'history' && (
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{history.length === 0 ? (
<div className="text-center py-8 text-sm text-gray-400">
</div>
) : (
history.map((b) => (
<button
key={b.id}
onClick={() => {
handleDateChange(b.date);
setViewMode('today');
}}
className="w-full text-left p-3 rounded-lg bg-gray-50 dark:bg-gray-750 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{formatBriefingDate(b.date)}
</span>
{b.weather && (
<span className="text-lg">{weatherIcon(b.weather.icon)}</span>
)}
</div>
{b.weather && (
<p className="text-xs text-gray-500">
{b.weather.condition} · {b.weather.temp.toFixed(0)}°C
</p>
)}
<p className="text-xs text-gray-400 mt-1 line-clamp-2">
{b.summary || '暂无摘要'}
</p>
</button>
))
)}
</div>
)}
</div>
);
}
/** 简报卡片展示组件 */
function BriefingCard({ briefing }: { briefing: Briefing }) {
return (
<div className="space-y-3">
{/* 日期 */}
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 text-center">
{formatBriefingDate(briefing.date)}
</h3>
{/* 天气卡片 */}
{briefing.weather && briefing.weather.condition && (
<div className="p-3 rounded-lg bg-gradient-to-br from-blue-50 to-cyan-50 dark:from-blue-900/20 dark:to-cyan-900/20 border border-blue-100 dark:border-blue-800/30">
<div className="flex items-center gap-3">
<span className="text-3xl">{weatherIcon(briefing.weather.icon)}</span>
<div>
<p className="text-lg font-bold text-blue-600 dark:text-blue-400">
{briefing.weather.temp.toFixed(0)}°C
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{briefing.weather.location} · {briefing.weather.condition}
</p>
</div>
</div>
</div>
)}
{/* 待办提醒 */}
{briefing.reminders.length > 0 && (
<div className="p-3 rounded-lg bg-pink-50 dark:bg-pink-900/10 border border-pink-100 dark:border-pink-800/30">
<h4 className="text-xs font-semibold text-pink-600 dark:text-pink-400 mb-2">
📋 ({briefing.reminders.length})
</h4>
<div className="space-y-1.5">
{briefing.reminders.map((r) => (
<div key={r.id} className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<span className="w-1.5 h-1.5 rounded-full bg-pink-400 flex-shrink-0" />
<span className="flex-1 truncate">{r.title}</span>
<span className="text-[10px] text-gray-400 flex-shrink-0">
{formatRemindTime(r.remind_at)}
</span>
</div>
))}
</div>
</div>
)}
{/* 新闻列表 */}
{briefing.news.length > 0 && briefing.news[0].title !== '未能获取今日新闻' && (
<div className="p-3 rounded-lg bg-green-50 dark:bg-green-900/10 border border-green-100 dark:border-green-800/30">
<h4 className="text-xs font-semibold text-green-600 dark:text-green-400 mb-2">
📰
</h4>
<div className="space-y-2">
{briefing.news.map((n, i) => (
<div key={i} className="text-xs">
{n.url ? (
<a
href={n.url}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-gray-700 dark:text-gray-300 hover:text-pink-500 transition-colors"
>
{n.title}
</a>
) : (
<span className="font-medium text-gray-700 dark:text-gray-300">
{n.title}
</span>
)}
{n.summary && (
<p className="text-gray-500 dark:text-gray-400 mt-0.5 line-clamp-2">
{n.summary}
</p>
)}
{n.source && (
<span className="text-[10px] text-gray-400">{n.source}</span>
)}
</div>
))}
</div>
</div>
)}
{/* AI 摘要 */}
{briefing.summary && (
<div className="p-3 rounded-lg bg-purple-50 dark:bg-purple-900/10 border border-purple-100 dark:border-purple-800/30">
<div className="flex items-start gap-2 mb-1">
<span className="text-sm">🌸</span>
<h4 className="text-xs font-semibold text-purple-600 dark:text-purple-400">
</h4>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-wrap">
{briefing.summary}
</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,550 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
listFiles,
deleteFile,
uploadFile,
downloadFile,
getFileThumbnailUrl,
getFileDownloadUrl,
type FileInfo,
} from '@/api/files';
interface FilePanelProps {
onClose: () => void;
}
/** MIME 类型分类 */
function getCategory(mimeType: string): 'image' | 'audio' | 'video' | 'document' | 'other' {
if (mimeType.startsWith('image/')) return 'image';
if (mimeType.startsWith('audio/')) return 'audio';
if (mimeType.startsWith('video/')) return 'video';
if (mimeType.startsWith('text/') || mimeType === 'application/pdf' || mimeType.includes('word')) return 'document';
return 'other';
}
/** 文件类型图标 */
function getFileIcon(mimeType: string): string {
const cat = getCategory(mimeType);
switch (cat) {
case 'image': return '🖼️';
case 'audio': return '🎵';
case 'video': return '🎬';
case 'document': return '📄';
default: return '📎';
}
}
/** 格式化文件大小 */
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/** 格式化时间 */
function formatTime(ts: string): string {
try {
const d = new Date(Number(ts));
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
} catch {
return ts;
}
}
/** 允许的文件扩展名 */
const ALLOWED_EXTS = new Set([
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg',
'.pdf', '.txt', '.md', '.doc', '.docx',
'.mp3', '.wav', '.ogg',
'.mp4', '.webm',
]);
/** 允许的 MIME 类型前缀 */
const ALLOWED_MIME_PREFIXES = [
'image/', 'audio/', 'video/',
'application/pdf', 'text/', 'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml',
];
export function FilePanel({ onClose }: FilePanelProps) {
// 双视图:list / upload
const [view, setView] = useState<'list' | 'upload'>('list');
// 文件列表
const [files, setFiles] = useState<FileInfo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// 筛选
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
// 上传
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [dragOver, setDragOver] = useState(false);
const [uploadErr, setUploadErr] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
// Lightbox 预览
const [previewFile, setPreviewFile] = useState<FileInfo | null>(null);
// 上下文菜单
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; file: FileInfo } | null>(null);
const limit = 20;
// 加载文件列表
const loadFiles = useCallback(async () => {
setLoading(true);
setError('');
try {
const { files: f, total: t } = await listFiles(page, limit);
setFiles(f);
setTotal(t);
} catch (e) {
setError('加载文件列表失败');
console.error('[FilePanel] 加载文件列表失败:', e);
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => {
loadFiles();
}, [loadFiles]);
const totalPages = Math.ceil(total / limit);
// 验证文件
function validateFile(file: File): string | null {
if (file.size > 20 * 1024 * 1024) {
return `文件 "${file.name}" 超过 20MB 限制`;
}
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
const mimeOk = ALLOWED_MIME_PREFIXES.some(p => file.type.startsWith(p));
const extOk = ALLOWED_EXTS.has(ext);
if (!mimeOk && !extOk) {
return `不支持的文件类型: ${file.type || ext}`;
}
return null;
}
// 处理上传
async function handleUpload(fileList: FileList | File[]) {
const filesToUpload = Array.from(fileList);
if (filesToUpload.length === 0) return;
// 验证所有文件
for (const f of filesToUpload) {
const err = validateFile(f);
if (err) {
setUploadErr(err);
return;
}
}
setUploading(true);
setUploadErr('');
setUploadProgress(0);
let successCount = 0;
for (let i = 0; i < filesToUpload.length; i++) {
try {
await uploadFile(filesToUpload[i]);
successCount++;
} catch (e) {
console.error('[FilePanel] 上传失败:', e);
}
setUploadProgress(Math.round(((i + 1) / filesToUpload.length) * 100));
}
setUploading(false);
setUploadProgress(0);
if (successCount > 0) {
setView('list');
setPage(1);
await loadFiles();
}
if (successCount < filesToUpload.length) {
setUploadErr(`${filesToUpload.length - successCount} 个文件上传失败`);
}
}
// 删除文件
async function handleDelete(file: FileInfo) {
setContextMenu(null);
if (!confirm(`确定删除 "${file.filename}" 吗?此操作不可恢复。`)) return;
try {
const ok = await deleteFile(file.id);
if (ok) {
setFiles(prev => prev.filter(f => f.id !== file.id));
setTotal(prev => prev - 1);
}
} catch (e) {
console.error('[FilePanel] 删除失败:', e);
}
}
// 下载文件
function handleDownload(file: FileInfo) {
setContextMenu(null);
downloadFile(file.id, file.filename).catch(e => {
console.error('[FilePanel] 下载失败:', e);
});
}
// 过滤文件
const filteredFiles = files.filter(f => {
if (search && !f.filename.toLowerCase().includes(search.toLowerCase())) return false;
if (typeFilter !== 'all' && getCategory(f.mime_type) !== typeFilter) return false;
return true;
});
// Lightbox 关闭
useEffect(() => {
if (!previewFile) return;
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') setPreviewFile(null);
}
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [previewFile]);
// 关闭上下文菜单
useEffect(() => {
if (!contextMenu) return;
function handleClick() { setContextMenu(null); }
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [contextMenu]);
return (
<div className="flex flex-col h-full">
{/* 视图切换标签 */}
<div className="flex border-b border-gray-100 dark:border-gray-700">
<button
onClick={() => setView('list')}
className={`flex-1 py-2 text-xs font-medium transition-colors ${
view === 'list'
? 'text-pink-500 border-b-2 border-pink-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
>
📋
</button>
<button
onClick={() => { setView('upload'); setUploadErr(''); }}
className={`flex-1 py-2 text-xs font-medium transition-colors ${
view === 'upload'
? 'text-pink-500 border-b-2 border-pink-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
>
📤
</button>
</div>
{/* 文件列表视图 */}
{view === 'list' && (
<div className="flex-1 flex flex-col min-h-0">
{/* 搜索和筛选 */}
<div className="px-3 py-2 flex gap-2">
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="🔍 搜索文件名..."
className="flex-1 px-2 py-1 text-xs border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:border-pink-300"
/>
<select
value={typeFilter}
onChange={e => setTypeFilter(e.target.value)}
className="px-2 py-1 text-xs border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none"
>
<option value="all"></option>
<option value="image">🖼 </option>
<option value="document">📄 </option>
<option value="audio">🎵 </option>
<option value="video">🎬 </option>
<option value="other">📎 </option>
</select>
</div>
{/* 文件列表 */}
<div className="flex-1 overflow-y-auto px-3">
{loading && files.length === 0 && (
<div className="flex items-center justify-center py-8 text-sm text-gray-400">
<div className="animate-spin mr-2"></div> ...
</div>
)}
{error && (
<div className="py-4 text-center text-sm text-red-500">
{error}
<button onClick={loadFiles} className="ml-2 underline"></button>
</div>
)}
{!loading && !error && filteredFiles.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-4xl mb-3">📁</span>
<p className="text-sm"></p>
<button
onClick={() => setView('upload')}
className="mt-3 px-4 py-1.5 text-xs text-pink-500 border border-pink-200 dark:border-pink-800 rounded-lg hover:bg-pink-50 dark:hover:bg-pink-900/20 transition-colors"
>
📤
</button>
</div>
)}
{filteredFiles.length > 0 && (
<div className="grid grid-cols-2 gap-2 py-2">
{filteredFiles.map(f => (
<div
key={f.id}
className="group relative flex flex-col items-center p-2 border border-gray-100 dark:border-gray-700 rounded-lg hover:border-pink-200 dark:hover:border-pink-800 hover:bg-pink-50/50 dark:hover:bg-pink-900/10 cursor-pointer transition-colors"
onClick={() => {
if (getCategory(f.mime_type) === 'image') {
setPreviewFile(f);
} else {
handleDownload(f);
}
}}
onContextMenu={e => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, file: f });
}}
>
{/* 缩略图或图标 */}
<div className="w-16 h-16 flex items-center justify-center rounded bg-gray-50 dark:bg-gray-700 mb-1 overflow-hidden">
{getCategory(f.mime_type) === 'image' ? (
<img
src={getFileThumbnailUrl(f.id)}
alt={f.filename}
className="w-full h-full object-cover"
loading="lazy"
onError={e => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
}}
/>
) : null}
<span className={`text-2xl ${getCategory(f.mime_type) === 'image' ? 'hidden' : ''}`}>
{getFileIcon(f.mime_type)}
</span>
</div>
{/* 文件名 */}
<p className="text-xs text-gray-700 dark:text-gray-300 text-center truncate w-full" title={f.filename}>
{f.filename}
</p>
{/* 大小和时间 */}
<p className="text-[10px] text-gray-400 mt-0.5">
{formatSize(f.size)} · {formatTime(f.created_at)}
</p>
{/* 操作按钮 (悬停显示) */}
<div className="absolute top-1 right-1 hidden group-hover:flex gap-0.5">
<button
onClick={e => { e.stopPropagation(); handleDownload(f); }}
className="p-1 text-gray-400 hover:text-pink-500 bg-white dark:bg-gray-800 rounded shadow-sm"
title="下载"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
<button
onClick={e => { e.stopPropagation(); handleDelete(f); }}
className="p-1 text-gray-400 hover:text-red-500 bg-white dark:bg-gray-800 rounded shadow-sm"
title="删除"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-3 py-2 border-t border-gray-100 dark:border-gray-700">
<span className="text-[10px] text-gray-400">
{total}
</span>
<div className="flex gap-1">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-2 py-0.5 text-xs rounded border border-gray-200 dark:border-gray-600 disabled:opacity-30 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
</button>
<span className="px-2 py-0.5 text-xs text-gray-500">{page} / {totalPages}</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="px-2 py-0.5 text-xs rounded border border-gray-200 dark:border-gray-600 disabled:opacity-30 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
</button>
</div>
</div>
)}
</div>
)}
{/* 上传视图 */}
{view === 'upload' && (
<div className="flex-1 flex flex-col p-4">
{/* 拖放区域 */}
<div
className={`flex-1 flex flex-col items-center justify-center border-2 border-dashed rounded-xl transition-colors ${
dragOver
? 'border-pink-400 bg-pink-50 dark:bg-pink-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-pink-300'
}`}
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={e => {
e.preventDefault();
setDragOver(false);
if (e.dataTransfer.files.length > 0) {
handleUpload(e.dataTransfer.files);
}
}}
onClick={() => fileInputRef.current?.click()}
>
{uploading ? (
<div className="flex flex-col items-center gap-3">
<div className="animate-spin text-3xl"></div>
<p className="text-sm text-gray-500">...</p>
<div className="w-48 h-2 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden">
<div
className="h-full bg-pink-500 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
<p className="text-xs text-gray-400">{uploadProgress}%</p>
</div>
) : (
<div className="flex flex-col items-center gap-2 pointer-events-none">
<span className="text-4xl">📤</span>
<p className="text-sm text-gray-500 dark:text-gray-400">
{dragOver ? '释放以上传文件' : '拖放文件到此处或点击选择'}
</p>
<p className="text-xs text-gray-400">
· 20MB
</p>
</div>
)}
</div>
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
accept=".jpg,.jpeg,.png,.gif,.webp,.svg,.pdf,.txt,.md,.doc,.docx,.mp3,.wav,.ogg,.mp4,.webm"
onChange={e => {
if (e.target.files && e.target.files.length > 0) {
handleUpload(e.target.files);
e.target.value = '';
}
}}
/>
{/* 上传错误 */}
{uploadErr && (
<div className="mt-3 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs text-red-600 dark:text-red-400">
{uploadErr}
<button onClick={() => setUploadErr('')} className="ml-2 underline"></button>
</div>
)}
{/* 支持的文件类型 */}
<div className="mt-3 text-[10px] text-gray-400 space-y-1">
<p>🖼 图片: JPG, PNG, GIF, WebP, SVG</p>
<p>📄 文档: PDF, TXT, MD, DOC, DOCX</p>
<p>🎵 音频: MP3, WAV, OGG</p>
<p>🎬 视频: MP4, WebM</p>
</div>
</div>
)}
{/* 右键菜单 */}
{contextMenu && (
<div
className="fixed z-[100] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-xl py-1 min-w-[120px]"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<button
onClick={() => handleDownload(contextMenu.file)}
className="w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
</button>
{getCategory(contextMenu.file.mime_type) === 'image' && (
<button
onClick={() => { setPreviewFile(contextMenu.file); setContextMenu(null); }}
className="w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
🔍
</button>
)}
<button
onClick={() => handleDelete(contextMenu.file)}
className="w-full text-left px-3 py-1.5 text-xs text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"
>
🗑
</button>
</div>
)}
{/* Lightbox 图片预览 */}
{previewFile && (
<div
className="fixed inset-0 z-[200] bg-black/80 flex items-center justify-center p-4"
onClick={() => setPreviewFile(null)}
>
<button
onClick={() => setPreviewFile(null)}
className="absolute top-4 right-4 text-white/80 hover:text-white text-2xl z-10"
>
</button>
<div className="flex flex-col items-center max-w-full max-h-full" onClick={e => e.stopPropagation()}>
<img
src={getFileDownloadUrl(previewFile.id)}
alt={previewFile.filename}
className="max-w-full max-h-[70vh] object-contain rounded-lg shadow-2xl"
/>
<div className="mt-3 text-center">
<p className="text-white text-sm font-medium">{previewFile.filename}</p>
<p className="text-white/60 text-xs mt-1">{formatSize(previewFile.size)}</p>
<div className="flex gap-3 mt-3 justify-center">
<button
onClick={() => handleDownload(previewFile)}
className="px-4 py-1.5 text-xs text-white bg-pink-500 hover:bg-pink-600 rounded-lg transition-colors"
>
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
+300 -2
View File
@@ -1,13 +1,91 @@
import { useRef, useEffect, useState } from 'react';
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
import { MoodIndicator } from '@/components/persona/MoodIndicator';
import { useAuth } from '@/hooks/useAuth';
import { usePWA } from '@/hooks/usePWA';
import { useNotificationStore } from '@/store/notificationStore';
import { useSessionStore } from '@/store/sessionStore';
import { ReminderPanel } from '@/components/layout/ReminderPanel';
import { BriefingPanel } from '@/components/layout/BriefingPanel';
import { AutomationPanel } from '@/components/layout/AutomationPanel';
import { FilePanel } from '@/components/layout/FilePanel';
import { KnowledgePanel } from '@/components/layout/KnowledgePanel';
import type { AppNotification } from '@/types/chat';
interface HeaderProps {
onMenuClick: () => void;
onSearchClick: () => void;
}
export function Header({ onMenuClick }: HeaderProps) {
/** 通知类型对应的图标和颜色 */
const NOTIF_STYLES: Record<string, { icon: string; bg: string; text: string }> = {
info: { icon: '️', bg: 'bg-blue-50 dark:bg-blue-900/30', text: 'text-blue-600 dark:text-blue-400' },
warning: { icon: '⚠️', bg: 'bg-yellow-50 dark:bg-yellow-900/30', text: 'text-yellow-600 dark:text-yellow-400' },
success: { icon: '✅', bg: 'bg-green-50 dark:bg-green-900/30', text: 'text-green-600 dark:text-green-400' },
thinking: { icon: '💭', bg: 'bg-purple-50 dark:bg-purple-900/30', text: 'text-purple-600 dark:text-purple-400' },
reminder: { icon: '🔔', bg: 'bg-pink-50 dark:bg-pink-900/30', text: 'text-pink-600 dark:text-pink-400' },
};
/** 格式化时间 */
function formatTime(ts: string): string {
try {
const d = new Date(ts);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return '刚刚';
if (diffMin < 60) return `${diffMin}分钟前`;
const diffHour = Math.floor(diffMin / 60);
if (diffHour < 24) return `${diffHour}小时前`;
return d.toLocaleDateString('zh-CN');
} catch {
return ts;
}
}
export function Header({ onMenuClick, onSearchClick }: HeaderProps) {
const { logout } = useAuth();
const {
notifications,
unreadCount,
isOpen,
toggleOpen,
setOpen,
markAsRead,
markAllAsRead,
} = useNotificationStore();
const setCurrentSessionId = useSessionStore((s) => s.setCurrentSessionId);
const dropdownRef = useRef<HTMLDivElement>(null);
// PWA Hook
const { isInstallable, isInstalled, hasUpdate, install, update } = usePWA();
// 下拉面板标签页切换:通知 / 提醒 / 简报
const [dropdownTab, setDropdownTab] = useState<'notifications' | 'reminders' | 'briefing' | 'automation' | 'files' | 'knowledge'>('notifications');
// 获取当前用户 ID
const userId = localStorage.getItem('user_id') || '';
// 点击外部关闭下拉
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen, setOpen]);
const handleNotifClick = (n: AppNotification) => {
markAsRead(n.id);
if (n.data?.session_id) {
setCurrentSessionId(n.data.session_id as string);
}
setOpen(false);
};
return (
<header className="flex items-center justify-between px-4 py-2 border-b border-pink-100 dark:border-pink-900 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm">
@@ -31,7 +109,227 @@ export function Header({ onMenuClick }: HeaderProps) {
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{/* PWA 安装按钮 */}
{isInstallable && !isInstalled && (
<button
onClick={install}
className="p-1.5 text-gray-400 hover:text-pink-500 transition-colors rounded-lg"
title="安装应用到桌面"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</button>
)}
{/* PWA 更新按钮 */}
{hasUpdate && (
<button
onClick={update}
className="px-2 py-1 text-xs font-medium text-white bg-pink-500 hover:bg-pink-600 rounded-full transition-colors"
title="有新版本可用,点击更新"
>
</button>
)}
{/* 通知铃铛 */}
<div className="relative" ref={dropdownRef}>
<button
onClick={toggleOpen}
className="relative p-1.5 text-gray-400 hover:text-pink-500 transition-colors rounded-lg"
title="通知"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex items-center justify-center w-4 h-4 text-[10px] font-bold text-white bg-red-500 rounded-full">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{/* 通知下拉列表 */}
{isOpen && (
<div className="absolute right-0 mt-2 w-80 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-50 overflow-hidden">
{/* 标签页切换:通知 / 提醒 */}
<div className="flex border-b border-gray-100 dark:border-gray-700">
<button
onClick={() => setDropdownTab('notifications')}
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
dropdownTab === 'notifications'
? 'text-pink-500 border-b-2 border-pink-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
>
{unreadCount > 0 && (
<span className="ml-1 text-[10px] bg-red-500 text-white px-1 rounded-full">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
<button
onClick={() => setDropdownTab('reminders')}
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
dropdownTab === 'reminders'
? 'text-pink-500 border-b-2 border-pink-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
>
</button>
<button
onClick={() => setDropdownTab('briefing')}
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
dropdownTab === 'briefing'
? 'text-pink-500 border-b-2 border-pink-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
>
📋
</button>
<button
onClick={() => setDropdownTab('automation')}
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
dropdownTab === 'automation'
? 'text-pink-500 border-b-2 border-pink-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
>
</button>
<button
onClick={() => setDropdownTab('files')}
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
dropdownTab === 'files'
? 'text-pink-500 border-b-2 border-pink-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
>
📁
</button>
<button
onClick={() => setDropdownTab('knowledge')}
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
dropdownTab === 'knowledge'
? 'text-pink-500 border-b-2 border-pink-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
>
📚
</button>
</div>
{/* 通知面板 */}
{dropdownTab === 'notifications' && (
<div className="max-h-80 overflow-y-auto">
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-50 dark:border-gray-700">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400">
</h3>
{unreadCount > 0 && (
<button
onClick={() => markAllAsRead()}
className="text-[10px] text-pink-500 hover:text-pink-600 transition-colors"
>
</button>
)}
</div>
{notifications.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-gray-400">
🔔
</div>
) : (
<div className="divide-y divide-gray-100 dark:divide-gray-700">
{notifications.slice(0, 10).map((n) => {
const style = NOTIF_STYLES[n.type] || NOTIF_STYLES.info;
return (
<button
key={n.id}
onClick={() => handleNotifClick(n)}
className={`w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors flex items-start gap-3 ${
!n.read ? 'bg-pink-50/50 dark:bg-pink-900/10' : ''
}`}
>
<span className="text-lg mt-0.5 flex-shrink-0">{style.icon}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-xs font-medium ${style.text}`}>
{n.title}
</span>
{!n.read && (
<span className="w-2 h-2 bg-pink-500 rounded-full flex-shrink-0" />
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
{n.body}
</p>
<span className="text-[10px] text-gray-400 mt-1 block">
{formatTime(n.timestamp)}
</span>
</div>
</button>
);
})}
</div>
)}
</div>
)}
{/* 提醒面板 */}
{dropdownTab === 'reminders' && (
<div className="max-h-80 overflow-y-auto">
<ReminderPanel userId={userId} onClose={() => setOpen(false)} />
</div>
)}
{/* 简报面板 */}
{dropdownTab === 'briefing' && (
<div className="max-h-80 overflow-y-auto">
<BriefingPanel userId={userId} onClose={() => setOpen(false)} />
</div>
)}
{/* 自动化面板 */}
{dropdownTab === 'automation' && (
<div className="max-h-80 overflow-y-auto">
<AutomationPanel onClose={() => setOpen(false)} />
</div>
)}
{/* 文件面板 */}
{dropdownTab === 'files' && (
<div className="max-h-96 overflow-y-auto">
<FilePanel onClose={() => setOpen(false)} />
</div>
)}
{/* 知识库面板 */}
{dropdownTab === 'knowledge' && (
<div className="max-h-96 overflow-y-auto">
<KnowledgePanel onClose={() => setOpen(false)} />
</div>
)}
</div>
)}
</div>
{/* 搜索按钮 */}
<button
onClick={onSearchClick}
className="p-1.5 text-gray-400 hover:text-pink-500 transition-colors rounded-lg"
title="搜索消息 (Ctrl+K)"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
<span className="text-xs text-gray-400 hidden sm:block">🌸 </span>
<button
onClick={logout}
@@ -0,0 +1,523 @@
import { useState, useEffect, useCallback } from 'react';
import type { KnowledgeBase, KnowledgeDocument, SearchChunkResult } from '@/api/knowledge';
import {
createKB,
listKBs,
updateKB,
deleteKB,
addDocument,
listDocuments,
deleteDocument,
searchKnowledge,
} from '@/api/knowledge';
interface KnowledgePanelProps {
onClose: () => void;
}
/** 格式化时间 */
function formatTime(ts: string): string {
try {
const d = new Date(ts);
return d.toLocaleString('zh-CN');
} catch {
return ts;
}
}
export function KnowledgePanel({ onClose }: KnowledgePanelProps) {
// 双标签页:知识库管理 / 搜索
const [tab, setTab] = useState<'bases' | 'search'>('bases');
// ========== 知识库管理状态 ==========
const [bases, setBases] = useState<KnowledgeBase[]>([]);
const [loadingBases, setLoadingBases] = useState(false);
const [error, setError] = useState('');
// 创建/编辑知识库
const [showKBForm, setShowKBForm] = useState(false);
const [editingKB, setEditingKB] = useState<KnowledgeBase | null>(null);
const [kbName, setKbName] = useState('');
const [kbDesc, setKbDesc] = useState('');
const [savingKB, setSavingKB] = useState(false);
// 选中知识库查看文档
const [selectedKB, setSelectedKB] = useState<KnowledgeBase | null>(null);
const [documents, setDocuments] = useState<KnowledgeDocument[]>([]);
const [loadingDocs, setLoadingDocs] = useState(false);
// 添加文档
const [showDocForm, setShowDocForm] = useState(false);
const [docTitle, setDocTitle] = useState('');
const [docContent, setDocContent] = useState('');
const [savingDoc, setSavingDoc] = useState(false);
// ========== 搜索状态 ==========
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchChunkResult[]>([]);
const [searchTotal, setSearchTotal] = useState(0);
const [searching, setSearching] = useState(false);
const [searchErr, setSearchErr] = useState('');
// ========== 加载知识库列表 ==========
const loadBases = useCallback(async () => {
setLoadingBases(true);
setError('');
try {
const list = await listKBs();
setBases(list);
} catch (e: any) {
setError(e.message || '加载知识库失败');
} finally {
setLoadingBases(false);
}
}, []);
useEffect(() => {
loadBases();
}, [loadBases]);
// ========== 创建/更新知识库 ==========
const handleSaveKB = async () => {
if (!kbName.trim()) return;
setSavingKB(true);
setError('');
try {
if (editingKB) {
await updateKB(editingKB.id, kbName.trim(), kbDesc.trim() || undefined);
} else {
await createKB(kbName.trim(), kbDesc.trim() || undefined);
}
setShowKBForm(false);
setEditingKB(null);
setKbName('');
setKbDesc('');
await loadBases();
} catch (e: any) {
setError(e.message || '保存知识库失败');
} finally {
setSavingKB(false);
}
};
const handleEditKB = (kb: KnowledgeBase) => {
setEditingKB(kb);
setKbName(kb.name);
setKbDesc(kb.description || '');
setShowKBForm(true);
};
const handleDeleteKB = async (id: string) => {
if (!confirm('确定要删除此知识库?所有文档将被永久删除。')) return;
try {
await deleteKB(id);
if (selectedKB?.id === id) {
setSelectedKB(null);
setDocuments([]);
}
await loadBases();
} catch (e: any) {
setError(e.message || '删除知识库失败');
}
};
// ========== 查看文档 ==========
const handleSelectKB = async (kb: KnowledgeBase) => {
setSelectedKB(kb);
setLoadingDocs(true);
try {
const docs = await listDocuments(kb.id);
setDocuments(docs);
} catch (e: any) {
setError(e.message || '加载文档失败');
} finally {
setLoadingDocs(false);
}
};
// ========== 添加文档 ==========
const handleAddDoc = async () => {
if (!docTitle.trim() || !docContent.trim() || !selectedKB) return;
setSavingDoc(true);
setError('');
try {
await addDocument(selectedKB.id, docTitle.trim(), docContent.trim());
setShowDocForm(false);
setDocTitle('');
setDocContent('');
// 刷新文档列表
const docs = await listDocuments(selectedKB.id);
setDocuments(docs);
// 刷新知识库列表 (更新文档计数)
await loadBases();
} catch (e: any) {
setError(e.message || '添加文档失败');
} finally {
setSavingDoc(false);
}
};
const handleDeleteDoc = async (id: string) => {
if (!confirm('确定要删除此文档?')) return;
try {
await deleteDocument(id);
if (selectedKB) {
const docs = await listDocuments(selectedKB.id);
setDocuments(docs);
}
await loadBases();
} catch (e: any) {
setError(e.message || '删除文档失败');
}
};
// ========== 搜索 ==========
const handleSearch = async () => {
if (!searchQuery.trim()) return;
setSearching(true);
setSearchErr('');
try {
const res = await searchKnowledge(searchQuery.trim());
setSearchResults(res.results);
setSearchTotal(res.total);
} catch (e: any) {
setSearchErr(e.message || '搜索失败');
} finally {
setSearching(false);
}
};
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleSearch();
};
return (
<div className="flex flex-col h-full">
{/* 标签页切换 */}
<div className="flex border-b border-gray-100 dark:border-gray-700 shrink-0">
<button
onClick={() => setTab('bases')}
className={`flex-1 py-2 text-xs font-medium transition-colors ${
tab === 'bases'
? 'text-pink-500 border-b-2 border-pink-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
>
📚
</button>
<button
onClick={() => setTab('search')}
className={`flex-1 py-2 text-xs font-medium transition-colors ${
tab === 'search'
? 'text-pink-500 border-b-2 border-pink-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
>
🔍
</button>
</div>
{/* 错误提示 */}
{error && (
<div className="mx-3 mt-2 px-3 py-2 text-xs text-red-600 bg-red-50 dark:bg-red-900/20 rounded-lg shrink-0">
{error}
<button className="ml-2 underline" onClick={() => setError('')}></button>
</div>
)}
{/* ========== 知识库管理 ========== */}
{tab === 'bases' && (
<div className="flex-1 overflow-y-auto">
{!selectedKB ? (
/* 知识库列表 */
<div>
<div className="flex items-center justify-between px-3 py-2">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400">
</h3>
<button
onClick={() => {
setEditingKB(null);
setKbName('');
setKbDesc('');
setShowKBForm(true);
}}
className="text-xs text-pink-500 hover:text-pink-600 transition-colors"
>
+
</button>
</div>
{showKBForm && (
<div className="mx-3 mb-2 p-3 bg-gray-50 dark:bg-gray-750 rounded-lg">
<input
type="text"
placeholder="知识库名称"
value={kbName}
onChange={(e) => setKbName(e.target.value)}
className="w-full px-2 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 mb-2 focus:outline-none focus:border-pink-300"
autoFocus
/>
<textarea
placeholder="描述 (可选)"
value={kbDesc}
onChange={(e) => setKbDesc(e.target.value)}
className="w-full px-2 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 mb-2 focus:outline-none focus:border-pink-300 resize-none"
rows={2}
/>
<div className="flex gap-2">
<button
onClick={handleSaveKB}
disabled={savingKB || !kbName.trim()}
className="px-3 py-1 text-xs bg-pink-500 text-white rounded hover:bg-pink-600 disabled:opacity-50 transition-colors"
>
{savingKB ? '保存中...' : editingKB ? '更新' : '创建'}
</button>
<button
onClick={() => {
setShowKBForm(false);
setEditingKB(null);
}}
className="px-3 py-1 text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
</button>
</div>
</div>
)}
{loadingBases ? (
<div className="px-4 py-8 text-center text-sm text-gray-400">...</div>
) : bases.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-gray-400">
📚 +
</div>
) : (
<div className="divide-y divide-gray-100 dark:divide-gray-700">
{bases.map((kb) => (
<div
key={kb.id}
className="px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
>
<div className="flex items-center justify-between">
<button
onClick={() => handleSelectKB(kb)}
className="flex-1 text-left"
>
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">
📚 {kb.name}
</div>
{kb.description && (
<div className="text-[10px] text-gray-400 mt-0.5 truncate">
{kb.description}
</div>
)}
<div className="text-[10px] text-gray-400 mt-0.5">
{kb.document_count || 0} · {formatTime(kb.created_at)}
</div>
</button>
<div className="flex gap-1 shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
handleEditKB(kb);
}}
className="text-[10px] text-gray-400 hover:text-pink-500 px-1"
title="编辑"
>
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteKB(kb.id);
}}
className="text-[10px] text-gray-400 hover:text-red-500 px-1"
title="删除"
>
🗑
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
) : (
/* 文档列表 */
<div>
<div className="flex items-center gap-2 px-3 py-2 border-b border-gray-100 dark:border-gray-700">
<button
onClick={() => {
setSelectedKB(null);
setDocuments([]);
}}
className="text-xs text-gray-400 hover:text-pink-500 transition-colors"
>
</button>
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 flex-1">
📚 {selectedKB.name}
</h3>
<button
onClick={() => setShowDocForm(true)}
className="text-xs text-pink-500 hover:text-pink-600 transition-colors"
>
+
</button>
</div>
{showDocForm && (
<div className="mx-3 my-2 p-3 bg-gray-50 dark:bg-gray-750 rounded-lg">
<input
type="text"
placeholder="文档标题"
value={docTitle}
onChange={(e) => setDocTitle(e.target.value)}
className="w-full px-2 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 mb-2 focus:outline-none focus:border-pink-300"
autoFocus
/>
<textarea
placeholder="文档内容"
value={docContent}
onChange={(e) => setDocContent(e.target.value)}
className="w-full px-2 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 mb-2 focus:outline-none focus:border-pink-300 resize-none"
rows={5}
/>
<div className="flex gap-2">
<button
onClick={handleAddDoc}
disabled={savingDoc || !docTitle.trim() || !docContent.trim()}
className="px-3 py-1 text-xs bg-pink-500 text-white rounded hover:bg-pink-600 disabled:opacity-50 transition-colors"
>
{savingDoc ? '保存中...' : '添加'}
</button>
<button
onClick={() => setShowDocForm(false)}
className="px-3 py-1 text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
</button>
</div>
</div>
)}
{loadingDocs ? (
<div className="px-4 py-8 text-center text-sm text-gray-400">...</div>
) : documents.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-gray-400">
📄 +
</div>
) : (
<div className="divide-y divide-gray-100 dark:divide-gray-700">
{documents.map((doc) => (
<div
key={doc.id}
className="px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-700 dark:text-gray-300 truncate">
📄 {doc.title}
</div>
<div className="text-[10px] text-gray-400 mt-0.5">
{doc.source_type === 'text'
? '📝 文本'
: doc.source_type === 'file'
? '📎 文件'
: '🔗 URL'}{' '}
· {doc.chunk_count || 0} · {formatTime(doc.created_at)}
</div>
</div>
<button
onClick={() => handleDeleteDoc(doc.id)}
className="text-[10px] text-gray-400 hover:text-red-500 px-1 shrink-0"
title="删除"
>
🗑
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
{/* ========== 搜索 ========== */}
{tab === 'search' && (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="px-3 py-2 flex gap-2 shrink-0">
<input
type="text"
placeholder="搜索知识库内容..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearchKeyDown}
className="flex-1 px-2 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:border-pink-300"
/>
<button
onClick={handleSearch}
disabled={searching || !searchQuery.trim()}
className="px-3 py-1.5 text-xs bg-pink-500 text-white rounded hover:bg-pink-600 disabled:opacity-50 transition-colors"
>
{searching ? '搜索中...' : '搜索'}
</button>
</div>
{searchErr && (
<div className="mx-3 mb-2 px-3 py-2 text-xs text-red-600 bg-red-50 dark:bg-red-900/20 rounded-lg">
{searchErr}
</div>
)}
<div className="flex-1 overflow-y-auto">
{searchResults.length === 0 && !searching && searchQuery && (
<div className="px-4 py-8 text-center text-sm text-gray-400">
🔍
</div>
)}
{searchResults.length === 0 && !searching && !searchQuery && (
<div className="px-4 py-8 text-center text-sm text-gray-400">
🔍
</div>
)}
{searching && (
<div className="px-4 py-8 text-center text-sm text-gray-400">...</div>
)}
{searchResults.length > 0 && (
<div>
<div className="px-3 py-1.5 text-[10px] text-gray-400">
{searchTotal}
</div>
<div className="divide-y divide-gray-100 dark:divide-gray-700">
{searchResults.map((r) => (
<div
key={r.chunk_id}
className="px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
>
<div className="text-[10px] text-pink-500 mb-0.5">
📚 {r.kb_name} {'>'} 📄 {r.doc_title}
</div>
{r.headline && (
<div className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{r.headline}
</div>
)}
<div className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed line-clamp-3">
{r.content}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,353 @@
import { useState, useEffect, useCallback } from 'react';
import {
listReminders,
createReminder,
cancelReminder,
deleteReminder,
type Reminder,
} from '@/api/reminders';
interface ReminderPanelProps {
/** 从 Header 传入用户 ID */
userId: string;
/** 关闭面板 */
onClose: () => void;
}
/** 状态标签颜色映射 */
const STATUS_STYLES: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
completed: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
cancelled: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400',
};
/** 状态中文映射 */
const STATUS_LABELS: Record<string, string> = {
pending: '待提醒',
completed: '已完成',
cancelled: '已取消',
};
/** 重复类型中文映射 */
const REPEAT_LABELS: Record<string, string> = {
none: '不重复',
daily: '每天',
weekly: '每周',
monthly: '每月',
};
/** 格式化时间 */
function formatDateTime(ts: string): string {
try {
const d = new Date(ts);
return d.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return ts;
}
}
/** 转换为 datetime-local 输入框的格式 */
function toDatetimeLocal(isoStr: string): string {
try {
const d = new Date(isoStr);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
} catch {
return '';
}
}
export function ReminderPanel({ userId, onClose }: ReminderPanelProps) {
const [activeTab, setActiveTab] = useState<'list' | 'create'>('list');
const [statusFilter, setStatusFilter] = useState<string>('');
const [reminders, setReminders] = useState<Reminder[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// 创建表单
const [formTitle, setFormTitle] = useState('');
const [formDesc, setFormDesc] = useState('');
const [formTime, setFormTime] = useState('');
const [formRepeat, setFormRepeat] = useState('none');
const [submitting, setSubmitting] = useState(false);
const fetchReminders = useCallback(async () => {
setLoading(true);
setError('');
const resp = await listReminders(userId, statusFilter || undefined, 50);
if (resp.error) {
setError(resp.error);
setReminders([]);
} else {
setReminders(resp.data?.reminders ?? []);
}
setLoading(false);
}, [userId, statusFilter]);
useEffect(() => {
fetchReminders();
}, [fetchReminders]);
const handleCreate = async () => {
if (!formTitle.trim()) return;
if (!formTime) return;
setSubmitting(true);
const remindAt = new Date(formTime).toISOString();
const resp = await createReminder({
title: formTitle.trim(),
description: formDesc.trim(),
remind_at: remindAt,
repeat_type: formRepeat,
});
if (resp.error) {
setError(resp.error);
} else {
setFormTitle('');
setFormDesc('');
setFormTime('');
setFormRepeat('none');
setActiveTab('list');
fetchReminders();
}
setSubmitting(false);
};
const handleCancel = async (id: string) => {
await cancelReminder(id);
fetchReminders();
};
const handleDelete = async (id: string) => {
if (!confirm('确定要删除这条提醒吗?')) return;
await deleteReminder(id);
fetchReminders();
};
return (
<div className="w-full max-h-full flex flex-col">
{/* Tab 切换 */}
<div className="flex items-center border-b border-gray-100 dark:border-gray-700">
<button
onClick={() => setActiveTab('list')}
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
activeTab === 'list'
? 'text-pink-500 border-b-2 border-pink-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
>
</button>
<button
onClick={() => setActiveTab('create')}
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
activeTab === 'create'
? 'text-pink-500 border-b-2 border-pink-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
>
+
</button>
</div>
{/* 错误提示 */}
{error && (
<div className="px-3 py-2 text-xs text-red-500 bg-red-50 dark:bg-red-900/20">
{error}
<button
onClick={() => setError('')}
className="ml-2 underline hover:no-underline"
>
</button>
</div>
)}
{/* 创建表单 */}
{activeTab === 'create' && (
<div className="p-3 space-y-3">
<div>
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
*
</label>
<input
type="text"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
placeholder="例如:喝水提醒"
maxLength={200}
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400"
/>
</div>
<div>
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
</label>
<textarea
value={formDesc}
onChange={(e) => setFormDesc(e.target.value)}
placeholder="提醒详情 (可选)"
rows={2}
maxLength={500}
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400 resize-none"
/>
</div>
<div>
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
*
</label>
<input
type="datetime-local"
value={formTime}
onChange={(e) => setFormTime(e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400"
/>
</div>
<div>
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
</label>
<select
value={formRepeat}
onChange={(e) => setFormRepeat(e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400"
>
<option value="none"></option>
<option value="daily"></option>
<option value="weekly"></option>
<option value="monthly"></option>
</select>
</div>
<div className="flex gap-2">
<button
onClick={() => {
setActiveTab('list');
setError('');
}}
className="flex-1 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded-lg text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={handleCreate}
disabled={submitting || !formTitle.trim() || !formTime}
className="flex-1 py-1.5 text-xs bg-pink-500 text-white rounded-lg hover:bg-pink-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{submitting ? '创建中...' : '创建提醒'}
</button>
</div>
</div>
)}
{/* 提醒列表 */}
{activeTab === 'list' && (
<>
{/* 状态筛选 */}
<div className="flex gap-1 px-3 py-2 border-b border-gray-50 dark:border-gray-700">
{['', 'pending', 'completed', 'cancelled'].map((s) => (
<button
key={s}
onClick={() => setStatusFilter(s)}
className={`px-2.5 py-1 text-[11px] rounded-full transition-colors ${
statusFilter === s
? 'bg-pink-100 text-pink-600 dark:bg-pink-900/40 dark:text-pink-400'
: 'text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
{s === '' ? '全部' : STATUS_LABELS[s]}
</button>
))}
</div>
{/* 列表内容 */}
<div className="overflow-y-auto max-h-72">
{loading ? (
<div className="px-4 py-8 text-center text-sm text-gray-400">
...
</div>
) : reminders.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-gray-400">
🔔 +
</div>
) : (
<div className="divide-y divide-gray-50 dark:divide-gray-700">
{reminders.map((r) => (
<div
key={r.id}
className="px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-800 dark:text-gray-200 truncate">
{r.title}
</span>
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0 ${
STATUS_STYLES[r.status] || STATUS_STYLES.pending
}`}
>
{STATUS_LABELS[r.status] || r.status}
</span>
</div>
{r.description && (
<p className="text-xs text-gray-400 dark:text-gray-500 truncate mt-0.5">
{r.description}
</p>
)}
<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] text-gray-400">
{formatDateTime(r.remind_at)}
</span>
{r.repeat_type && r.repeat_type !== 'none' && (
<span className="text-[10px] text-pink-400">
🔄 {REPEAT_LABELS[r.repeat_type] || r.repeat_type}
</span>
)}
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-1 flex-shrink-0">
{r.status === 'pending' && (
<button
onClick={() => handleCancel(r.id)}
title="取消提醒"
className="p-0.5 text-gray-300 hover:text-yellow-500 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
<button
onClick={() => handleDelete(r.id)}
title="删除"
className="p-0.5 text-gray-300 hover:text-red-500 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,243 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { searchMessages, type SearchResult } from '@/api/sessions';
import { useSessionStore } from '@/store/sessionStore';
interface SearchModalProps {
isOpen: boolean;
onClose: () => void;
}
/** 高亮文本中的关键词 */
function highlightText(text: string, keyword: string): React.ReactNode {
if (!keyword.trim()) return text;
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escaped})`, 'gi');
const parts = text.split(regex);
return parts.map((part, i) =>
regex.test(part) ? (
<mark key={i} className="bg-yellow-200 dark:bg-yellow-700 text-inherit rounded px-0.5">
{part}
</mark>
) : (
part
)
);
}
/** 截取围绕关键词的上下文片段 */
function snippetAroundKeyword(text: string, keyword: string, contextLen: number = 40): string {
if (!keyword.trim()) {
return text.length > 100 ? text.slice(0, 100) + '...' : text;
}
const idx = text.toLowerCase().indexOf(keyword.toLowerCase());
if (idx === -1) return text.length > 100 ? text.slice(0, 100) + '...' : text;
const start = Math.max(0, idx - contextLen);
const end = Math.min(text.length, idx + keyword.length + contextLen);
let snippet = text.slice(start, end);
if (start > 0) snippet = '…' + snippet;
if (end < text.length) snippet = snippet + '…';
return snippet;
}
/** 格式化时间戳 */
function formatTime(ts: number): string {
const date = new Date(ts);
if (isNaN(date.getTime())) return '';
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return '刚刚';
if (diffMin < 60) return `${diffMin}分钟前`;
const diffHour = Math.floor(diffMin / 60);
if (diffHour < 24) return `${diffHour}小时前`;
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
export function SearchModal({ isOpen, onClose }: SearchModalProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [searched, setSearched] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const setCurrentSessionId = useSessionStore((s) => s.setCurrentSessionId);
const loadMessagesFromServer = useSessionStore((s) => s.loadMessagesFromServer);
const userId = localStorage.getItem('user_id') || '';
const doSearch = useCallback(
async (q: string) => {
if (!q.trim()) {
setResults([]);
setTotal(0);
setSearched(false);
setLoading(false);
return;
}
setLoading(true);
setSearched(true);
const resp = await searchMessages(q.trim(), userId, 50, 0);
setResults(resp.results);
setTotal(resp.total);
setLoading(false);
},
[userId]
);
// 防抖输入
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setQuery(val);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
doSearch(val);
}, 300);
},
[doSearch]
);
// 打开时聚焦输入框
useEffect(() => {
if (isOpen) {
setQuery('');
setResults([]);
setTotal(0);
setSearched(false);
setLoading(false);
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [isOpen]);
// 清理定时器
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
// 点击结果跳转到对应会话
const handleResultClick = useCallback(
async (result: SearchResult) => {
onClose();
setCurrentSessionId(result.session_id);
await loadMessagesFromServer(result.session_id);
},
[onClose, setCurrentSessionId, loadMessagesFromServer]
);
// ESC 关闭
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]">
{/* 背景遮罩 */}
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" onClick={onClose} />
{/* 搜索面板 */}
<div className="relative w-full max-w-lg mx-4 bg-white dark:bg-gray-850 rounded-2xl shadow-2xl border border-pink-100 dark:border-pink-800 flex flex-col max-h-[60vh]">
{/* 搜索输入框 */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-pink-100 dark:border-pink-800">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 text-gray-400 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
ref={inputRef}
type="text"
value={query}
onChange={handleInputChange}
placeholder="搜索历史消息…"
className="flex-1 bg-transparent border-none outline-none text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400"
/>
{loading && (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-pink-400 border-t-transparent" />
)}
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 结果列表 */}
<div className="flex-1 overflow-y-auto">
{!searched && query.length === 0 && (
<div className="py-12 text-center text-sm text-gray-400">
</div>
)}
{searched && loading && results.length === 0 && (
<div className="py-12 text-center text-sm text-gray-400">
</div>
)}
{searched && !loading && results.length === 0 && (
<div className="py-12 text-center text-sm text-gray-400">
</div>
)}
{results.length > 0 && (
<>
<div className="px-4 py-2 text-xs text-gray-400 border-b border-pink-50 dark:border-pink-900">
{total}
</div>
{results.map((result) => (
<button
key={result.message_id}
onClick={() => handleResultClick(result)}
className="w-full text-left px-4 py-3 hover:bg-pink-50 dark:hover:bg-pink-900/20 transition-colors border-b border-pink-50 dark:border-pink-900/50 last:border-b-0"
>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-pink-500 dark:text-pink-400 truncate max-w-[60%]">
{result.session_title || '新的对话'}
</span>
<span className="text-xs text-gray-400 ml-auto shrink-0">
{formatTime(result.created_at)}
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-300 line-clamp-2">
{highlightText(snippetAroundKeyword(result.content, query), query)}
</p>
</button>
))}
</>
)}
</div>
{/* 底部提示 */}
<div className="px-4 py-2 border-t border-pink-100 dark:border-pink-800 text-xs text-gray-400 text-center">
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs">ESC</kbd>
{' · '}
</div>
</div>
</div>
);
}
+96 -21
View File
@@ -2,6 +2,7 @@ import { useState, useCallback } from 'react';
import { useSession } from '@/hooks/useSession';
import { useSessionStore } from '@/store/sessionStore';
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
import { exportSession, type ExportFormat } from '@/api/sessions';
import type { Session } from '@/types/session';
interface SidebarProps {
@@ -26,6 +27,24 @@ export function Sidebar({ onClose }: SidebarProps) {
sessionId?: string;
} | null>(null);
// 导出下拉状态
const [exportMenuId, setExportMenuId] = useState<string | null>(null);
const [exportingId, setExportingId] = useState<string | null>(null);
/** 执行导出 */
const handleExport = useCallback(async (sessionId: string, format: ExportFormat) => {
setExportMenuId(null);
setExportingId(sessionId);
try {
await exportSession(sessionId, format);
} catch (err) {
console.error('[Sidebar] 导出失败:', err);
alert(err instanceof Error ? err.message : '导出失败');
} finally {
setExportingId(null);
}
}, []);
// 按 updated_at 降序排列
const displaySessions = [...sessions].sort((a, b) => {
const ta = typeof a.updated_at === 'string' ? parseInt(a.updated_at, 10) : (a.updated_at as unknown as number);
@@ -174,28 +193,84 @@ export function Sidebar({ onClose }: SidebarProps) {
</p>
</div>
</div>
{canDelete && (
<button
onClick={(e) => handleDeleteClick(e, session.id)}
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-400 transition-all"
title="删除会话"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
<div className="flex items-center gap-0.5 relative">
{/* 导出按钮 */}
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
setExportMenuId(exportMenuId === session.id ? null : session.id);
}}
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-blue-400 transition-all"
title="导出会话"
disabled={exportingId === session.id}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
)}
{exportingId === session.id ? (
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
)}
</button>
{/* 格式选择下拉菜单 */}
{exportMenuId === session.id && (
<div className="absolute right-0 top-full mt-1 z-40 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 min-w-[120px]">
{(['json', 'markdown', 'txt'] as ExportFormat[]).map((fmt) => (
<button
key={fmt}
onClick={(e) => {
e.stopPropagation();
handleExport(session.id, fmt);
}}
className="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
{fmt === 'json' && '📦 JSON'}
{fmt === 'markdown' && '📝 Markdown'}
{fmt === 'txt' && '📄 TXT'}
</button>
))}
</div>
)}
</div>
{/* 删除按钮 */}
{canDelete && (
<button
onClick={(e) => handleDeleteClick(e, session.id)}
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-400 transition-all"
title="删除会话"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
)}
</div>
</div>
);
})
+4 -2
View File
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { useChatStore } from '@/store/chatStore';
import { useWebSocket } from './useWebSocket';
import type { ChatMode } from '@/types/chat';
import type { ChatMode, MessageAttachment } from '@/types/chat';
export function useChat() {
const {
@@ -15,7 +15,7 @@ export function useChat() {
const { sendMessage, isConnected } = useWebSocket();
const send = useCallback(
(content: string, mode: ChatMode = 'text') => {
(content: string, mode: ChatMode = 'text', attachments?: MessageAttachment[]) => {
const userMsgId = `user_${Date.now()}`;
// 添加用户消息
@@ -23,6 +23,7 @@ export function useChat() {
id: userMsgId,
role: 'user',
content,
attachments,
timestamp: Date.now(),
});
@@ -34,6 +35,7 @@ export function useChat() {
type: 'message',
content,
mode,
attachments,
timestamp: Date.now(),
});
},
+174
View File
@@ -0,0 +1,174 @@
import { useState, useEffect, useCallback } from 'react';
interface UsePWAReturn {
isInstallable: boolean;
isInstalled: boolean;
isOffline: boolean;
hasUpdate: boolean;
install: () => Promise<void>;
update: () => void;
}
interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
}
let deferredPrompt: BeforeInstallPromptEvent | null = null;
let swRegistration: ServiceWorkerRegistration | null = null;
let updateCallback: (() => void) | null = null;
/**
* 注册 Service Worker 并监听其更新
*/
export function registerServiceWorker(): void {
if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').then((reg) => {
swRegistration = reg;
console.log('SW registered:', reg.scope);
// 监听 SW 更新
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
if (!newWorker) return;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// 有新 SW 等待激活
updateCallback?.();
}
});
});
}).catch((err) => {
console.log('SW registration failed:', err);
});
// 定期检查更新 (每 60 分钟)
setInterval(() => {
if (swRegistration) {
swRegistration.update().catch(() => {});
}
}, 60 * 60 * 1000);
});
}
/**
* usePWA Hook - 管理 PWA 安装和更新
*/
export function usePWA(): UsePWAReturn {
const [isInstallable, setIsInstallable] = useState(false);
const [isInstalled, setIsInstalled] = useState(false);
const [isOffline, setIsOffline] = useState(!navigator.onLine);
const [hasUpdate, setHasUpdate] = useState(false);
// 检测是否已安装为 PWA
useEffect(() => {
const checkInstalled = () => {
const standalone = window.matchMedia('(display-mode: standalone)').matches;
const safariStandalone = (navigator as Navigator & { standalone?: boolean }).standalone === true;
setIsInstalled(standalone || safariStandalone);
};
checkInstalled();
const mediaQuery = window.matchMedia('(display-mode: standalone)');
mediaQuery.addEventListener('change', checkInstalled);
return () => {
mediaQuery.removeEventListener('change', checkInstalled);
};
}, []);
// 监听 beforeinstallprompt 事件
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
deferredPrompt = e as BeforeInstallPromptEvent;
setIsInstallable(true);
};
window.addEventListener('beforeinstallprompt', handler);
return () => {
window.removeEventListener('beforeinstallprompt', handler);
};
}, []);
// 监听 online/offline 事件
useEffect(() => {
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
// 注册 SW 更新回调
useEffect(() => {
updateCallback = () => setHasUpdate(true);
return () => {
updateCallback = null;
};
}, []);
// 安装 PWA
const install = useCallback(async () => {
if (!deferredPrompt) {
console.log('PWA: beforeinstallprompt 不可用');
return;
}
try {
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`PWA: 用户 ${outcome === 'accepted' ? '接受' : '拒绝'} 安装`);
deferredPrompt = null;
setIsInstallable(false);
if (outcome === 'accepted') {
setIsInstalled(true);
}
} catch (err) {
console.error('PWA install error:', err);
}
}, []);
// 更新 SW
const update = useCallback(() => {
if (!swRegistration || !swRegistration.waiting) {
// 尝试触发更新检查
if (swRegistration) {
swRegistration.update().catch(() => {});
}
return;
}
// 通知等待中的 SW 跳过等待
swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' });
swRegistration.waiting.addEventListener('statechange', (e) => {
const worker = e.target as ServiceWorker;
if (worker.state === 'activated') {
setHasUpdate(false);
window.location.reload();
}
});
}, []);
return {
isInstallable,
isInstalled,
isOffline,
hasUpdate,
install,
update,
};
}
@@ -0,0 +1,185 @@
import { useState, useRef, useCallback, useEffect } from 'react';
/**
* 浏览器 Speech Recognition API 的类型声明补充
* 完整声明见 vite-env.d.ts
*/
interface UseSpeechRecognitionReturn {
isListening: boolean;
isSupported: boolean;
interimText: string;
finalText: string;
error: string | null;
startListening: () => void;
stopListening: () => void;
resetText: () => void;
}
type RecognitionError =
| 'no-speech'
| 'aborted'
| 'audio-capture'
| 'network'
| 'not-allowed'
| 'service-not-allowed'
| 'bad-grammar'
| 'language-not-supported';
const ERROR_MESSAGES: Record<RecognitionError, string> = {
'no-speech': '未检测到语音,请再试一次',
'aborted': '语音输入已中止',
'audio-capture': '无法访问麦克风设备',
'network': '网络错误,语音识别需要网络连接',
'not-allowed': '麦克风权限被拒绝,请在浏览器设置中允许麦克风访问',
'service-not-allowed': '语音识别服务不可用',
'bad-grammar': '语法配置错误',
'language-not-supported': '当前语言不支持语音识别',
};
function getRecognitionError(error: string): string {
return ERROR_MESSAGES[error as RecognitionError] || `语音识别错误: ${error}`;
}
export function useSpeechRecognition(): UseSpeechRecognitionReturn {
const [isListening, setIsListening] = useState(false);
const [interimText, setInterimText] = useState('');
const [finalText, setFinalText] = useState('');
const [error, setError] = useState<string | null>(null);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const finalAccRef = useRef<string[]>([]);
const SpeechRecognitionAPI =
typeof window !== 'undefined'
? window.SpeechRecognition || window.webkitSpeechRecognition
: undefined;
const isSupported = SpeechRecognitionAPI != null;
const resetText = useCallback(() => {
setInterimText('');
setFinalText('');
finalAccRef.current = [];
}, []);
const stopListening = useCallback(() => {
if (recognitionRef.current) {
recognitionRef.current.stop();
recognitionRef.current = null;
}
setIsListening(false);
}, []);
// 自动静默超时:若 3 秒内无任何结果,自动停止
const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const resetSilenceTimer = useCallback(() => {
if (silenceTimerRef.current) {
clearTimeout(silenceTimerRef.current);
}
silenceTimerRef.current = setTimeout(() => {
stopListening();
}, 3000);
}, [stopListening]);
const startListening = useCallback(() => {
if (!SpeechRecognitionAPI) {
setError('浏览器不支持语音识别');
return;
}
// 如果已有实例则先停止
if (recognitionRef.current) {
recognitionRef.current.abort();
recognitionRef.current = null;
}
setError(null);
setInterimText('');
setFinalText('');
finalAccRef.current = [];
const recognition = new SpeechRecognitionAPI();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'zh-CN';
recognition.onresult = (event: SpeechRecognitionEvent) => {
resetSilenceTimer();
let newInterim = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i];
if (result.isFinal) {
finalAccRef.current.push(result[0].transcript);
} else {
newInterim += result[0].transcript;
}
}
setInterimText(newInterim);
setFinalText(finalAccRef.current.join(''));
};
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
const message = getRecognitionError(event.error);
setError(message);
if (event.error === 'no-speech') {
// 无语音不算致命错误,可以继续
} else {
setIsListening(false);
recognitionRef.current = null;
}
};
recognition.onend = () => {
setIsListening(false);
recognitionRef.current = null;
if (silenceTimerRef.current) {
clearTimeout(silenceTimerRef.current);
silenceTimerRef.current = null;
}
// onend 时把已累积的 final 结果合并
if (finalAccRef.current.length > 0) {
setFinalText(finalAccRef.current.join(''));
setInterimText('');
}
};
recognition.onstart = () => {
setIsListening(true);
resetSilenceTimer();
};
recognitionRef.current = recognition;
recognition.start();
}, [SpeechRecognitionAPI, resetSilenceTimer]);
// cleanup: 组件卸载时停止识别
useEffect(() => {
return () => {
if (silenceTimerRef.current) {
clearTimeout(silenceTimerRef.current);
}
if (recognitionRef.current) {
recognitionRef.current.abort();
recognitionRef.current = null;
}
};
}, []);
return {
isListening,
isSupported,
interimText,
finalText,
error,
startListening,
stopListening,
resetText,
};
}
@@ -0,0 +1,275 @@
import { useState, useEffect, useRef, useCallback } from 'react';
export interface SpeakOptions {
rate?: number; // 语速 0.1-10, 默认 1
pitch?: number; // 音调 0-2, 默认 1
volume?: number; // 音量 0-1, 默认 1
voice?: SpeechSynthesisVoice;
lang?: string; // 默认 zh-CN
}
export interface UseSpeechSynthesisReturn {
isSpeaking: boolean;
isSupported: boolean;
isPaused: boolean;
voices: SpeechSynthesisVoice[];
currentVoice: SpeechSynthesisVoice | null;
speak: (text: string, options?: SpeakOptions) => void;
stop: () => void;
pause: () => void;
resume: () => void;
setVoice: (voice: SpeechSynthesisVoice) => void;
}
/**
* 获取可用的中文语音列表
*/
export function getChineseVoices(): SpeechSynthesisVoice[] {
if (typeof window === 'undefined' || !window.speechSynthesis) {
return [];
}
const voices = window.speechSynthesis.getVoices();
return voices.filter(
(v) =>
v.lang.startsWith('zh-CN') ||
v.lang.startsWith('zh-TW') ||
v.lang.startsWith('zh-HK') ||
v.lang.startsWith('zh-'),
);
}
/**
* 获取最佳中文语音 — 优先选择包含 "Xiaoxiao" 的 (自然度最高)
*/
export function getBestChineseVoice(): SpeechSynthesisVoice | null {
const chineseVoices = getChineseVoices();
if (chineseVoices.length === 0) return null;
// 优先匹配包含 "Xiaoxiao" 的语音
const xiaoxiao = chineseVoices.find((v) => v.name.includes('Xiaoxiao'));
if (xiaoxiao) return xiaoxiao;
// 其次匹配 "Yunxi"、"Xiaoyi"
const yunxi = chineseVoices.find((v) => v.name.includes('Yunxi'));
if (yunxi) return yunxi;
const xiaoyi = chineseVoices.find((v) => v.name.includes('Xiaoyi'));
if (xiaoyi) return xiaoyi;
// fallback 到第一个中文语音
return chineseVoices[0];
}
/**
* 将长文本按句号、换行分割成段落,避免浏览器截断
*/
function splitTextIntoChunks(text: string, maxChunkLength: number = 200): string[] {
const chunks: string[] = [];
// 按句号、感叹号、问号、换行分割
const sentences = text.split(/(?<=[。!?!?\n])/g);
let current = '';
for (const sentence of sentences) {
if (current.length + sentence.length > maxChunkLength && current.length > 0) {
chunks.push(current.trim());
current = '';
}
current += sentence;
}
if (current.trim()) {
chunks.push(current.trim());
}
return chunks.length > 0 ? chunks : [text];
}
/**
* 浏览器 Speech Synthesis TTS Hook
*
* 使用浏览器原生 Speech Synthesis API 进行文字转语音。
* - 中文语音自动优选
* - 长文本自动分段
* - Chrome 暂停 bug 规避(定期 resume
*/
export function useSpeechSynthesis(): UseSpeechSynthesisReturn {
const [isSpeaking, setIsSpeaking] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]);
const [currentVoice, setCurrentVoice] = useState<SpeechSynthesisVoice | null>(null);
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
const chunksRef = useRef<string[]>([]);
const chunkIndexRef = useRef(0);
const optionsRef = useRef<SpeakOptions>({});
const resumeIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isSupported =
typeof window !== 'undefined' && 'speechSynthesis' in window;
// 加载语音列表
useEffect(() => {
if (!isSupported) return;
const loadVoices = () => {
const available = window.speechSynthesis.getVoices();
if (available.length > 0) {
setVoices(available);
// 自动选择最佳中文语音
const best = getBestChineseVoice();
if (best) {
setCurrentVoice(best);
}
}
};
loadVoices();
window.speechSynthesis.onvoiceschanged = loadVoices;
return () => {
window.speechSynthesis.onvoiceschanged = null;
};
}, [isSupported]);
// Chrome bug 规避:定期 resume 避免长时间不调用后暂停
useEffect(() => {
if (isSpeaking && !isPaused) {
resumeIntervalRef.current = setInterval(() => {
if (window.speechSynthesis.speaking && window.speechSynthesis.paused) {
window.speechSynthesis.resume();
}
}, 5000);
}
return () => {
if (resumeIntervalRef.current) {
clearInterval(resumeIntervalRef.current);
resumeIntervalRef.current = null;
}
};
}, [isSpeaking, isPaused]);
/** 朗读下一段 */
const speakNextChunk = useCallback(() => {
const chunks = chunksRef.current;
const idx = chunkIndexRef.current;
if (idx >= chunks.length) {
// 全部读完
setIsSpeaking(false);
setIsPaused(false);
utteranceRef.current = null;
return;
}
const utterance = new SpeechSynthesisUtterance(chunks[idx]);
const opts = optionsRef.current;
utterance.rate = opts.rate ?? 1;
utterance.pitch = opts.pitch ?? 1;
utterance.volume = opts.volume ?? 1;
utterance.lang = opts.lang ?? 'zh-CN';
if (opts.voice) {
utterance.voice = opts.voice;
} else if (currentVoice) {
utterance.voice = currentVoice;
}
utterance.onend = () => {
chunkIndexRef.current++;
speakNextChunk();
};
utterance.onerror = (e) => {
// 'canceled' 是正常取消,不报错
if (e.error !== 'canceled' && e.error !== 'interrupted') {
console.warn('[TTS] SpeechSynthesis error:', e.error);
}
setIsSpeaking(false);
setIsPaused(false);
utteranceRef.current = null;
};
utterance.onpause = () => setIsPaused(true);
utterance.onresume = () => setIsPaused(false);
utteranceRef.current = utterance;
window.speechSynthesis.speak(utterance);
}, [currentVoice]);
/** 开始朗读 */
const speak = useCallback(
(text: string, options?: SpeakOptions) => {
if (!isSupported || !text.trim()) return;
// 先停止当前朗读
window.speechSynthesis.cancel();
// 分段
const chunks = splitTextIntoChunks(text);
chunksRef.current = chunks;
chunkIndexRef.current = 0;
optionsRef.current = options ?? {};
setIsSpeaking(true);
setIsPaused(false);
// 延迟一帧确保 cancel 生效
setTimeout(() => speakNextChunk(), 50);
},
[isSupported, speakNextChunk],
);
/** 停止朗读 */
const stop = useCallback(() => {
window.speechSynthesis.cancel();
setIsSpeaking(false);
setIsPaused(false);
utteranceRef.current = null;
chunksRef.current = [];
chunkIndexRef.current = 0;
}, []);
/** 暂停 */
const pause = useCallback(() => {
if (isSpeaking && !isPaused) {
window.speechSynthesis.pause();
setIsPaused(true);
}
}, [isSpeaking, isPaused]);
/** 恢复 */
const resume = useCallback(() => {
if (isPaused) {
window.speechSynthesis.resume();
setIsPaused(false);
}
}, [isPaused]);
/** 设置语音 */
const setVoice = useCallback((voice: SpeechSynthesisVoice) => {
setCurrentVoice(voice);
}, []);
// 组件卸载时停止
useEffect(() => {
return () => {
window.speechSynthesis.cancel();
if (resumeIntervalRef.current) {
clearInterval(resumeIntervalRef.current);
}
};
}, []);
return {
isSpeaking,
isSupported,
isPaused,
voices,
currentVoice,
speak,
stop,
pause,
resume,
setVoice,
};
}
+32
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { useChatStore } from '@/store/chatStore';
import { useSessionStore } from '@/store/sessionStore';
import { useNotificationStore } from '@/store/notificationStore';
import { getToken } from '@/api/client';
import type { WSClientMessage, WSServerMessage } from '@/types/chat';
@@ -193,6 +194,37 @@ function handleServerMessage(msg: WSServerMessage) {
}
break;
case 'notification':
if (msg.notification) {
// 添加到通知 store
const { addNotification } = useNotificationStore.getState();
addNotification(msg.notification);
// 触发浏览器桌面通知
if (typeof Notification !== 'undefined') {
if (Notification.permission === 'granted') {
const n = new Notification(msg.notification.title, {
body: msg.notification.body,
icon: '/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png',
tag: msg.notification.id,
data: msg.notification.data,
});
n.onclick = () => {
window.focus();
if (msg.notification?.data?.session_id) {
const { setCurrentSessionId } = useSessionStore.getState();
const sid = msg.notification.data.session_id as string;
if (sid) setCurrentSessionId(sid);
}
n.close();
};
} else if (Notification.permission === 'default') {
Notification.requestPermission();
}
}
}
break;
case 'error':
console.error('[WS] 服务端错误:', msg.error);
setTyping(false);
+36
View File
@@ -145,3 +145,39 @@
background: rgba(0, 0, 0, 0.4);
}
}
/* ===== 语音识别动画 ===== */
@keyframes voice-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
50% { box-shadow: 0 0 0 12px rgba(239, 68, 68, 0); }
}
.voice-btn-active {
animation: voice-pulse 1.5s ease-in-out infinite;
}
/* 实时识别文本淡入效果 */
@keyframes interim-fade {
from { opacity: 0.5; }
to { opacity: 0.9; }
}
.interim-text {
animation: interim-fade 0.3s ease-in-out;
}
/* ===== TTS 文字转语音动画 ===== */
@keyframes tts-wave {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.05);
opacity: 0.85;
}
}
.tts-playing {
animation: tts-wave 1s ease-in-out infinite;
}
+11
View File
@@ -3,6 +3,17 @@ import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
// 注册 PWA Service Worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').then((reg) => {
console.log('SW registered:', reg.scope);
}).catch((err) => {
console.log('SW registration failed:', err);
});
});
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
@@ -0,0 +1,69 @@
import { create } from 'zustand';
import type { AppNotification, NotificationData } from '@/types/chat';
const MAX_NOTIFICATIONS = 50;
interface NotificationStore {
notifications: AppNotification[];
unreadCount: number;
isOpen: boolean;
addNotification: (data: NotificationData) => void;
markAsRead: (id: string) => void;
markAllAsRead: () => void;
removeNotification: (id: string) => void;
clearAll: () => void;
toggleOpen: () => void;
setOpen: (open: boolean) => void;
}
export const useNotificationStore = create<NotificationStore>((set) => ({
notifications: [],
unreadCount: 0,
isOpen: false,
addNotification: (data: NotificationData) =>
set((state) => {
const existing = state.notifications.find((n) => n.id === data.id);
if (existing) return state;
const newNotif: AppNotification = {
...data,
read: false,
createdAt: Date.now(),
};
const notifications = [newNotif, ...state.notifications].slice(0, MAX_NOTIFICATIONS);
const unreadCount = notifications.filter((n) => !n.read).length;
return { notifications, unreadCount };
}),
markAsRead: (id: string) =>
set((state) => {
const notifications = state.notifications.map((n) =>
n.id === id ? { ...n, read: true } : n
);
const unreadCount = notifications.filter((n) => !n.read).length;
return { notifications, unreadCount };
}),
markAllAsRead: () =>
set((state) => ({
notifications: state.notifications.map((n) => ({ ...n, read: true })),
unreadCount: 0,
})),
removeNotification: (id: string) =>
set((state) => {
const notifications = state.notifications.filter((n) => n.id !== id);
const unreadCount = notifications.filter((n) => !n.read).length;
return { notifications, unreadCount };
}),
clearAll: () => set({ notifications: [], unreadCount: 0, isOpen: false }),
toggleOpen: () => set((state) => ({ isOpen: !state.isOpen })),
setOpen: (open: boolean) => set({ isOpen: open }),
}));
+35 -1
View File
@@ -17,6 +17,18 @@ export interface VoiceSegment {
durationMs?: number;
}
/** 消息附件 (图片等) */
export interface MessageAttachment {
type: 'image';
url: string; // 图片 URL 或 data URL
thumbnail_url?: string;
filename?: string;
width?: number;
height?: number;
size?: number; // 文件大小 bytes
description?: string; // AI 对图片的描述
}
/** 单条消息 */
export interface Message {
id: string;
@@ -24,6 +36,7 @@ export interface Message {
content: string;
audioUrl?: string;
segments?: VoiceSegment[];
attachments?: MessageAttachment[];
timestamp: number;
isStreaming?: boolean;
}
@@ -61,12 +74,32 @@ export interface WSClientMessage {
mode?: ChatMode;
content?: string;
audio_data?: string; // base64
attachments?: MessageAttachment[];
timestamp: number;
}
/** 通知类型 */
export type NotificationType = 'info' | 'warning' | 'success' | 'thinking' | 'reminder';
/** WebSocket 通知数据 */
export interface NotificationData {
id: string;
type: NotificationType;
title: string;
body: string;
timestamp: string;
data?: Record<string, unknown>;
}
/** 站内通知 (store 中使用,扩展了已读状态) */
export interface AppNotification extends NotificationData {
read: boolean;
createdAt: number; // 客户端收到时间
}
/** WebSocket 服务端消息 */
export interface WSServerMessage {
type: 'response' | 'segment' | 'audio' | 'error' | 'device_update' | 'pong' | 'history_response' | 'stream_chunk' | 'stream_end' | 'background_thinking';
type: 'response' | 'segment' | 'audio' | 'error' | 'device_update' | 'pong' | 'history_response' | 'stream_chunk' | 'stream_end' | 'background_thinking' | 'notification';
message_id?: string;
text?: string;
content?: string;
@@ -80,6 +113,7 @@ export interface WSServerMessage {
messages?: Message[];
devices?: IoTDevice[];
thinking_status?: BackgroundThinkingStatus;
notification?: NotificationData;
timestamp: number;
}
+47
View File
@@ -1 +1,48 @@
/// <reference types="vite/client" />
// ===== 浏览器 Speech Recognition API 类型声明 =====
// Chrome/Edge 使用 window.SpeechRecognition,部分旧版使用 window.webkitSpeechRecognition
interface SpeechRecognitionEvent extends Event {
results: SpeechRecognitionResultList;
resultIndex: number;
}
interface SpeechRecognitionResultList {
length: number;
[index: number]: SpeechRecognitionResult;
}
interface SpeechRecognitionResult {
isFinal: boolean;
length: number;
[index: number]: SpeechRecognitionAlternative;
}
interface SpeechRecognitionAlternative {
transcript: string;
confidence: number;
}
interface SpeechRecognitionErrorEvent extends Event {
error: string;
message: string;
}
interface SpeechRecognition extends EventTarget {
continuous: boolean;
interimResults: boolean;
lang: string;
onresult: ((event: SpeechRecognitionEvent) => void) | null;
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
onend: (() => void) | null;
onstart: (() => void) | null;
start(): void;
stop(): void;
abort(): void;
}
interface Window {
SpeechRecognition?: new () => SpeechRecognition;
webkitSpeechRecognition?: new () => SpeechRecognition;
}