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
+16
View File
@@ -67,6 +67,22 @@ export const SERVICES = {
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
goBin: '/usr/local/go/bin/go',
},
'voice-service': {
name: '语音识别服务',
cwd: path.join(ROOT, 'backend/voice-service'),
command: './main',
env: {
PORT: '8093',
WHISPER_BINARY: './whisper.cpp/main',
WHISPER_MODEL: './whisper.cpp/models/ggml-small.bin',
WHISPER_LANGUAGE: 'zh',
},
healthUrl: 'http://localhost:8093/api/v1/health',
port: 8093,
buildCommand: 'go',
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
goBin: '/usr/local/go/bin/go',
},
frontend: {
name: 'Frontend',
cwd: path.join(ROOT, 'frontend/web'),
+167
View File
@@ -20,6 +20,7 @@ import { performanceMonitor } from './performance.js';
import { SERVICES, DEVTOOLS_PORT, LOGS_DIR, logFile, GATEWAY_URL, TOOL_ENGINE_URL, ADMIN_USERNAME, ADMIN_PASSWORD } from './config.js';
const MEMORY_SERVICE_URL = process.env.MEMORY_SERVICE_URL || 'http://localhost:8091';
const VOICE_SERVICE_URL = process.env.VOICE_SERVICE_URL || 'http://localhost:8093';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const TUNNEL_SCRIPT = path.join(ROOT, 'scripts/tunnel.sh');
@@ -621,6 +622,55 @@ app.get('/api/tool-calls/stats', async (_req, res) => {
// ---- 自主思考日志代理 (转发到 memory-service) ----
// ---- 语音识别服务代理 (转发到 voice-service) ----
/**
* 代理请求到 Voice-Service
* @param {string} path - Voice-Service API 路径
* @param {object} opts - fetch 选项
*/
async function proxyToVoiceService(path, opts = {}) {
const url = `${VOICE_SERVICE_URL}${path}`;
const logPrefix = `[VoiceService代理]`;
try {
console.log(`${logPrefix} ${opts.method || 'GET'} ${path}`);
const resp = await fetch(url, {
...opts,
signal: AbortSignal.timeout(60000), // 语音转录可能需要较长时间
});
const body = await resp.json().catch(() => null);
if (!resp.ok) {
console.log(`${logPrefix} 请求失败 (HTTP ${resp.status}): ${path}`);
}
return { status: resp.status, body };
} catch (err) {
const isConnRefused = err.message?.includes('ECONNREFUSED') || err.cause?.code === 'ECONNREFUSED';
console.error(`${logPrefix} 请求异常: ${path} - ${err.message}`);
return {
status: 502,
body: {
error: `Voice-Service 不可达: ${err.message}`,
errorType: isConnRefused ? 'voice_service_not_running' : 'voice_service_unreachable',
hint: isConnRefused
? 'Voice-Service 服务未启动,请先在「服务管理」面板中启动 Voice-Service'
: 'Voice-Service 服务无响应,请检查网络连接和服务状态',
},
};
}
}
// GET /api/voice/status — 获取 STT 服务状态
app.get('/api/voice/status', async (_req, res) => {
const result = await proxyToVoiceService('/api/v1/status');
res.status(result.status).json(result.body);
});
// GET /api/voice/health — STT 健康检查
app.get('/api/voice/health', async (_req, res) => {
const result = await proxyToVoiceService('/api/v1/health');
res.status(result.status).json(result.body);
});
/**
* 代理请求到 Memory-Service
* @param {string} path - Memory-Service API 路径
@@ -691,6 +741,123 @@ app.get('/api/v1/thinking/:id', async (req, res) => {
res.status(result.status).json(result.body);
});
// ---- 记忆时间线 (合并记忆 + 思考) ----
app.get('/api/memory-timeline', async (req, res) => {
const { user_id, limit } = req.query;
if (!user_id) {
return res.status(400).json({ error: '缺少 user_id 参数' });
}
const maxItems = parseInt(limit) || 100;
try {
// 并行调用记忆和思考 API
const memQs = new URLSearchParams({ user_id, limit: String(maxItems) }).toString();
const thinkQs = new URLSearchParams({ user_id, limit: String(maxItems), offset: '0' }).toString();
const [memResult, thinkResult] = await Promise.all([
proxyToMemoryService(`/api/v1/memories?${memQs}`),
proxyToMemoryService(`/api/v1/thinking?${thinkQs}`),
]);
const memories = [];
const thinkingLogs = [];
// 解析记忆列表
if (memResult.body && !memResult.body.error) {
const memData = Array.isArray(memResult.body) ? memResult.body : (memResult.body.memories || memResult.body.results || []);
for (const m of memData) {
const createdAt = m.created_at || m.CreatedAt || m.timestamp;
memories.push({
id: m.id || m.ID || '',
type: 'memory',
title: m.title || (m.content || '').substring(0, 60),
content: m.content || '',
summary: m.summary || '',
importance: m.importance || 1,
category: m.category || 'other',
source: m.source || 'unknown',
session_id: m.session_id || '',
keywords: m.keywords || [],
access_count: m.access_count || 0,
timestamp: createdAt ? new Date(createdAt).toISOString() : null,
user_id: m.user_id || user_id,
});
}
}
// 解析思考日志列表
if (thinkResult.body && !thinkResult.body.error) {
const thinkData = thinkResult.body.logs || (Array.isArray(thinkResult.body) ? thinkResult.body : []);
for (const t of thinkData) {
const createdAt = t.created_at || t.CreatedAt || t.timestamp;
// 解析思考主题:提取第一行或前80个字符
const content = t.content || '';
const firstLine = content.split('\n')[0] || '';
const topic = firstLine.length > 80 ? firstLine.substring(0, 77) + '...' : firstLine;
// 尝试从内容推断触发方式
let trigger = '定时';
if (content.includes('用户') || content.includes('手动') || content.includes('manual')) {
trigger = '手动';
} else if (content.includes('scheduled') || content.includes('定时') || content.includes('interval')) {
trigger = '定时';
}
thinkingLogs.push({
id: t.id || t.ID || '',
type: 'thinking',
title: topic || '自主思考',
content: content,
summary: content.length > 200 ? content.substring(0, 197) + '...' : content,
tool_call_count: t.tool_call_count || 0,
content_length: t.content_length || content.length,
trigger: trigger,
tool_calls: t.tool_calls || null,
timestamp: createdAt ? new Date(createdAt).toISOString() : null,
user_id: t.user_id || user_id,
// 思考没有重要性,设为0用于排序
importance: 0,
source: 'thinking',
});
}
}
// 合并并按时间排序(降序:最新的在前)
const timeline = [...memories, ...thinkingLogs].sort((a, b) => {
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
return tb - ta;
});
// 截取限制条数
const result = timeline.slice(0, maxItems);
// 统计摘要
const stats = {
total_memories: memories.length,
total_thinking: thinkingLogs.length,
latest_memory_time: memories.length > 0 ? memories.reduce((max, m) => {
const t = m.timestamp ? new Date(m.timestamp).getTime() : 0;
return t > max ? t : max;
}, 0) : null,
latest_thinking_time: thinkingLogs.length > 0 ? thinkingLogs.reduce((max, t) => {
const ts = t.timestamp ? new Date(t.timestamp).getTime() : 0;
return ts > max ? ts : max;
}, 0) : null,
};
res.json({
timeline: result,
stats,
total: timeline.length,
user_id,
});
} catch (err) {
console.error('[记忆时间线] 错误:', err.message);
res.status(500).json({ error: `获取时间线失败: ${err.message}` });
}
});
// ---- 健康检查代理 ----
app.get('/api/proxy/:id/health', async (req, res) => {
const svc = SERVICES[req.params.id];