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:
@@ -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'),
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user