1 line
165 KiB
JSON
1 line
165 KiB
JSON
{"repo": "bubingbubing/meow-style-studio", "files": {"server/index.js": {"ecd51f2f3b74a57299afa94e7abbdd77ee8cc4c8": {"t": 1785595165.5978737, "n": 157864, "h": "63ccbf24ed9522fd", "c": "const path = require(\"path\");\nrequire(\"dotenv\").config({ path: path.join(__dirname, \"..\", \".env\") });\nrequire(\"dotenv\").config({ path: path.join(__dirname, \".env\"), override: true });\nconst express = require(\"express\");\nconst http = require(\"http\");\nconst { Server: SocketServer } = require(\"socket.io\");\nconst cors = require(\"cors\");\nconst multer = require(\"multer\");\nconst fs = require(\"fs\");\nconst os = require(\"os\");\nconst net = require(\"net\");\nconst { spawn } = require(\"child_process\");\nconst fetch = require(\"node-fetch\");\nconst bodyParser = require(\"body-parser\");\nconst { db, generateId } = require(\"./db\");\nconst { router: authRouter, authMiddleware, JWT_SECRET } = require(\"./auth\");\n\nconst app = express();\nconst PORT = 3000;\nconst DEFAULT_ASR_PORT = Number(process.env.ASR_PORT || 3001);\nconst DEFAULT_ASR_SERVICE_URL = `http://localhost:${DEFAULT_ASR_PORT}`;\nlet managedAsrProcess = null;\n\nfunction isLocalAsrUrl(url) {\n try {\n const parsed = new URL(url);\n return [\"localhost\", \"127.0.0.1\", \"::1\"].includes(parsed.hostname);\n } catch (err) {\n return false;\n }\n}\n\nfunction isPortOpen(port, host = \"127.0.0.1\") {\n return new Promise((resolve) => {\n const socket = net.createConnection({ port, host });\n socket.setTimeout(600);\n socket.once(\"connect\", () => {\n socket.destroy();\n resolve(true);\n });\n socket.once(\"timeout\", () => {\n socket.destroy();\n resolve(false);\n });\n socket.once(\"error\", () => resolve(false));\n });\n}\n\nasync function ensureLocalAsrService() {\n const asrServiceUrl = process.env.ASR_SERVICE_URL || DEFAULT_ASR_SERVICE_URL;\n if (!isLocalAsrUrl(asrServiceUrl)) {\n console.log(`[ASR] Using external ASR service: ${asrServiceUrl}`);\n return;\n }\n\n if (await isPortOpen(DEFAULT_ASR_PORT)) {\n console.log(`[ASR] Service already running on ${asrServiceUrl}`);\n return;\n }\n\n const projectRoot = path.join(__dirname, \"..\");\n const bundledPython = path.join(projectRoot, \"asr_env\", \"Scripts\", \"python.exe\");\n const pythonBin = fs.existsSync(bundledPython) ? bundledPython : \"python\";\n const asrScript = path.join(__dirname, \"asr_service.py\");\n\n console.log(`[ASR] Starting local ASR service on ${asrServiceUrl}...`);\n managedAsrProcess = spawn(pythonBin, [asrScript], {\n cwd: projectRoot,\n env: {\n ...process.env,\n ASR_PORT: String(DEFAULT_ASR_PORT),\n },\n stdio: \"inherit\",\n });\n\n managedAsrProcess.on(\"exit\", (code, signal) => {\n managedAsrProcess = null;\n if (code !== 0 && signal !== \"SIGTERM\") {\n console.warn(`[ASR] Local ASR service exited with code ${code ?? \"null\"} signal ${signal ?? \"null\"}`);\n }\n });\n}\n\nfunction stopManagedAsrService() {\n if (managedAsrProcess && !managedAsrProcess.killed) {\n managedAsrProcess.kill();\n }\n}\n\nprocess.once(\"SIGINT\", () => {\n stopManagedAsrService();\n process.exit(0);\n});\nprocess.once(\"SIGTERM\", () => {\n stopManagedAsrService();\n process.exit(0);\n});\nprocess.once(\"exit\", stopManagedAsrService);\n\nconst UPLOADS_DIR = path.join(__dirname, \"uploads\");\nconst DATA_DIR = path.join(__dirname, \"..\", \"data\");\nconst AGENT_TRACE_LOG = path.join(DATA_DIR, \"agent-traces.jsonl\");\n\nif (!fs.existsSync(UPLOADS_DIR)) {\n fs.mkdirSync(UPLOADS_DIR, { recursive: true });\n}\n\nif (!fs.existsSync(DATA_DIR)) {\n fs.mkdirSync(DATA_DIR, { recursive: true });\n}\n\nconst allowedOrigins = (process.env.CORS_ORIGIN || \"\")\n .split(\",\")\n .map((origin) => origin.trim())\n .filter(Boolean);\n\napp.use(cors({\n origin(origin, callback) {\n if (!origin || allowedOrigins.length === 0 || allowedOrigins.includes(origin)) {\n return callback(null, true);\n }\n return callback(new Error(\"Not allowed by CORS\"));\n },\n}));\napp.use(bodyParser.json({ limit: \"50mb\" }));\napp.use(bodyParser.urlencoded({ extended: true, limit: \"50mb\" }));\napp.use(bodyParser.raw({ type: \"application/octet-stream\", limit: \"50mb\" }));\napp.use(\"/uploads\", express.static(UPLOADS_DIR));\n\nconst API_SECRET = process.env.API_SECRET || process.env.EXPO_PUBLIC_SERVER_API_KEY || \"\";\nconst ADMIN_REVIEW_KEY = process.env.ADMIN_REVIEW_KEY || process.env.ADMIN_KEY || \"\";\nif (!API_SECRET && process.env.NODE_ENV === \"production\") {\n throw new Error(\"API_SECRET is required in production\");\n}\nif (!ADMIN_REVIEW_KEY) {\n console.warn(\"[Admin] WARNING: ADMIN_REVIEW_KEY is not set. Review console API is disabled.\");\n}\nif (API_SECRET) {\n app.use(\"/api\", (req, res, next) => {\n if (req.path === \"/city-search\" || req.path === \"/weather\" || req.path === \"/circle/upload\" || req.path === \"/agent-trace\" || req.path === \"/agent-trace/latest\" || req.path === \"/agent-trace/stats\") {\n return next();\n }\n const key = req.headers[\"x-api-key\"] || req.query.api_key || \"\";\n if (key !== API_SECRET) {\n return res.status(401).json({ error: \"Unauthorized\" });\n }\n next();\n });\n console.log(\"API authentication enabled\");\n} else {\n console.log(\"WARNING: API authentication disabled (no API_SECRET set)\");\n}\n\nconst storage = multer.diskStorage({\n destination: (req, file, cb) => cb(null, UPLOADS_DIR),\n filename: (req, file, cb) => {\n const ext = path.extname(file.originalname || \"\").toLowerCase() || \".jpg\";\n cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);\n },\n});\nconst upload = multer({\n storage,\n limits: {\n fileSize: Number(process.env.UPLOAD_MAX_BYTES || 25 * 1024 * 1024),\n files: 10,\n },\n fileFilter: (req, file, cb) => {\n if (!file.mimetype || !file.mimetype.startsWith(\"image/\")) {\n return cb(new Error(\"Only image uploads are allowed\"));\n }\n cb(null, true);\n },\n});\n\nfunction createNotification({ userId, type, actorId, postId, commentId, content }) {\n try {\n const nid = generateId();\n db.prepare(\n \"INSERT INTO notifications (id, user_id, type, actor_id, post_id, comment_id, content) VALUES (?, ?, ?, ?, ?, ?, ?)\"\n ).run(nid, userId, type, actorId, postId || null, commentId || null, content || \"\");\n } catch (err) {\n console.error(\"[Notification] create error:\", err.message);\n }\n}\n\nfunction adminReviewMiddleware(req, res, next) {\n if (!ADMIN_REVIEW_KEY) {\n return res.status(503).json({ error: \"Admin review console is not configured\" });\n }\n const key = req.headers[\"x-admin-key\"] || req.query.admin_key || \"\";\n if (key !== ADMIN_REVIEW_KEY) {\n return res.status(401).json({ error: \"Unauthorized admin key\" });\n }\n next();\n}\n\nfunction normalizeReviewStatus(value) {\n const allowed = new Set([\"pending_review\", \"approved\", \"rejected\", \"hidden\"]);\n return allowed.has(value) ? value : \"pending_review\";\n}\n\napp.use(\"/api/auth\", authRouter);\n\napp.get(\"/api/health\", (req, res) => {\n res.json({ status: \"ok\", timestamp: new Date().toISOString() });\n});\n\napp.get(\"/admin\", (req, res) => {\n res.sendFile(path.join(__dirname, \"admin-review.html\"));\n});\n\nfunction compactAgentTracePayload(payload) {\n const task = payload && typeof payload === \"object\" ? payload.task || {} : {};\n const client = payload && typeof payload === \"object\" ? payload.clientContext || {} : {};\n const observations = Array.isArray(task.observations) ? task.observations.slice(-12) : [];\n const lifecycleEvents = Array.isArray(task.lifecycleEvents) ? task.lifecycleEvents.slice(-16) : [];\n const recentMessages = Array.isArray(task.recentMessages) ? task.recentMessages.slice(-10) : [];\n const serverReceivedAtMs = Date.now();\n const clientSentAtMs = Number(payload?.clientSentAtMs || payload?.sentAtMs || 0) || null;\n return {\n traceVersion: 2,\n traceId: payload?.traceId || `${serverReceivedAtMs}-${Math.random().toString(36).slice(2, 8)}`,\n receivedAt: new Date(serverReceivedAtMs).toISOString(),\n serverReceivedAtMs,\n clientSentAt: clientSentAtMs ? new Date(clientSentAtMs).toISOString() : payload?.clientSentAt || payload?.sentAt || null,\n clientSentAtMs,\n clientTimeZone: payload?.clientTimeZone || \"\",\n source: payload?.source || \"frontend\",\n userId: payload?.userId || \"\",\n reason: payload?.reason || \"\",\n task: {\n id: task.id,\n taskType: task.taskType,\n title: task.title,\n objective: task.objective,\n status: task.status,\n stage: task.stage,\n updatedAt: task.updatedAt,\n step: task.step,\n maxSteps: task.maxSteps,\n progress: task.progress,\n lastToolAction: task.lastToolAction,\n weatherRetryCount: task.weatherRetryCount,\n recentMessages,\n observations,\n lifecycleEvents,\n },\n clientContext: {\n activeTab: client.activeTab,\n selectedCityId: client.selectedCityId,\n selectedCityLabel: client.selectedCityLabel,\n deviceLocationLabel: client.deviceLocationLabel,\n deviceLocationFresh: client.deviceLocationFresh,\n deviceLocationUpdatedAt: client.deviceLocationUpdatedAt,\n deviceLocationSource: client.deviceLocationSource,\n weatherLocationName: client.weatherLocationName,\n weatherLoading: client.weatherLoading,\n weatherDataReady: client.weatherDataReady,\n weatherError: client.weatherError,\n lastWeatherFetch: client.lastWeatherFetch,\n currentWeather: client.currentWeather,\n weatherDays: Array.isArray(client.weatherDays) ? client.weatherDays.slice(0, 4) : client.weatherDays,\n lastAgentActionResult: client.lastAgentActionResult,\n userCities: Array.isArray(client.userCities) ? client.userCities.slice(0, 8) : client.userCities,\n },\n };\n}\n\napp.post(\"/api/agent-trace\", (req, res) => {\n try {\n const entry = compactAgentTracePayload(req.body || {});\n fs.appendFileSync(AGENT_TRACE_LOG, `${JSON.stringify(entry)}\\n`, \"utf8\");\n res.json({ ok: true, receivedAt: entry.receivedAt });\n } catch (err) {\n console.warn(\"[AgentTrace] write failed:\", err.message);\n res.status(500).json({ error: \"Failed to write agent trace\" });\n }\n});\n\napp.get(\"/api/agent-trace/latest\", (req, res) => {\n try {\n if (!fs.existsSync(AGENT_TRACE_LOG)) {\n return res.json({ traces: [], stats: { path: AGENT_TRACE_LOG, lines: 0 } });\n }\n const limit = Math.max(1, Math.min(Number(req.query.limit || 20), 100));\n const lines = fs.readFileSync(AGENT_TRACE_LOG, \"utf8\").trim().split(/\\r?\\n/).filter(Boolean);\n const traces = lines.slice(-limit).map((line) => {\n try {\n return JSON.parse(line);\n } catch {\n return { parseError: true, raw: line };\n }\n });\n const stat = fs.statSync(AGENT_TRACE_LOG);\n res.json({\n traces,\n stats: {\n path: AGENT_TRACE_LOG,\n lines: lines.length,\n size: stat.size,\n modifiedAt: stat.mtime.toISOString(),\n modifiedAtMs: stat.mtimeMs,\n serverNow: new Date().toISOString(),\n serverNowMs: Date.now(),\n },\n });\n } catch (err) {\n console.warn(\"[AgentTrace] read failed:\", err.message);\n res.status(500).json({ error: \"Failed to read agent trace\" });\n }\n});\n\napp.get(\"/api/agent-trace/stats\", (req, res) => {\n try {\n if (!fs.existsSync(AGENT_TRACE_LOG)) {\n return res.json({ path: AGENT_TRACE_LOG, exists: false, lines: 0, serverNow: new Date().toISOString(), serverNowMs: Date.now() });\n }\n const content = fs.readFileSync(AGENT_TRACE_LOG, \"utf8\");\n const lines = content.trim().split(/\\r?\\n/).filter(Boolean);\n const lastLine = lines[lines.length - 1] || \"\";\n let lastTrace = null;\n try {\n lastTrace = lastLine ? JSON.parse(lastLine) : null;\n } catch {\n lastTrace = { parseError: true, raw: lastLine.slice(0, 500) };\n }\n const stat = fs.statSync(AGENT_TRACE_LOG);\n res.json({\n path: AGENT_TRACE_LOG,\n exists: true,\n lines: lines.length,\n size: stat.size,\n modifiedAt: stat.mtime.toISOString(),\n modifiedAtMs: stat.mtimeMs,\n serverNow: new Date().toISOString(),\n serverNowMs: Date.now(),\n lastTrace,\n });\n } catch (err) {\n console.warn(\"[AgentTrace] stats failed:\", err.message);\n res.status(500).json({ error: \"Failed to read agent trace stats\" });\n }\n});\n\nfunction normalizeMeowInput(text) {\n return String(text || \"\").replace(/[。,!??.,\\s]/g, \"\").toLowerCase();\n}\n\nfunction isMeowWeatherLike(text) {\n return /(?:天气|气温|温度|多少度|几度|冷不冷|热不热|下雨|下雪|weather|temperature)/i.test(String(text || \"\"));\n}\n\nfunction isMeowCurrentLocationReference(text) {\n return /(?:我这里|我这边|这里|这边|外面|外边|室外|周围|当前位置|我附近|附近|本地|我所在|所在地|所在城市|当前所在|当前城市|where i am|current location|nearby|outside|here)/i.test(String(text || \"\"));\n}\n\nfunction isMeowCurrentLocationLookup(text) {\n return /(?:我现在在哪里|我在哪|我在哪里|我现在的位置|我当前的位置|我所在的位置|帮我定位|定位一下我|where am i|my current location)/i.test(String(text || \"\"));\n}\n\nfunction isPseudoMeowWeatherCity(value) {\n const city = String(value || \"\")\n .replace(/[。,!??.,\\s]/g, \"\")\n .replace(/(?:市|县|区|地区)$/g, \"\")\n .trim();\n if (!city) return true;\n return /^(?:外面|外边|室外|周围|这里|这边|本地|附近|当前位置|当前城市|当前所在|当前所在城市|所在城市|所在地|我这里|我这边|我附近|我所在)$/i.test(city);\n}\n\nfunction cleanMeowWeatherCity(value) {\n const city = String(value || \"\")\n .replace(/[。,!??.,\\s]/g, \"\")\n .replace(/(?:市|县|区|地区)$/g, \"\")\n .trim();\n if (!city || isPseudoMeowWeatherCity(city)) return \"\";\n return city;\n}\n\nfunction meowWeatherLocationMode(message, city) {\n if (isMeowCurrentLocationReference(message)) return \"current\";\n if (city && !isPseudoMeowWeatherCity(city)) return \"city\";\n return \"ambiguous\";\n}\n\nfunction extractMeowWeatherCity(text) {\n if (!isMeowWeatherLike(text)) return \"\";\n if (isMeowCurrentLocationReference(text)) return \"\";\n const cleaned = String(text || \"\")\n .replace(/(?:我想知道|想知道|帮我看看|帮我看|帮我查|查一下|看一下|看看|告诉我|请问|今天|明天|后天|现在|当前|未来|最近|的|呢|吧|怎么样|如何|怎样|吗|啊)/g, \"\")\n .trim();\n const beforeWeather = cleaned.match(/([\\u4e00-\\u9fa5A-Za-z]{2,20})(?:市|县|区|地区)?(?:天气|气温|温度)/);\n if (beforeWeather?.[1]) return cleanMeowWeatherCity(beforeWeather[1]);\n const afterWeather = cleaned.match(/(?:天气|气温|温度).*?(?:在|定位到|for)?([\\u4e00-\\u9fa5A-Za-z]{2,20})/);\n return cleanMeowWeatherCity(afterWeather?.[1]);\n}\n\nfunction fallbackMeowPlan(message) {\n const value = normalizeMeowInput(message);\n if (/^(?:你好|您好|嗨|哈喽|hello|hi|hey|在吗|喵喵)$/.test(value)) {\n return {\n kind: \"chat\",\n reply: \"我在。你可以和我聊天,也可以直接说想让我帮你做什么。\",\n confidence: 0.86,\n };\n }\n if (isMeowCurrentLocationLookup(message)) {\n return {\n kind: \"task\",\n taskType: \"location_lookup\",\n title: \"查看当前位置\",\n objective: message,\n confidence: 0.86,\n slots: { needsLiveLocation: true },\n steps: [\"判断是否需要定位\", \"调用定位工具\", \"读取位置结果\", \"回答你的位置\"],\n initialAction: { type: \"DETECT_LOCATION\" },\n };\n }\n if (isMeowWeatherLike(message)) {\n const city = extractMeowWeatherCity(message);\n const locationMode = meowWeatherLocationMode(message, city);\n if (locationMode === \"current\") {\n return {\n kind: \"task\",\n taskType: \"weather_lookup\",\n title: \"查看当前所在城市天气\",\n objective: message,\n confidence: 0.84,\n slots: { locationMode: \"current\" },\n steps: [\"识别位置\", \"打开推荐页\", \"读取当前天气\", \"完成反馈\"],\n initialAction: { type: \"DETECT_LOCATION\" },\n };\n }\n if (locationMode === \"ambiguous\") {\n return {\n kind: \"task\",\n taskType: \"weather_lookup\",\n title: \"查看天气\",\n objective: message,\n confidence: 0.68,\n slots: { locationMode: \"ambiguous\" },\n requiredFields: [\n { id: \"location\", label: \"地区\", kind: \"choice\", required: true, options: [\"使用当前位置\", \"手动输入城市\"] },\n ],\n steps: [\"确认位置\", \"打开推荐页\", \"读取天气\", \"完成反馈\"],\n };\n }\n return {\n kind: city ? \"task\" : \"chat\",\n reply: city ? undefined : \"想查哪个地区的天气?\",\n taskType: city ? \"weather_lookup\" : undefined,\n title: city ? \"查看地区天气\" : undefined,\n objective: message,\n confidence: city ? 0.82 : 0.58,\n slots: city ? { locationMode: \"city\", targetCity: city } : undefined,\n steps: city ? [\"识别地区\", \"打开推荐页\", \"切换或添加地区\", \"读取天气\", \"完成反馈\"] : undefined,\n };\n }\n if (/(?:发帖|发布|帖子|搭圈|动态|朋友圈)/.test(String(message || \"\"))) {\n return {\n kind: \"task\",\n taskType: \"publish_post\",\n title: \"发布搭圈帖子\",\n objective: message,\n confidence: 0.8,\n steps: [\"理解发布需求\", \"打开发帖编辑器\", \"收集必要素材\", \"填写草稿\", \"交给你确认\"],\n requiredFields: [\n { id: \"images\", label: \"图片\", kind: \"images\", required: true, description: \"发布帖子前需要至少一张图片。\" },\n { id: \"title\", label: \"标题\", kind: \"text\", required: true, placeholder: \"输入帖子标题...\" },\n { id: \"caption\", label: \"正文\", kind: \"textarea\", required: true, placeholder: \"输入帖子正文...\" },\n ],\n initialAction: { type: \"publish_post\", params: { initialTags: [\"meow\"] } },\n };\n }\n return {\n kind: \"chat\",\n reply: \"我在听。这更像普通对话,如果你想让我执行任务,可以直接说:帮我做某件事。\",\n confidence: 0.55,\n };\n}\n\nfunction sanitizeMeowPlan(raw, message) {\n const fallback = fallbackMeowPlan(message);\n if (!raw || (raw.kind !== \"chat\" && raw.kind !== \"task\")) return fallback;\n const contextRelations = [\"continue_previous_context\", \"new_topic\", \"new_same_kind_task\", \"repeat_previous_result\", \"modify_current_task\", \"chat\"];\n const contextRelation = contextRelations.includes(raw.contextRelation)\n ? raw.contextRelation\n : fallback.contextRelation || (raw.kind === \"chat\" ? \"chat\" : \"new_topic\");\n const plan = {\n ...fallback,\n ...raw,\n kind: raw.kind,\n contextRelation,\n confidence: Number.isFinite(Number(raw.confidence)) ? Number(raw.confidence) : fallback.confidence,\n };\n if (isMeowCurrentLocationLookup(message) && (plan.kind !== \"task\" || plan.taskType !== \"location_lookup\")) {\n return {\n kind: \"task\",\n contextRelation: \"new_topic\",\n taskType: \"location_lookup\",\n title: \"查看当前位置\",\n objective: String(message || \"\"),\n confidence: Math.max(plan.confidence || 0, 0.86),\n slots: { needsLiveLocation: true },\n steps: [\"判断是否需要定位\", \"调用定位工具\", \"读取位置结果\", \"回答你的位置\"],\n initialAction: { type: \"DETECT_LOCATION\" },\n };\n }\n if (plan.kind === \"chat\") {\n return {\n kind: \"chat\",\n contextRelation: plan.contextRelation,\n reply: typeof plan.reply === \"string\" && plan.reply.trim() ? plan.reply.trim() : fallback.reply,\n confidence: plan.confidence,\n ambiguity: plan.ambiguity && typeof plan.ambiguity === \"object\" ? plan.ambiguity : undefined,\n };\n }\n const taskType = typeof plan.taskType === \"string\" && plan.taskType.trim() ? plan.taskType.trim() : fallback.taskType || \"freeform\";\n const rawSlots = plan.slots && typeof plan.slots === \"object\" ? plan.slots : {};\n const plannedCity = cleanMeowWeatherCity(rawSlots.targetCity || extractMeowWeatherCity(message));\n const weatherMode = taskType === \"weather_lookup\"\n ? ([\"city\", \"current\", \"ambiguous\"].includes(rawSlots.locationMode) ? rawSlots.locationMode : meowWeatherLocationMode(message, plannedCity))\n : undefined;\n const slots = taskType === \"weather_lookup\"\n ? {\n ...rawSlots,\n locationMode: weatherMode,\n targetCity: weatherMode === \"city\" ? plannedCity : undefined,\n }\n : rawSlots;\n return {\n kind: \"task\",\n contextRelation: plan.contextRelation,\n taskType,\n title: typeof plan.title === \"string\" && plan.title.trim() ? plan.title.trim().slice(0, 40) : fallback.title || \"新任务\",\n objective: typeof plan.objective === \"string\" && plan.objective.trim() ? plan.objective.trim() : String(message || \"\"),\n confidence: plan.confidence,\n ambiguity: plan.ambiguity && typeof plan.ambiguity === \"object\" ? plan.ambiguity : undefined,\n slots,\n steps: Array.isArray(plan.steps) ? plan.steps.filter((item) => typeof item === \"string\" && item.trim()).slice(0, 8) : fallback.steps,\n requiredFields: Array.isArray(plan.requiredFields) ? plan.requiredFields.slice(0, 8) : fallback.requiredFields,\n initialAction: plan.initialAction && typeof plan.initialAction.type === \"string\" ? plan.initialAction : fallback.initialAction,\n next: plan.next && typeof plan.next === \"object\" ? plan.next : undefined,\n };\n}\n\nfunction extractJsonObject(text) {\n const raw = String(text || \"\").trim();\n if (!raw) return null;\n try {\n return JSON.parse(raw);\n } catch (_) {\n const match = raw.match(/\\{[\\s\\S]*\\}/);\n if (!match) return null;\n try {\n return JSON.parse(match[0]);\n } catch {\n return null;\n }\n }\n}\n\nasync function callMeowPlannerLLM({ message, context, clientContext, availableTools }) {\n const apiKey = process.env.OPENAI_API_KEY;\n if (!apiKey) return null;\n const baseUrl = (process.env.OPENAI_BASE_URL || \"https://api.openai.com/v1\").replace(/\\/$/, \"\");\n const model = process.env.OPENAI_MODEL || \"gpt-4o-mini\";\n const plannerClientContext = clientContext ? {\n ...clientContext,\n cachedLocation: clientContext.cachedLocation || {\n label: clientContext.deviceLocationLabel,\n coords: clientContext.deviceLocationCoords,\n updatedAt: clientContext.deviceLocationUpdatedAt,\n source: clientContext.deviceLocationSource,\n reliableForCurrentLocationAnswer: false,\n },\n deviceLocationLabel: undefined,\n deviceLocationCoords: undefined,\n deviceLocationFresh: false,\n } : clientContext;\n const body = {\n model,\n temperature: 0.2,\n response_format: { type: \"json_object\" },\n messages: [\n {\n role: \"system\",\n content:\n \"You are Meow App smart planner. Output JSON only, no Markdown. Decide whether the user is chatting or asking for an app task, then return a structured plan. Supported taskType values: publish_post, weather_lookup, location_lookup, change_nickname, change_bio, change_avatar, generate_recommendation, start_tryon, add_wardrobe_item, navigate_chat, navigate_profile, navigate_circle, navigate_recommend, navigate_tryon, freeform. For location_lookup, use DETECT_LOCATION; cached or previous location in clientContext is only a hint and must not be used as a current location answer. For weather_lookup, set slots.locationMode to one of city, current, ambiguous. Use current for here/outside/nearby/current location/my city/where I am. For missing user input, use requiredFields with kind text, textarea, images, or choice. Stable field ids: publish post uses images, title, caption; location uses location. Never expose backend/API/database wording to users.\",\n },\n {\n role: \"user\",\n content: JSON.stringify({\n message,\n recentConversation: Array.isArray(context) ? context.slice(-12) : [],\n clientContext: plannerClientContext,\n plannerInstructions: [\n \"First decide how this new message relates to the recent conversation.\",\n \"If clientContext.agentConversationMode is 'new' or clientContext.conversationFresh is true, treat this as a fresh chat session. Do not continue, repeat, or reuse any previous task fields unless the user explicitly asks to repeat prior content.\",\n \"Return contextRelation as one of: continue_previous_context, new_topic, new_same_kind_task, repeat_previous_result, modify_current_task, chat.\",\n \"If the message starts a different task, use new_topic even if the recent conversation was about weather or another task.\",\n \"If the user says something like 'another one' or 'one more' after a completed task, use new_same_kind_task and do not reuse prior field values unless the user explicitly asks to repeat the previous result.\",\n \"If the user seems to ask for repeating the previous result but the intent is ambiguous, include ambiguity.question and ask before acting.\",\n \"For location questions such as 我现在在哪里/我在哪/帮我定位, create a location_lookup task and call DETECT_LOCATION. Cached previous location is only a hint, not a valid current-location answer.\",\n \"For weather intent, slots.locationMode must be city, current, or ambiguous. Treat 外面/这里/附近/当前位置/当前所在城市 as current, not as a city name.\",\n \"Do not let old weather context override a clear new task such as publishing a post.\",\n \"When the task needs user-provided assets or text, describe the missing information with requiredFields or next.type='ask_user'. Do not call hidden picker/input tools directly.\",\n \"For add_wardrobe_item, use requiredFields schema instead of opening the wardrobe form first. Prefer this order: images, title, category(choice), season(choice), optional notes(textarea). category options must be exactly: 上装, 下装, 单体连衣. season options must be exactly: 春, 夏, 秋, 冬, 春/秋, 四季.\",\n \"For any task requiring photos, the first missing field should usually be images so the frontend card can ask the user to upload photos before collecting text or choices.\",\n ],\n availableTools: Array.isArray(availableTools) && availableTools.length ? availableTools : [\n { name: \"publish_post\", description: \"Open the circle post composer. Do not pass the user's command as caption unless it is explicit post text.\", params: { initialTags: [\"string\"] } },\n { name: \"update_circle_compose\", description: \"Fill post composer fields.\", params: { headline: \"string\", caption: \"string\", locationLabel: \"string\" } },\n { name: \"DETECT_LOCATION\", description: \"Detect the user's current location information. Useful when the task needs to know where the user currently is, such as current-location weather.\", params: {} },\n { name: \"switch_city\", description: \"Switch or add a weather city.\", params: { city: \"string\" } },\n { name: \"generate_recommendation\", description: \"Generate outfit recommendation from a preference.\", params: { preference: \"string\", category: \"full|top|bottom|dress\" } },\n { name: \"start_tryon\", description: \"Open or start try-on flow.\", params: { style: \"string\" } },\n { name: \"OPEN_WARDROBE_ADD\", description: \"Open wardrobe item creation form.\", params: {} },\n { name: \"wardrobe_add_images\", description: \"Ask user to add images to the wardrobe draft via frontend card.\", params: {} },\n { name: \"update_wardrobe_draft\", description: \"Patch wardrobe draft fields. category must be one of: 上装, 下装, 单体连衣. season must be one of: 春, 夏, 秋, 冬, 春/秋, 四季.\", params: { title: \"string\", category: \"上装|下装|单体连衣\", season: \"春|夏|秋|冬|春/秋|四季\", notes: \"string\", imageUris: [\"string\"] } },\n { name: \"wardrobe_item_save\", description: \"Save the prepared wardrobe item after user confirmation.\", params: {} },\n { name: \"change_nickname\", description: \"Change user nickname.\", params: { nickname: \"string\" } },\n { name: \"change_bio\", description: \"Change user profile bio.\", params: { bio: \"string\" } },\n { name: \"NAVIGATE\", description: \"Navigate to an app screen.\", params: { screen: \"recommend|circle|profile|chat|tryon\" } },\n ],\n outputShape: {\n kind: \"chat | task\",\n contextRelation: \"continue_previous_context | new_topic | new_same_kind_task | repeat_previous_result | modify_current_task | chat\",\n reply: \"chat 时给用户看的自然回复\",\n taskType: \"filled when kind is task\",\n title: \"short task title\",\n objective: \"任务目标\",\n confidence: 0.0,\n ambiguity: { reason: \"\", question: \"\", options: [] },\n steps: [\"user-visible execution stage\"],\n requiredFields: [{ id: \"field_id\", label: \"给用户看的字段名\", kind: \"text|textarea|images|choice\", required: true, placeholder: \"\", description: \"\", options: [] }],\n initialAction: { type: \"App action name\", params: {} },\n next: { type: \"reply|action|ask_user|wait|complete\", message: \"\", action: { type: \"\", params: {} }, fields: [] },\n },\n }),\n },\n ],\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(body),\n });\n if (!response.ok) {\n const detail = await response.text().catch(() => \"\");\n throw new Error(`Planner LLM failed: ${response.status} ${detail.slice(0, 160)}`);\n }\n const data = await response.json();\n return extractJsonObject(data?.choices?.[0]?.message?.content);\n}\n\napp.post(\"/api/meow/plan\", async (req, res) => {\n const message = String(req.body?.message || \"\");\n const context = Array.isArray(req.body?.context) ? req.body.context : [];\n const clientContext = req.body?.clientContext || {};\n const availableTools = Array.isArray(req.body?.availableTools) ? req.body.availableTools : [];\n if (!message.trim()) {\n return res.status(400).json({ error: \"message is required\" });\n }\n try {\n const llmPlan = await callMeowPlannerLLM({ message, context, clientContext, availableTools });\n if (llmPlan) {\n return res.json({ source: \"llm\", decision: sanitizeMeowPlan(llmPlan, message) });\n }\n } catch (err) {\n console.warn(\"[MeowPlan] LLM unavailable, using fallback:\", err.message);\n }\n return res.json({ source: \"fallback\", decision: fallbackMeowPlan(message) });\n});\n\nfunction sanitizeMeowStep(raw, fallback, availableTools = []) {\n if (!raw || ![\"reply\", \"action\", \"ask_user\", \"wait\", \"complete\"].includes(raw.type)) return fallback;\n if (raw.type === \"action\") {\n if (!raw.action || typeof raw.action.type !== \"string\") return fallback;\n const allowed = new Set(\n (Array.isArray(availableTools) ? availableTools : [])\n .map((tool) => typeof tool === \"string\" ? tool : tool?.name)\n .filter(Boolean)\n );\n if (allowed.size && !allowed.has(raw.action.type)) return fallback;\n }\n return {\n type: raw.type,\n message: typeof raw.message === \"string\" ? raw.message.slice(0, 800) : fallback.message,\n action: raw.action && typeof raw.action.type === \"string\" ? raw.action : undefined,\n fields: Array.isArray(raw.fields) ? raw.fields.slice(0, 8) : undefined,\n };\n}\n\nfunction fallbackMeowStep(task, observation, clientContext = {}) {\n const observationType = String(observation?.type || \"\");\n const envelope = observation?.envelope && typeof observation.envelope === \"object\" ? observation.envelope : {};\n const lastAction = String(observation?.action?.type || envelope?.action?.type || \"\");\n const goalEvaluation = observation?.goalEvaluation || envelope?.goalEvaluation || {};\n const envelopeStatus = String(envelope?.status || observation?.status || \"\");\n const resultContract = envelope?.data?.resultContract || {};\n const plannedNext = task?.meta?.next;\n const initialAction = plannedNext?.type === \"action\" && plannedNext.action?.type\n ? plannedNext.action\n : task?.meta?.initialAction;\n\n if (observationType === \"missing_information\") {\n const fields = Array.isArray(task?.meta?.requiredFields) ? task.meta.requiredFields : [];\n return {\n type: \"ask_user\",\n message: \"\\u6211\\u9700\\u8981\\u4f60\\u5148\\u8865\\u5145\\u5fc5\\u8981\\u4fe1\\u606f\\u3002\",\n fields,\n };\n }\n\n const missingAfterInput = Array.isArray(observation?.missingAfterInput)\n ? observation.missingAfterInput.map((item) => String(item || \"\")).filter(Boolean)\n : [];\n if (missingAfterInput.length && Array.isArray(task?.meta?.requiredFields)) {\n const fields = task.meta.requiredFields.filter((field) => missingAfterInput.includes(String(field?.id || \"\")));\n return {\n type: \"ask_user\",\n message: \"\\u6211\\u8fd8\\u9700\\u8981\\u4f60\\u7ee7\\u7eed\\u8865\\u5145\\u5fc5\\u8981\\u4fe1\\u606f\\u3002\",\n fields: fields.length ? fields : task.meta.requiredFields,\n };\n }\n\n if (envelopeStatus === \"waiting\") {\n return { type: \"wait\", message: envelope.userVisibleSummary || \"我正在等待 App 状态更新。\" };\n }\n\n if (envelopeStatus === \"failed\" || envelope?.error) {\n return {\n type: \"wait\",\n message: envelope?.error?.message || envelope.userVisibleSummary || \"刚才的 App 工具没有返回可用结果,我需要重新判断下一步。\",\n };\n }\n\n if (resultContract && resultContract.ready === false && Array.isArray(resultContract.missing) && resultContract.missing.length) {\n const fields = Array.isArray(task?.meta?.requiredFields)\n ? task.meta.requiredFields.filter((field) => resultContract.missing.includes(field?.id))\n : resultContract.missing.map((id) => ({ id, label: id, kind: id === \"images\" ? \"images\" : id === \"caption\" ? \"textarea\" : \"text\", required: true }));\n return {\n type: \"ask_user\",\n message: \"我还需要你补充必要信息。\",\n fields,\n };\n }\n\n if (task?.taskType === \"location_lookup\") {\n const locationLabel = clientContext?.deviceLocationFresh ? String(clientContext?.deviceLocationLabel || \"\") : \"\";\n const updatedAt = Number(clientContext?.deviceLocationUpdatedAt || 0);\n const startedAt = Number(task?.meta?.startedAt || task?.createdAt || 0);\n const updatedForTask = !!updatedAt && (!startedAt || updatedAt + 1000 >= startedAt);\n if (locationLabel && updatedForTask && lastAction === \"DETECT_LOCATION\") {\n return { type: \"complete\", message: `你现在的位置是${locationLabel}。` };\n }\n if (observationType === \"task_started\" && initialAction?.type) {\n return { type: \"action\", message: \"我先获取你当前所在位置。\", action: initialAction };\n }\n if (lastAction === \"DETECT_LOCATION\") {\n return { type: \"wait\", message: \"我正在等待定位结果返回。\" };\n }\n return { type: \"action\", message: \"我先获取你当前所在位置。\", action: { type: \"DETECT_LOCATION\" } };\n }\n\n if (task?.taskType === \"publish_post\") {\n if (observationType === \"task_started\" && initialAction?.type) {\n return {\n type: \"action\",\n message: \"\\u6211\\u5148\\u6253\\u5f00\\u53d1\\u5e03\\u7f16\\u8f91\\u5668\\u3002\",\n action: initialAction,\n };\n }\n const fields = Array.isArray(task?.meta?.requiredFields) ? task.meta.requiredFields : [];\n if (fields.length) {\n return {\n type: \"ask_user\",\n message: \"我需要先收集发布所需的信息。\",\n fields,\n };\n }\n return {\n type: \"ask_user\",\n message: \"我需要先收集发布所需的图片、标题和正文。\",\n fields: [\n { id: \"images\", label: \"图片\", kind: \"images\", required: true },\n { id: \"title\", label: \"标题\", kind: \"text\", required: true },\n { id: \"caption\", label: \"正文\", kind: \"textarea\", required: true },\n ],\n };\n }\n if (task?.taskType === \"weather_lookup\" && typeof observation?.weatherReport === \"string\") {\n if (goalEvaluation?.complete === true) {\n return { type: \"complete\", message: observation.weatherReport };\n }\n return { type: \"wait\", message: \"我已经读到天气信息,但还需要确认它是否匹配你的问题。\" };\n }\n if (task?.taskType === \"weather_lookup\") {\n if (goalEvaluation?.complete === true) {\n return { type: \"complete\", message: goalEvaluation.report || \"我已经拿到匹配你问题的天气结果。\" };\n }\n const targetCity = String(task?.meta?.targetCity || observation?.targetCity || \"\").trim();\n const locationMode = task?.meta?.locationMode === \"current\" ? \"current\" : \"city\";\n if (lastAction === \"NAVIGATE\") {\n return locationMode === \"current\"\n ? { type: \"action\", message: \"\\u6211\\u5148\\u5b9a\\u4f4d\\u4f60\\u6240\\u5728\\u7684\\u57ce\\u5e02\\u3002\", action: { type: \"DETECT_LOCATION\" } }\n : { type: \"action\", message: `\\u6211\\u6765\\u5207\\u6362\\u5230\\u201c${targetCity || \"\\u76ee\\u6807\\u5730\\u533a\"}\\u201d\\u7684\\u5929\\u6c14\\u3002`, action: { type: \"switch_city\", params: { city: targetCity } } };\n }\n if (lastAction === \"switch_city\" || lastAction === \"DETECT_LOCATION\") {\n return { type: \"wait\", message: \"\\u6211\\u6b63\\u5728\\u7b49\\u5f85\\u5929\\u6c14\\u4fe1\\u606f\\u5237\\u65b0\\u3002\" };\n }\n return {\n type: \"action\",\n message: \"\\u6211\\u5148\\u6253\\u5f00\\u63a8\\u8350\\u9875\\u68c0\\u67e5\\u5929\\u6c14\\u680f\\u3002\",\n action: { type: \"NAVIGATE\", params: { screen: \"recommend\" } },\n };\n }\n return { type: \"complete\", message: \"我已经根据当前结果处理完成。\" };\n}\n\nasync function callMeowStepLLM({ task, observation, context, clientContext, availableTools }) {\n const apiKey = process.env.OPENAI_API_KEY;\n if (!apiKey) return null;\n const baseUrl = (process.env.OPENAI_BASE_URL || \"https://api.openai.com/v1\").replace(/\\/$/, \"\");\n const model = process.env.OPENAI_MODEL || \"gpt-4o-mini\";\n const body = {\n model,\n temperature: 0.25,\n response_format: { type: \"json_object\" },\n messages: [\n {\n role: \"system\",\n content:\n \"You are Meow App's agent step controller. Output JSON only. Given the current task, recent conversation, clientContext, and the latest frontend tool observation, decide exactly one next step. Think in a ReAct loop: read observation.envelope as the canonical tool/user/app-state receipt, especially envelope.status, envelope.input, envelope.output, envelope.error, envelope.appStatePatch, envelope.goalEvaluation, and envelope.data.resultContract; compare the receipt with the task objective, then choose action, ask_user, wait, reply, or complete. Treat resultContract.kind/ready/missing/warnings as the standard tool result contract for every tool, not just weather. Treat task.meta.observations, task.meta.lifecycleEvents, and observation.step/maxSteps as the agent trace and budget. Before completing, compare the user's original objective with the latest observation and decide whether the result actually satisfies the goal. If observation.goalEvaluation or observation.envelope.goalEvaluation exists, treat goalEvaluation.complete as the frontend's structured completion check; do not complete when it is false. If resultContract.ready is false, do not complete unless you can answer safely without app completion. If envelope.status is 'waiting', wait unless the latest clientContext already satisfies the goal. If envelope.status is 'failed' or envelope.error exists, do not complete; decide whether to retry, ask the user, or wait for recovery. If observation.weatherReport exists and goalEvaluation.complete is true, use it as the factual base for the final answer. If observation.type is task_started and task.meta.initialAction exists, usually call that action first to open the right App surface before asking for fields. Respect task.meta.requiredFields and task.meta.fieldValues: do not complete a schema-driven task while required fields are still missing, and do not ask again for fields already collected. If user-provided information or assets are missing, return {type:'ask_user', fields:[...]} and let the frontend card protocol collect them; do not invent or call hidden picker/input tools. For weather_lookup, inspect weather_tool_state carefully: targetCity, dateHint, selectedMatchesTarget, hasWeatherForRequestedDate, weatherLoading, and goalEvaluation must match the user's request. For example, a request for tomorrow needs tomorrow's forecast, not today's current weather. If weatherLoading is true, wait. If a weather tool was already dispatched and state has not changed, wait or explain the delay instead of repeating the same tool. Usually first NAVIGATE to recommend, then use DETECT_LOCATION for current location or switch_city for a named city, then wait until a goal-satisfying weather_result arrives. Never repeat the same tool if the latest observation already shows it was dispatched unless clientContext changed. If the user's goal is solved, return {type:'complete', message:'natural user-facing summary'}. If more app work is needed, return {type:'action', action:{type:'tool_name', params:{...}}, message:'short progress text'}. If user input is needed, return {type:'ask_user', message:'...', fields:[...]}. If the app must wait for state refresh, return {type:'wait', message:'...'}. If no tool is needed and you just need to answer, return {type:'reply', message:'...'}. Use only available frontend tools and never expose backend/API/database details.\",\n },\n {\n role: \"user\",\n content: JSON.stringify({\n task,\n observation,\n recentConversation: Array.isArray(context) ? context.slice(-12) : [],\n clientContext,\n availableTools: Array.isArray(availableTools) && availableTools.length ? availableTools : [\n \"publish_post\",\n \"update_circle_compose\",\n \"DETECT_LOCATION\",\n \"switch_city\",\n \"generate_recommendation\",\n \"start_tryon\",\n \"OPEN_WARDROBE_ADD\",\n \"wardrobe_add_images\",\n \"update_wardrobe_draft\",\n \"wardrobe_item_save\",\n \"change_nickname\",\n \"change_bio\",\n \"NAVIGATE\",\n ],\n outputShape: {\n type: \"reply|action|ask_user|wait|complete\",\n message: \"natural user-facing text\",\n action: { type: \"tool_name\", params: {} },\n fields: [{ id: \"images|title|caption|location|category|season|notes\", label: \"\", kind: \"text|textarea|images|choice\", required: true }],\n },\n }),\n },\n ],\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(body),\n });\n if (!response.ok) {\n const detail = await response.text().catch(() => \"\");\n throw new Error(`Step LLM failed: ${response.status} ${detail.slice(0, 160)}`);\n }\n const data = await response.json();\n return extractJsonObject(data?.choices?.[0]?.message?.content);\n}\n\napp.post(\"/api/meow/step\", async (req, res) => {\n const task = req.body?.task || {};\n const observation = req.body?.observation || {};\n const context = Array.isArray(req.body?.context) ? req.body.context : [];\n const clientContext = req.body?.clientContext || {};\n const availableTools = Array.isArray(req.body?.availableTools) ? req.body.availableTools : [];\n const fallback = fallbackMeowStep(task, observation, clientContext);\n try {\n const llmStep = await callMeowStepLLM({ task, observation, context, clientContext, availableTools });\n if (llmStep) {\n return res.json({ source: \"llm\", decision: sanitizeMeowStep(llmStep, fallback, availableTools) });\n }\n } catch (err) {\n console.warn(\"[MeowStep] LLM unavailable, using fallback:\", err.message);\n }\n return res.json({ source: \"fallback\", decision: fallback });\n});\n\nasync function downloadAndSaveImage(urlOrData) {\n let buffer;\n let ext = \".jpg\";\n\n if (typeof urlOrData === \"string\" && urlOrData.startsWith(\"http\")) {\n const imageResponse = await fetch(urlOrData);\n if (!imageResponse.ok) {\n throw new Error(\"Failed to download image\");\n }\n buffer = Buffer.from(await imageResponse.arrayBuffer());\n } else if (typeof urlOrData === \"string\" && urlOrData.includes(\"base64\")) {\n const base64Data = urlOrData.split(\",\")[1];\n buffer = Buffer.from(base64Data, \"base64\");\n } else if (typeof urlOrData === \"string\") {\n const base64Data = urlOrData.split(\",\")[1] || urlOrData;\n buffer = Buffer.from(base64Data, \"base64\");\n } else {\n throw new Error(\"Invalid image data format\");\n }\n\n const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`;\n const filepath = path.join(UPLOADS_DIR, filename);\n fs.writeFileSync(filepath, buffer);\n return `/uploads/${filename}`;\n}\n\nfunction extractTryonPreviewUrl(data) {\n const urlOrPath = data?.data?.[0]?.url || data?.data?.[0]?.b64_json || null;\n return typeof urlOrPath === \"string\" ? urlOrPath : null;\n}\n\nfunction isTransientNetworkError(error) {\n const code = error?.code || error?.cause?.code;\n const message = String(error?.message || \"\");\n return [\"ENOTFOUND\", \"EAI_AGAIN\", \"ECONNRESET\", \"ETIMEDOUT\", \"ECONNREFUSED\"].includes(code) ||\n /getaddrinfo|network|timeout|socket hang up/i.test(message);\n}\n\nasync function fetchWithTransientRetry(url, options, retries = 2) {\n let lastError = null;\n for (let attempt = 0; attempt <= retries; attempt++) {\n try {\n return await fetch(url, options);\n } catch (error) {\n lastError = error;\n if (!isTransientNetworkError(error) || attempt >= retries) throw error;\n const delayMs = 800 * (attempt + 1);\n console.warn(`[TryOn] Network error (${error.code || error.message}); retrying in ${delayMs}ms...`);\n await new Promise((resolve) => setTimeout(resolve, delayMs));\n }\n }\n throw lastError;\n}\n\nasync function performTryonGeneration(payload, userId) {\n const {\n image,\n prompt,\n model,\n size,\n response_format,\n watermark,\n sequential_image_generation,\n } = payload;\n\n if (!image || (Array.isArray(image) && !image.length) || (typeof image === \"string\" && !image)) {\n const err = new Error(\"image is required\");\n err.statusCode = 400;\n throw err;\n }\n\n const endpoint = process.env.TRYON_API_ENDPOINT;\n const apiKey = process.env.TRYON_API_KEY;\n\n if (!endpoint || !apiKey) {\n const err = new Error(\"TRYON_API_ENDPOINT or TRYON_API_KEY not configured\");\n err.statusCode = 500;\n throw err;\n }\n\n const imageCount = Array.isArray(image) ? image.length : 1;\n console.log(`[TryOn] User: ${userId}, Request: ${imageCount} image(s), model: ${model || \"default\"}, size: ${size || \"default\"}`);\n\n const requestBody = {\n model: model || \"doubao-seedream-4-0-250828\",\n prompt,\n image,\n ...(size && { size }),\n ...(response_format && { response_format }),\n ...(watermark !== undefined && { watermark }),\n ...(sequential_image_generation !== undefined && { sequential_image_generation }),\n };\n\n const bodyStr = JSON.stringify(requestBody);\n console.log(`[TryOn] Forwarding to API, body size: ${(bodyStr.length / 1024 / 1024).toFixed(2)} MB`);\n\n const response = await fetchWithTransientRetry(endpoint, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${apiKey}`,\n },\n body: bodyStr,\n });\n\n const contentType = response.headers.get(\"content-type\");\n console.log(`[TryOn] API response: ${response.status}, content-type: ${contentType}`);\n\n if (contentType && contentType.includes(\"application/json\")) {\n const data = await response.json();\n if (data.error) {\n console.error(\"[TryOn] API error:\", JSON.stringify(data.error));\n const err = new Error(typeof data.error === \"string\" ? data.error : JSON.stringify(data.error));\n err.statusCode = response.status;\n err.payload = data;\n throw err;\n }\n\n try {\n const images = data?.data || [];\n for (let i = 0; i < images.length; i++) {\n const img = images[i];\n if (img.url) {\n console.log(`[TryOn] Downloading and saving image ${i + 1}...`);\n const localUrl = await downloadAndSaveImage(img.url);\n console.log(`[TryOn] Saved image to: ${localUrl}`);\n img.url = localUrl;\n }\n if (img.b64_json) {\n console.log(`[TryOn] Saving base64 image ${i + 1}...`);\n const localUrl = await downloadAndSaveImage(img.b64_json);\n console.log(`[TryOn] Saved image to: ${localUrl}`);\n img.b64_json = localUrl;\n }\n }\n } catch (saveErr) {\n console.warn(\"[TryOn] Failed to save image to server:\", saveErr.message);\n }\n\n return { status: response.status, contentType: \"application/json\", body: data };\n }\n\n const buffer = await response.arrayBuffer();\n const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.jpg`;\n const filepath = path.join(UPLOADS_DIR, filename);\n fs.writeFileSync(filepath, Buffer.from(buffer));\n const localUrl = `/uploads/${filename}`;\n console.log(`[TryOn] Saved binary image to: ${localUrl}`);\n return { status: response.status, contentType: \"application/json\", body: { data: [{ url: localUrl }] } };\n}\n\nfunction mapTryonJob(row) {\n if (!row) return null;\n return {\n id: row.id,\n historyId: row.history_id,\n status: row.status,\n previewUrl: row.result_preview_url,\n errorMessage: row.error_message || \"\",\n attempts: row.attempts || 0,\n providerLabel: row.provider_label || \"Doubao Seedream 4.0\",\n createdAt: row.created_at,\n updatedAt: row.updated_at,\n startedAt: row.started_at,\n completedAt: row.completed_at,\n };\n}\n\nconst tryonQueue = [];\nconst tryonRunning = new Set();\nconst TRYON_MAX_CONCURRENCY = Math.max(1, Number(process.env.TRYON_MAX_CONCURRENCY || 1));\n\nfunction pumpTryonQueue() {\n while (tryonRunning.size < TRYON_MAX_CONCURRENCY && tryonQueue.length > 0) {\n const jobId = tryonQueue.shift();\n if (!jobId || tryonRunning.has(jobId)) continue;\n tryonRunning.add(jobId);\n setImmediate(() => processTryonJob(jobId).finally(() => {\n tryonRunning.delete(jobId);\n pumpTryonQueue();\n }));\n }\n}\n\nfunction enqueueTryonJob(jobId) {\n if (!tryonQueue.includes(jobId) && !tryonRunning.has(jobId)) {\n tryonQueue.push(jobId);\n pumpTryonQueue();\n }\n}\n\nasync function processTryonJob(jobId) {\n const job = db.prepare(\"SELECT * FROM tryon_jobs WHERE id = ?\").get(jobId);\n if (!job || ![\"queued\", \"retrying\"].includes(job.status)) return;\n\n const nowIso = new Date().toISOString();\n db.prepare(`\n UPDATE tryon_jobs\n SET status = 'processing', started_at = COALESCE(started_at, ?), updated_at = ?, attempts = attempts + 1, error_message = ''\n WHERE id = ?\n `).run(nowIso, nowIso, jobId);\n\n try {\n const payload = JSON.parse(job.request_payload);\n const result = await performTryonGeneration(payload, job.user_id);\n const previewUrl = extractTryonPreviewUrl(result.body);\n const completedAt = new Date().toISOString();\n\n db.prepare(`\n UPDATE tryon_jobs\n SET status = ?, result_preview_url = ?, updated_at = ?, completed_at = ?\n WHERE id = ?\n `).run(previewUrl ? \"ready\" : \"failed\", previewUrl || null, completedAt, completedAt, jobId);\n\n db.prepare(`\n UPDATE tryon_history\n SET status = ?, preview_url = ?, analysis_summary = ?, provider_label = ?\n WHERE id = ? AND user_id = ?\n `).run(\n previewUrl ? \"ready\" : \"failed\",\n previewUrl || null,\n previewUrl ? \"Try-on image generated successfully.\" : \"Try-on API did not return an image.\",\n job.provider_label || \"Doubao Seedream 4.0\",\n job.history_id,\n job.user_id\n );\n } catch (err) {\n const failedAt = new Date().toISOString();\n const message = err.message || \"Try-on job failed\";\n console.error(\"[TryOn Job] failed:\", jobId, message);\n db.prepare(`\n UPDATE tryon_jobs\n SET status = 'failed', error_message = ?, updated_at = ?, completed_at = ?\n WHERE id = ?\n `).run(message, failedAt, failedAt, jobId);\n db.prepare(`\n UPDATE tryon_history\n SET status = 'failed', analysis_summary = ?\n WHERE id = ? AND user_id = ?\n `).run(message, job.history_id, job.user_id);\n }\n}\n\napp.post(\"/api/tryon\", authMiddleware, async (req, res) => {\n try {\n const result = await performTryonGeneration(req.body, req.user.userId);\n return res.status(result.status).json(result.body);\n } catch (err) {\n console.error(\"Tryon proxy error:\", err.message);\n if (err.payload) {\n return res.status(err.statusCode || 502).json(err.payload);\n }\n return res.status(err.statusCode || 502).json({ error: \"Failed to reach tryon API\", detail: err.message });\n }\n});\n\napp.post(\"/api/tryon/jobs\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const {\n id,\n title,\n matchedItemIds,\n cityLabel,\n providerLabel,\n analysisSummary,\n request,\n } = req.body;\n\n const requestPayload = request || req.body;\n delete requestPayload.id;\n delete requestPayload.title;\n delete requestPayload.matchedItemIds;\n delete requestPayload.cityLabel;\n delete requestPayload.providerLabel;\n delete requestPayload.analysisSummary;\n delete requestPayload.request;\n\n const historyId = id || generateId();\n const jobId = `job-${historyId}`;\n const nowIso = new Date().toISOString();\n\n try {\n db.prepare(`\n INSERT OR REPLACE INTO tryon_history (id, user_id, title, prompt, matched_item_ids, preview_url, status, favorited, created_at, city_label, provider_label, analysis_summary, service_mode)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n `).run(\n historyId,\n userId,\n title || \"Try-on\",\n requestPayload.prompt || \"\",\n JSON.stringify(matchedItemIds || []),\n null,\n \"processing\",\n 0,\n nowIso,\n cityLabel || \"\",\n providerLabel || \"Doubao Seedream 4.0\",\n analysisSummary || \"Try-on job queued.\",\n \"live\"\n );\n\n db.prepare(`\n INSERT OR REPLACE INTO tryon_jobs (id, user_id, history_id, request_payload, status, result_preview_url, error_message, attempts, provider_label, created_at, updated_at, started_at, completed_at)\n VALUES (?, ?, ?, ?, 'queued', NULL, '', 0, ?, ?, ?, NULL, NULL)\n `).run(jobId, userId, historyId, JSON.stringify(requestPayload), providerLabel || \"Doubao Seedream 4.0\", nowIso, nowIso);\n\n enqueueTryonJob(jobId);\n const job = db.prepare(\"SELECT * FROM tryon_jobs WHERE id = ? AND user_id = ?\").get(jobId, userId);\n res.status(202).json({ job: mapTryonJob(job) });\n } catch (err) {\n console.error(\"[TryOn Job] create error:\", err.message);\n res.status(500).json({ error: \"Failed to create try-on job\", detail: err.message });\n }\n});\n\napp.get(\"/api/tryon/jobs/:id\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const job = db.prepare(\"SELECT * FROM tryon_jobs WHERE id = ? AND user_id = ?\").get(req.params.id, userId);\n if (!job) {\n return res.status(404).json({ error: \"Job not found\" });\n }\n res.json({ job: mapTryonJob(job) });\n});\n\napp.get(\"/api/weather\", async (req, res) => {\n const { latitude, longitude } = req.query;\n\n if (!latitude || !longitude) {\n return res.status(400).json({ error: \"latitude and longitude are required\" });\n }\n\n const AMAP_KEY = process.env.AMAP_KEY;\n if (!AMAP_KEY) {\n return res.status(500).json({ error: \"AMAP_KEY not configured\" });\n }\n\n try {\n const regeoUrl = `https://restapi.amap.com/v3/geocode/regeo?key=${AMAP_KEY}&location=${longitude},${latitude}&extensions=base`;\n const regeoRes = await fetch(regeoUrl);\n const regeoData = await regeoRes.json();\n\n const addr = regeoData.regeocode?.addressComponent || {};\n const adcode = addr.adcode || \"\";\n const district = addr.district || \"\";\n const cityRaw = Array.isArray(addr.city) ? \"\" : (addr.city || \"\");\n const province = addr.province || \"\";\n const locationName = district || cityRaw || province;\n const cityName = cityRaw || province.replace(/市$/, \"\") || district;\n\n let amapCurrent = null;\n let amapForecast = null;\n\n if (adcode) {\n const [baseRes, allRes] = await Promise.allSettled([\n fetch(`https://restapi.amap.com/v3/weather/weatherInfo?key=${AMAP_KEY}&city=${adcode}&extensions=base`),\n fetch(`https://restapi.amap.com/v3/weather/weatherInfo?key=${AMAP_KEY}&city=${adcode}&extensions=all`),\n ]);\n\n if (baseRes.status === \"fulfilled\" && baseRes.value.ok) {\n const baseData = await baseRes.value.json();\n amapCurrent = baseData.lives?.[0] || null;\n }\n if (allRes.status === \"fulfilled\" && allRes.value.ok) {\n const allData = await allRes.value.json();\n amapForecast = allData.forecasts?.[0] || null;\n }\n }\n\n const openMeteoUrl = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&hourly=temperature_2m,weather_code&timezone=auto&forecast_days=2`;\n let hourlyData = null;\n try {\n const hourlyRes = await fetch(openMeteoUrl);\n const hourlyJson = await hourlyRes.json();\n hourlyData = hourlyJson.hourly || null;\n } catch {}\n\n const result = { locationName, cityName, adcode, current: null, daily: null, hourly: hourlyData };\n\n if (amapCurrent) {\n result.current = {\n temperature: parseFloat(amapCurrent.temperature_float || amapCurrent.temperature || \"18\"),\n weather_label: amapCurrent.weather || \"晴\",\n humidity: parseFloat(amapCurrent.humidity_float || amapCurrent.humidity || \"50\"),\n winddirection: amapCurrent.winddirection || \"\",\n windpower: amapCurrent.windpower || \"\",\n };\n }\n\n if (amapForecast) {\n result.daily = amapForecast.casts.map((cast) => ({\n date: cast.date,\n dayweather: cast.dayweather,\n nightweather: cast.nightweather,\n daytemp: parseInt(cast.daytemp, 10) || 0,\n nighttemp: parseInt(cast.nighttemp, 10) || 0,\n }));\n }\n\n return res.json(result);\n } catch (err) {\n console.error(\"Weather proxy error:\", err.message);\n return res.status(502).json({ error: \"Failed to reach weather API\", detail: err.message });\n }\n});\n\napp.get(\"/api/city-search\", async (req, res) => {\n const { keywords } = req.query;\n if (!keywords || !keywords.trim()) {\n return res.status(400).json({ error: \"keywords is required\" });\n }\n const AMAP_KEY = process.env.AMAP_KEY;\n if (!AMAP_KEY) {\n return res.status(500).json({ error: \"AMAP_KEY not configured\" });\n }\n try {\n const url = `https://restapi.amap.com/v3/config/district?key=${AMAP_KEY}&keywords=${encodeURIComponent(keywords.trim())}&subdistrict=0`;\n const response = await fetch(url);\n const data = await response.json();\n const cities = (data.districts || [])\n .filter((d) => d.level === \"city\" || d.level === \"province\")\n .slice(0, 10)\n .map((d) => {\n const parts = (d.center || \"\").split(\",\");\n return {\n id: d.adcode,\n name: d.name,\n latitude: parseFloat(parts[1]) || 0,\n longitude: parseFloat(parts[0]) || 0,\n };\n });\n return res.json(cities);\n } catch (err) {\n console.error(\"City search error:\", err.message);\n return res.status(502).json({ error: \"Failed to search cities\" });\n }\n});\n\napp.get(\"/api/wardrobe\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n try {\n const items = db.prepare(\"SELECT * FROM wardrobe_items WHERE user_id = ? ORDER BY created_at DESC\").all(userId);\n const result = items.map((item) => ({\n id: item.id,\n title: item.title,\n category: item.category,\n notes: item.notes,\n coverUri: item.cover_url,\n colorTags: JSON.parse(item.color_tags || \"[]\"),\n seasonTags: JSON.parse(item.season_tags || \"[]\"),\n purchaseDate: item.purchase_date,\n isFavorite: !!item.is_favorite,\n sourceLabel: item.source_label,\n analysis: item.analysis ? JSON.parse(item.analysis) : null,\n createdAt: item.created_at,\n photos: db.prepare(\"SELECT photo_url FROM wardrobe_photos WHERE item_id = ?\").all(item.id).map((p) => p.photo_url),\n }));\n res.json(result);\n } catch (err) {\n console.error(\"[Wardrobe] GET error:\", err.message);\n res.status(500).json({ error: \"获取衣橱失败\" });\n }\n});\n\napp.post(\"/api/wardrobe\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const { title, category, notes, coverUri, colorTags, seasonTags, purchaseDate, isFavorite, sourceLabel, photos } = req.body;\n\n try {\n const id = generateId();\n db.prepare(`\n INSERT INTO wardrobe_items (id, user_id, title, category, notes, cover_url, color_tags, season_tags, purchase_date, is_favorite, source_label)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n `).run(\n id, userId, title || \"\", category || \"\", notes || \"\", coverUri || \"\",\n JSON.stringify(colorTags || []), JSON.stringify(seasonTags || []),\n purchaseDate || null, isFavorite ? 1 : 0, sourceLabel || null\n );\n\n if (Array.isArray(photos)) {\n const insertPhoto = db.prepare(\"INSERT INTO wardrobe_photos (id, item_id, photo_url) VALUES (?, ?, ?)\");\n for (const photoUrl of photos) {\n insertPhoto.run(generateId(), id, photoUrl);\n }\n }\n\n res.status(201).json({\n id, title: title || \"\", category: category || \"\", notes: notes || \"\",\n coverUri: coverUri || \"\", colorTags: colorTags || [], seasonTags: seasonTags || [],\n purchaseDate: purchaseDate || null, isFavorite: !!isFavorite, sourceLabel: sourceLabel || null,\n createdAt: new Date().toISOString(), photos: photos || [],\n });\n } catch (err) {\n console.error(\"[Wardrobe] POST error:\", err.message);\n res.status(500).json({ error: \"添加衣橱失败\" });\n }\n});\n\napp.put(\"/api/wardrobe/:id\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const itemId = req.params.id;\n\n try {\n const existing = db.prepare(\"SELECT * FROM wardrobe_items WHERE id = ? AND user_id = ?\").get(itemId, userId);\n if (!existing) {\n return res.status(404).json({ error: \"Item not found\" });\n }\n\n const { title, category, notes, coverUri, colorTags, seasonTags, purchaseDate, isFavorite, sourceLabel } = req.body;\n\n db.prepare(`\n UPDATE wardrobe_items SET title = ?, category = ?, notes = ?, cover_url = ?,\n color_tags = ?, season_tags = ?, purchase_date = ?, is_favorite = ?,\n source_label = ?\n WHERE id = ? AND user_id = ?\n `).run(\n title !== undefined ? title : existing.title,\n category !== undefined ? category : existing.category,\n notes !== undefined ? notes : existing.notes,\n coverUri !== undefined ? coverUri : existing.cover_url,\n JSON.stringify(colorTags || JSON.parse(existing.color_tags || \"[]\")),\n JSON.stringify(seasonTags || JSON.parse(existing.season_tags || \"[]\")),\n purchaseDate !== undefined ? purchaseDate : existing.purchase_date,\n isFavorite !== undefined ? (isFavorite ? 1 : 0) : existing.is_favorite,\n sourceLabel !== undefined ? sourceLabel : existing.source_label,\n itemId, userId\n );\n\n const updated = db.prepare(\"SELECT * FROM wardrobe_items WHERE id = ?\").get(itemId);\n res.json({\n id: updated.id, title: updated.title, category: updated.category,\n notes: updated.notes, coverUri: updated.cover_url,\n colorTags: JSON.parse(updated.color_tags || \"[]\"),\n seasonTags: JSON.parse(updated.season_tags || \"[]\"),\n purchaseDate: updated.purchase_date, isFavorite: !!updated.is_favorite,\n sourceLabel: updated.source_label, createdAt: updated.created_at,\n });\n } catch (err) {\n console.error(\"[Wardrobe] PUT error:\", err.message);\n res.status(500).json({ error: \"更新衣橱失败\" });\n }\n});\n\napp.delete(\"/api/wardrobe/:id\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const itemId = req.params.id;\n\n try {\n const existing = db.prepare(\"SELECT * FROM wardrobe_items WHERE id = ? AND user_id = ?\").get(itemId, userId);\n if (!existing) {\n return res.status(404).json({ error: \"Item not found\" });\n }\n\n db.prepare(\"DELETE FROM wardrobe_photos WHERE item_id = ?\").run(itemId);\n db.prepare(\"DELETE FROM wardrobe_items WHERE id = ? AND user_id = ?\").run(itemId, userId);\n\n res.json({ id: itemId, deleted: true });\n } catch (err) {\n console.error(\"[Wardrobe] DELETE error:\", err.message);\n res.status(500).json({ error: \"删除衣橱失败\" });\n }\n});\n\nfunction wardrobePhotoDataUrl(itemId, coverUrl) {\n const photoRows = db.prepare(\n \"SELECT photo_url FROM wardrobe_photos WHERE item_id = ? ORDER BY created_at ASC\"\n ).all(itemId);\n const candidates = [...photoRows.map((row) => row.photo_url), coverUrl].filter(Boolean);\n\n for (const candidate of candidates) {\n if (candidate.startsWith(\"data:image/\")) return candidate;\n if (candidate.startsWith(\"http://\") || candidate.startsWith(\"https://\")) return candidate;\n if (!candidate.startsWith(\"/uploads/\")) continue;\n\n const filePath = path.join(UPLOADS_DIR, path.basename(candidate));\n if (!fs.existsSync(filePath)) continue;\n const ext = path.extname(filePath).toLowerCase();\n const mime = ext === \".png\"\n ? \"image/png\"\n : ext === \".webp\"\n ? \"image/webp\"\n : ext === \".gif\"\n ? \"image/gif\"\n : \"image/jpeg\";\n return `data:${mime};base64,${fs.readFileSync(filePath).toString(\"base64\")}`;\n }\n\n return null;\n}\n\nfunction parseWardrobeAnalysis(content) {\n const raw = Array.isArray(content)\n ? content.map((part) => typeof part === \"string\" ? part : part?.text || \"\").join(\"\")\n : content;\n if (raw && typeof raw === \"object\") return raw;\n if (typeof raw !== \"string\" || !raw.trim()) throw new Error(\"Vision model returned empty content\");\n\n const cleaned = raw.trim().replace(/^```(?:json)?\\s*/i, \"\").replace(/\\s*```$/, \"\");\n const firstBrace = cleaned.indexOf(\"{\");\n const lastBrace = cleaned.lastIndexOf(\"}\");\n const jsonText = firstBrace >= 0 && lastBrace > firstBrace\n ? cleaned.slice(firstBrace, lastBrace + 1)\n : cleaned;\n return JSON.parse(jsonText);\n}\n\napp.post(\"/api/wardrobe/:id/analyze\", authMiddleware, async (req, res) => {\n const userId = req.user.userId;\n const itemId = req.params.id;\n\n try {\n const item = db.prepare(\"SELECT * FROM wardrobe_items WHERE id = ? AND user_id = ?\").get(itemId, userId);\n if (!item) {\n return res.status(404).json({ error: \"Item not found\" });\n }\n\n const imageInput = wardrobePhotoDataUrl(itemId, item.cover_url);\n if (!imageInput) return res.status(400).json({ error: \"No uploaded photo to analyze\" });\n\n const baseUrl = (process.env.WARDROBE_VISION_BASE_URL || \"https://api.xiaomimimo.com/v1\").replace(/\\/$/, \"\");\n const endpoint = `${baseUrl}/chat/completions`;\n const apiKey = process.env.WARDROBE_VISION_API_KEY;\n const model = process.env.WARDROBE_VISION_MODEL || \"mimo-v2.5\";\n\n if (!apiKey) {\n return res.status(500).json({ error: \"WARDROBE_VISION_API_KEY not configured\" });\n }\n\n const prompt = [\n \"你是喵喵 App 的衣物视觉分析模型。请只分析图片里的衣服,忽略人物和背景。\",\n \"只返回一个 JSON 对象,不要 Markdown,不要解释。\",\n \"schema:\",\n \"{\\\"category\\\":string,\\\"itemType\\\":string,\\\"style\\\":string,\\\"seasons\\\":string[],\\\"pairingSeasons\\\":string[],\\\"weatherTags\\\":string[],\\\"occasions\\\":string[],\\\"temperatureRange\\\":{\\\"min\\\":number,\\\"max\\\":number},\\\"colors\\\":string[],\\\"description\\\":string,\\\"material\\\":string,\\\"thickness\\\":string,\\\"fit\\\":string,\\\"breathability\\\":string,\\\"weatherSuitability\\\":string,\\\"stylingTips\\\":string[],\\\"visibleDetails\\\":string[]}.\",\n \"字段要求:description 用 30-80 个中文字符具体描述颜色、版型、领口/袖口/图案等可见特征;material、thickness、fit、breathability 可以合理推断但不要编造品牌。\",\n \"weatherSuitability 要说明它适合什么温度、晴雨、风、湿度或温差场景;stylingTips 给 2-4 条中文穿搭建议;visibleDetails 给 3-6 个图片可见细节。\",\n \"temperatureRange 用摄氏度,结合厚薄和材质判断。所有值尽量使用自然中文。\",\n ].join(\" \");\n\n const requestBody = {\n model,\n messages: [{\n role: \"user\",\n content: [\n { type: \"text\", text: prompt },\n { type: \"image_url\", image_url: { url: imageInput } },\n ],\n }],\n temperature: 0.1,\n };\n\n console.log(`[Wardrobe Analyze] User: ${userId}, Item: ${itemId}`);\n\n const response = await fetch(endpoint, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(requestBody),\n });\n\n if (!response.ok) {\n const text = await response.text();\n console.error(\"[Wardrobe Analyze] API error:\", text);\n return res.status(response.status).json({ error: \"Vision API request failed\", detail: text });\n }\n\n const data = await response.json();\n const content = data.choices?.[0]?.message?.content || data.data?.[0]?.content || data.text || data.content || \"\";\n\n let analysis;\n try {\n analysis = parseWardrobeAnalysis(content);\n } catch (parseErr) {\n console.error(\"[Wardrobe Analyze] JSON parse error:\", parseErr.message, \"content:\", content);\n return res.status(500).json({ error: \"Failed to parse vision model response\", raw: content });\n }\n\n db.prepare(\"UPDATE wardrobe_items SET analysis = ? WHERE id = ? AND user_id = ?\").run(JSON.stringify(analysis), itemId, userId);\n\n res.json({ id: itemId, analysis });\n } catch (err) {\n console.error(\"[Wardrobe] Analyze error:\", err.message);\n res.status(500).json({ error: \"分析衣物失败\" });\n }\n});\n\napp.post(\"/api/wardrobe/:id/photo\", authMiddleware, upload.single(\"photo\"), (req, res) => {\n const userId = req.user.userId;\n const itemId = req.params.id;\n\n try {\n const existing = db.prepare(\"SELECT * FROM wardrobe_items WHERE id = ? AND user_id = ?\").get(itemId, userId);\n if (!existing) {\n if (req.file) fs.unlinkSync(req.file.path);\n return res.status(404).json({ error: \"Item not found\" });\n }\n if (!req.file) {\n return res.status(400).json({ error: \"No photo uploaded\" });\n }\n\n const photoUrl = `/uploads/${req.file.filename}`;\n db.prepare(\"INSERT INTO wardrobe_photos (id, item_id, photo_url) VALUES (?, ?, ?)\").run(generateId(), itemId, photoUrl);\n const hasUploadedCover = typeof existing.cover_url === \"string\" && existing.cover_url.startsWith(\"/uploads/\");\n if (!hasUploadedCover) {\n db.prepare(\"UPDATE wardrobe_items SET cover_url = ? WHERE id = ? AND user_id = ?\").run(photoUrl, itemId, userId);\n }\n\n res.json({ photoUrl });\n } catch (err) {\n console.error(\"[Wardrobe] Photo upload error:\", err.message);\n res.status(500).json({ error: \"上传照片失败\" });\n }\n});\n\napp.get(\"/api/tryon/history\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n try {\n const history = db.prepare(\"SELECT * FROM tryon_history WHERE user_id = ? ORDER BY created_at DESC\").all(userId);\n res.json(history.map((h) => ({\n id: h.id, title: h.title, prompt: h.prompt,\n matchedItemIds: JSON.parse(h.matched_item_ids || \"[]\"),\n previewUrl: h.preview_url,\n previewUri: h.preview_url,\n status: h.status,\n favorited: !!h.favorited, createdAt: h.created_at,\n cityLabel: h.city_label, providerLabel: h.provider_label,\n analysisSummary: h.analysis_summary, serviceMode: h.service_mode,\n })));\n } catch (err) {\n console.error(\"[TryOn] History GET error:\", err.message);\n res.status(500).json({ error: \"获取试穿历史失败\" });\n }\n});\n\napp.post(\"/api/tryon/history\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const { id, title, prompt, matchedItemIds, previewUrl, status, favorited, createdAt, cityLabel, providerLabel, analysisSummary, serviceMode } = req.body;\n\n try {\n const histId = id || generateId();\n db.prepare(`\n INSERT OR REPLACE INTO tryon_history (id, user_id, title, prompt, matched_item_ids, preview_url, status, favorited, created_at, city_label, provider_label, analysis_summary, service_mode)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n `).run(\n histId, userId, title || \"\", prompt || \"\",\n JSON.stringify(matchedItemIds || []), previewUrl || null,\n status || \"processing\", favorited ? 1 : 0, createdAt || new Date().toISOString(),\n cityLabel || \"\", providerLabel || \"\", analysisSummary || \"\", serviceMode || \"live\"\n );\n\n res.status(201).json({ id: histId });\n } catch (err) {\n console.error(\"[TryOn] History POST error:\", err.message);\n res.status(500).json({ error: \"保存试穿记录失败\" });\n }\n});\n\napp.put(\"/api/tryon/history/:id\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const histId = req.params.id;\n\n try {\n const existing = db.prepare(\"SELECT * FROM tryon_history WHERE id = ? AND user_id = ?\").get(histId, userId);\n if (!existing) {\n return res.status(404).json({ error: \"Record not found\" });\n }\n\n const { status, previewUrl, favorited } = req.body;\n const updates = [];\n const values = [];\n\n if (status !== undefined) { updates.push(\"status = ?\"); values.push(status); }\n if (previewUrl !== undefined) { updates.push(\"preview_url = ?\"); values.push(previewUrl); }\n if (favorited !== undefined) { updates.push(\"favorited = ?\"); values.push(favorited ? 1 : 0); }\n\n if (updates.length === 0) {\n return res.status(400).json({ error: \"No fields to update\" });\n }\n\n values.push(histId, userId);\n db.prepare(`UPDATE tryon_history SET ${updates.join(\", \")} WHERE id = ? AND user_id = ?`).run(...values);\n\n res.json({ id: histId, updated: true });\n } catch (err) {\n console.error(\"[TryOn] History PUT error:\", err.message);\n res.status(500).json({ error: \"更新试穿记录失败\" });\n }\n});\n\napp.delete(\"/api/tryon/history/:id\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const histId = req.params.id;\n\n try {\n db.prepare(\"DELETE FROM tryon_history WHERE id = ? AND user_id = ?\").run(histId, userId);\n res.json({ id: histId, deleted: true });\n } catch (err) {\n console.error(\"[TryOn] History DELETE error:\", err.message);\n res.status(500).json({ error: \"删除试穿记录失败\" });\n }\n});\n\napp.get(\"/api/profile\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n try {\n const user = db.prepare(\"SELECT id, email, nickname, avatar_url, created_at FROM users WHERE id = ?\").get(userId);\n if (!user) return res.status(404).json({ error: \"User not found\" });\n\n let profile = db.prepare(\"SELECT * FROM user_profiles WHERE user_id = ?\").get(userId);\n if (!profile) {\n db.prepare(\"INSERT INTO user_profiles (user_id) VALUES (?)\").run(userId);\n profile = db.prepare(\"SELECT * FROM user_profiles WHERE user_id = ?\").get(userId);\n }\n\n const likesCount = db.prepare(\"SELECT COALESCE(SUM(likes_count), 0) AS total FROM circle_posts WHERE user_id = ?\").get(userId).total;\n \n const followersCount = db.prepare(\"SELECT COUNT(*) AS count FROM follows WHERE following_id = ?\").get(userId).count;\n \n const followingCount = db.prepare(\"SELECT COUNT(*) AS count FROM follows WHERE follower_id = ?\").get(userId).count;\n\n res.json({\n id: user.id,\n email: user.email,\n nickname: user.nickname,\n avatarUrl: user.avatar_url,\n gender: user.gender,\n createdAt: user.created_at,\n bodyProfile: {\n heightCm: profile.body_height || \"\",\n weightKg: profile.body_weight || \"\",\n shoulderCm: profile.body_shoulder || \"\",\n topSize: profile.top_size || \"\",\n bottomSize: profile.bottom_size || \"\",\n shoeSize: profile.shoe_size || \"\",\n },\n userPhotoUrl: profile.user_photo_url,\n faceImageUrl: profile.face_image_url,\n stylePreference: profile.style_preference || \"\",\n location: profile.location || \"\",\n school: profile.school || \"\",\n bio: profile.bio || \"\",\n likesCount,\n followersCount,\n followingCount,\n });\n } catch (err) {\n console.error(\"[Profile] GET error:\", err.message);\n res.status(500).json({ error: \"获取用户资料失败\" });\n }\n});\n\napp.put(\"/api/profile\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const { nickname, avatarUrl, gender, age, company, bodyProfile, userPhotoUrl, faceImageUrl, stylePreference, location, school, bio } = req.body;\n\n try {\n if (nickname !== undefined || avatarUrl !== undefined || gender !== undefined || age !== undefined || company !== undefined) {\n const updates = [];\n const values = [];\n if (nickname !== undefined) { updates.push(\"nickname = ?\"); values.push(nickname); }\n if (avatarUrl !== undefined) { updates.push(\"avatar_url = ?\"); values.push(avatarUrl); }\n if (gender !== undefined) { updates.push(\"gender = ?\"); values.push(gender); }\n if (age !== undefined) { updates.push(\"age = ?\"); values.push(age); }\n if (company !== undefined) { updates.push(\"company = ?\"); values.push(company); }\n updates.push(\"updated_at = datetime('now')\");\n values.push(userId);\n db.prepare(`UPDATE users SET ${updates.join(\", \")} WHERE id = ?`).run(...values);\n }\n\n let profile = db.prepare(\"SELECT * FROM user_profiles WHERE user_id = ?\").get(userId);\n if (!profile) {\n db.prepare(\"INSERT INTO user_profiles (user_id) VALUES (?)\").run(userId);\n }\n\n const profileUpdates = [];\n const profileValues = [];\n if (bodyProfile) {\n if (bodyProfile.heightCm !== undefined) { profileUpdates.push(\"body_height = ?\"); profileValues.push(bodyProfile.heightCm); }\n if (bodyProfile.weightKg !== undefined) { profileUpdates.push(\"body_weight = ?\"); profileValues.push(bodyProfile.weightKg); }\n if (bodyProfile.shoulderCm !== undefined) { profileUpdates.push(\"body_shoulder = ?\"); profileValues.push(bodyProfile.shoulderCm); }\n if (bodyProfile.topSize !== undefined) { profileUpdates.push(\"top_size = ?\"); profileValues.push(bodyProfile.topSize); }\n if (bodyProfile.bottomSize !== undefined) { profileUpdates.push(\"bottom_size = ?\"); profileValues.push(bodyProfile.bottomSize); }\n if (bodyProfile.shoeSize !== undefined) { profileUpdates.push(\"shoe_size = ?\"); profileValues.push(bodyProfile.shoeSize); }\n }\n if (userPhotoUrl !== undefined) { profileUpdates.push(\"user_photo_url = ?\"); profileValues.push(userPhotoUrl); }\n if (faceImageUrl !== undefined) { profileUpdates.push(\"face_image_url = ?\"); profileValues.push(faceImageUrl); }\n if (stylePreference !== undefined) { profileUpdates.push(\"style_preference = ?\"); profileValues.push(stylePreference); }\n if (location !== undefined) { profileUpdates.push(\"location = ?\"); profileValues.push(location); }\n if (school !== undefined) { profileUpdates.push(\"school = ?\"); profileValues.push(school); }\n if (bio !== undefined) { profileUpdates.push(\"bio = ?\"); profileValues.push(bio); }\n\n if (profileUpdates.length > 0) {\n profileUpdates.push(\"updated_at = datetime('now')\");\n profileValues.push(userId);\n db.prepare(`UPDATE user_profiles SET ${profileUpdates.join(\", \")} WHERE user_id = ?`).run(...profileValues);\n }\n\n const user = db.prepare(\"SELECT id, email, nickname, avatar_url, age, company FROM users WHERE id = ?\").get(userId);\n const updatedProfile = db.prepare(\"SELECT * FROM user_profiles WHERE user_id = ?\").get(userId);\n\n res.json({\n id: user.id,\n email: user.email,\n nickname: user.nickname,\n avatarUrl: user.avatar_url,\n age: user.age,\n company: user.company,\n bodyProfile: {\n heightCm: updatedProfile.body_height || \"\",\n weightKg: updatedProfile.body_weight || \"\",\n shoulderCm: updatedProfile.body_shoulder || \"\",\n topSize: updatedProfile.top_size || \"\",\n bottomSize: updatedProfile.bottom_size || \"\",\n shoeSize: updatedProfile.shoe_size || \"\",\n },\n userPhotoUrl: updatedProfile.user_photo_url,\n faceImageUrl: updatedProfile.face_image_url,\n stylePreference: updatedProfile.style_preference || \"\",\n location: updatedProfile.location || \"\",\n school: updatedProfile.school || \"\",\n bio: updatedProfile.bio || \"\",\n });\n } catch (err) {\n console.error(\"[Profile] PUT error:\", err.message);\n res.status(500).json({ error: \"更新用户资料失败\" });\n }\n});\n\napp.post(\"/api/profile/photo\", authMiddleware, upload.single(\"photo\"), (req, res) => {\n const userId = req.user.userId;\n\n try {\n if (!req.file) {\n return res.status(400).json({ error: \"No photo uploaded\" });\n }\n\n const photoUrl = `/uploads/${req.file.filename}`;\n db.prepare(\"UPDATE user_profiles SET user_photo_url = ? WHERE user_id = ?\").run(photoUrl, userId);\n db.prepare(\"UPDATE users SET avatar_url = ?, updated_at = datetime('now') WHERE id = ?\").run(photoUrl, userId);\n\n res.json({ photoUrl, avatarUrl: photoUrl });\n } catch (err) {\n console.error(\"[Profile] Photo upload error:\", err.message);\n res.status(500).json({ error: \"上传头像失败\" });\n }\n});\n\napp.post(\"/api/profile/face-photo\", authMiddleware, upload.single(\"photo\"), (req, res) => {\n const userId = req.user.userId;\n\n try {\n if (!req.file) {\n return res.status(400).json({ error: \"No photo uploaded\" });\n }\n\n const photoUrl = `/uploads/${req.file.filename}`;\n db.prepare(\"UPDATE user_profiles SET face_image_url = ? WHERE user_id = ?\").run(photoUrl, userId);\n\n res.json({ photoUrl });\n } catch (err) {\n console.error(\"[Profile] Face photo upload error:\", err.message);\n res.status(500).json({ error: \"上传肖像照片失败\" });\n }\n});\n\nfunction isVirtualCircleAuthor(post) {\n const email = String(post.author_email || post.email || \"\");\n return /^bot_\\d+@stylestudio\\.bot$/i.test(email);\n}\n\nfunction shuffleCirclePosts(posts) {\n const list = [...posts];\n for (let i = list.length - 1; i > 0; i--) {\n const j = Math.floor(Math.random() * (i + 1));\n [list[i], list[j]] = [list[j], list[i]];\n }\n return list;\n}\n\nfunction mixCirclePostsWithRealPriority(posts) {\n const realPosts = posts.filter((post) => !post.isVirtualAuthor);\n const virtualPosts = posts.filter((post) => post.isVirtualAuthor);\n const realWithImages = realPosts.filter((post) => Array.isArray(post.imageUris) && post.imageUris.length > 0);\n const realWithoutImages = realPosts.filter((post) => !Array.isArray(post.imageUris) || post.imageUris.length === 0);\n const realQueue = [...shuffleCirclePosts(realWithImages), ...shuffleCirclePosts(realWithoutImages)];\n const virtualQueue = shuffleCirclePosts(virtualPosts);\n const mixed = [];\n const pattern = [\"real\", \"real\", \"virtual\", \"real\", \"real\", \"virtual\", \"real\", \"real\", \"real\", \"virtual\"];\n let realIndex = 0;\n let virtualIndex = 0;\n\n while (realIndex < realQueue.length || virtualIndex < virtualQueue.length) {\n for (const slot of pattern) {\n if (slot === \"real\") {\n if (realIndex < realQueue.length) {\n mixed.push(realQueue[realIndex++]);\n } else if (virtualIndex < virtualQueue.length) {\n mixed.push(virtualQueue[virtualIndex++]);\n }\n } else {\n if (virtualIndex < virtualQueue.length) {\n mixed.push(virtualQueue[virtualIndex++]);\n } else if (realIndex < realQueue.length) {\n mixed.push(realQueue[realIndex++]);\n }\n }\n if (realIndex >= realQueue.length && virtualIndex >= virtualQueue.length) break;\n }\n }\n\n return mixed;\n}\n\napp.get(\"/api/circle\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const { feed } = req.query;\n try {\n let posts;\n if (feed === \"following\") {\n posts = db.prepare(`\n SELECT p.*, u.nickname, u.email as author_email, u.avatar_url as author_avatar\n FROM circle_posts p\n JOIN users u ON p.user_id = u.id\n WHERE p.user_id IN (\n SELECT following_id FROM follows WHERE follower_id = ?\n )\n AND p.visibility = 'public'\n AND COALESCE(p.review_status, 'approved') = 'approved'\n ORDER BY p.created_at DESC\n `).all(userId);\n } else {\n posts = db.prepare(`\n SELECT p.*, u.nickname, u.email as author_email, u.avatar_url as author_avatar\n FROM circle_posts p\n JOIN users u ON p.user_id = u.id\n WHERE p.visibility = 'public'\n AND COALESCE(p.review_status, 'approved') = 'approved'\n ORDER BY p.created_at DESC\n `).all();\n }\n\n const userProfile = db.prepare(\"SELECT location FROM user_profiles WHERE user_id = ?\").get(userId);\n const userLocation = userProfile?.location || \"\";\n\n const result = posts.map((post) => {\n const isLiked = !!db.prepare(\"SELECT 1 FROM circle_likes WHERE post_id = ? AND user_id = ?\").get(post.id, userId);\n const isFollowed = !!db.prepare(\"SELECT 1 FROM follows WHERE follower_id = ? AND following_id = ?\").get(userId, post.user_id);\n const commentCount = db.prepare(\"SELECT COUNT(*) as c FROM circle_comments WHERE post_id = ?\").get(post.id).c;\n\n let imageUris = [];\n if (post.image_urls) {\n try { imageUris = JSON.parse(post.image_urls); } catch { imageUris = []; }\n }\n if (imageUris.length === 0 && post.cover_image_url) {\n imageUris = [post.cover_image_url];\n }\n\n return {\n id: post.id,\n author: post.nickname,\n authorId: post.user_id,\n authorAvatar: post.author_avatar,\n isVirtualAuthor: isVirtualCircleAuthor(post),\n headline: post.headline,\n caption: post.caption,\n coverUri: imageUris[0] || post.cover_image_url || null,\n imageUris,\n imageAspectRatio: post.image_aspect_ratio,\n locationLabel: post.location_label,\n visibility: post.visibility || \"public\",\n reviewStatus: post.review_status || \"approved\",\n likes: post.likes_count,\n isLiked,\n isFollowed,\n tags: JSON.parse(post.tags || \"[]\"),\n createdAt: post.created_at,\n comments: [],\n commentCount: commentCount,\n };\n });\n\n if (feed === \"nearby\") {\n const userLocParts = userLocation.split(/[省市县区]/).filter(Boolean);\n result.sort((a, b) => {\n const score = (post) => {\n const loc = post.locationLabel || \"\";\n if (!loc || !userLocation) return 0;\n for (let i = 0; i < userLocParts.length; i++) {\n if (loc.includes(userLocParts[i])) return userLocParts.length - i;\n }\n return 0;\n };\n return score(b) - score(a);\n });\n const mixed = mixCirclePostsWithRealPriority(result);\n result.splice(0, result.length, ...mixed);\n } else if (feed !== \"following\") {\n const mixed = mixCirclePostsWithRealPriority(result);\n result.splice(0, result.length, ...mixed);\n }\n\n res.json(result);\n } catch (err) {\n console.error(\"[Circle] GET error:\", err.message);\n res.status(500).json({ error: \"获取搭配圈失败\" });\n }\n});\n\napp.get(\"/api/circle/search\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const { q } = req.query;\n if (!q || typeof q !== \"string\" || q.trim().length === 0) {\n return res.json([]);\n }\n try {\n const keyword = `%${q.trim()}%`;\n const posts = db.prepare(`\n SELECT p.*, u.nickname, u.avatar_url as author_avatar\n FROM circle_posts p\n JOIN users u ON p.user_id = u.id\n WHERE p.visibility = 'public'\n AND COALESCE(p.review_status, 'approved') = 'approved'\n AND (p.headline LIKE ? OR p.caption LIKE ? OR p.tags LIKE ? OR p.location_label LIKE ?)\n ORDER BY p.created_at DESC\n LIMIT 50\n `).all(keyword, keyword, keyword, keyword);\n\n const result = posts.map((post) => {\n let imageUris = [];\n try {\n const parsed = JSON.parse(post.image_urls || \"[]\");\n if (Array.isArray(parsed)) imageUris = parsed;\n } catch { /* ignore */ }\n return {\n id: post.id,\n author: post.nickname,\n authorAvatar: post.author_avatar,\n headline: post.headline,\n caption: post.caption,\n coverUri: imageUris[0] || post.cover_image_url || null,\n imageUris,\n imageAspectRatio: post.image_aspect_ratio,\n locationLabel: post.location_label,\n visibility: post.visibility || \"public\",\n reviewStatus: post.review_status || \"approved\",\n likes: post.likes_count || 0,\n isLiked: false,\n tags: JSON.parse(post.tags || \"[]\"),\n comments: [],\n commentCount: 0,\n createdAt: post.created_at,\n };\n });\n\n res.json(result);\n } catch (err) {\n console.error(\"[Circle] Search error:\", err.message);\n res.status(500).json({ error: \"搜索失败\" });\n }\n});\n\napp.get(\"/api/users/search\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const rawQuery = (req.query.query !== undefined ? req.query.query : req.query.q) || \"\";\n const query = ((rawQuery + \"\")).trim().toLowerCase();\n if (!query) {\n return res.json([]);\n }\n try {\n const followingIds = db.prepare(\"SELECT following_id FROM follows WHERE follower_id = ?\").all(userId).map((r) => r.following_id);\n const followingSet = new Set(followingIds);\n\n const users = db.prepare(`\n SELECT\n u.id,\n u.nickname as name,\n u.avatar_url,\n u.user_code,\n u.gender\n FROM users u\n WHERE u.id != ?\n AND (LOWER(u.nickname) LIKE ? OR u.user_code LIKE ?)\n ORDER BY\n (CASE WHEN u.user_code = ? THEN 1 ELSE 2 END),\n u.nickname\n LIMIT 50\n `).all(userId, `%${query}%`, `%${query}%`, query);\n\n const result = users.map((u) => ({\n id: u.id,\n name: u.name,\n handle: u.user_code || `@${u.id.slice(-8)}`,\n avatarUrl: u.avatar_url,\n isFollowed: followingSet.has(u.id),\n userCode: u.user_code,\n gender: u.gender,\n }));\n\n res.json(result);\n } catch (err) {\n console.error(\"[Users] Search error:\", err.message);\n res.status(500).json({ error: \"搜索用户失败\" });\n }\n});\n\napp.get(\"/api/search/history\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n try {\n const history = db.prepare(`\n SELECT * FROM search_history\n WHERE user_id = ?\n ORDER BY created_at DESC\n LIMIT 20\n `).all(userId);\n res.json(history.map((h) => ({ id: h.id, query: h.query, createdAt: h.created_at })));\n } catch (err) {\n console.error(\"[Search] History GET error:\", err.message);\n res.status(500).json({ error: \"获取搜索历史失败\" });\n }\n});\n\napp.post(\"/api/search/history\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const { query } = req.body;\n if (!query || typeof query !== \"string\" || query.trim().length === 0) {\n return res.status(400).json({ error: \"Query is required\" });\n }\n try {\n const trimmed = query.trim();\n const existing = db.prepare(\"SELECT id FROM search_history WHERE user_id = ? AND query = ?\").get(userId, trimmed);\n if (existing) {\n db.prepare(\"UPDATE search_history SET created_at = datetime('now') WHERE id = ?\").run(existing.id);\n return res.json({ id: existing.id, query: trimmed, updated: true });\n }\n const id = generateId();\n db.prepare(\"INSERT INTO search_history (id, user_id, query) VALUES (?, ?, ?)\").run(id, userId, trimmed);\n res.json({ id, query: trimmed, created: true });\n } catch (err) {\n console.error(\"[Search] History POST error:\", err.message);\n res.status(500).json({ error: \"保存搜索历史失败\" });\n }\n});\n\napp.delete(\"/api/search/history/:id\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n try {\n db.prepare(\"DELETE FROM search_history WHERE id = ? AND user_id = ?\").run(req.params.id, userId);\n res.json({ deleted: true });\n } catch (err) {\n console.error(\"[Search] History DELETE error:\", err.message);\n res.status(500).json({ error: \"删除失败\" });\n }\n});\n\napp.delete(\"/api/search/history\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n try {\n db.prepare(\"DELETE FROM search_history WHERE user_id = ?\").run(userId);\n res.json({ cleared: true });\n } catch (err) {\n console.error(\"[Search] History CLEAR error:\", err.message);\n res.status(500).json({ error: \"清空失败\" });\n }\n});\n\napp.post(\"/api/circle/upload\", authMiddleware, upload.single(\"photo\"), (req, res) => {\n if (!req.file) {\n return res.status(400).json({ error: \"No file uploaded\" });\n }\n const imageUrl = `/uploads/${req.file.filename}`;\n res.status(201).json({ url: imageUrl });\n});\n\napp.post(\"/api/circle\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const { id, headline, caption, coverImageUrl, imageUrls, imageAspectRatio, locationLabel, tags, visibility } = req.body;\n\n try {\n const postId = id || generateId();\n const parsedImageUrls = imageUrls || (coverImageUrl ? [coverImageUrl] : []);\n const coverImage = coverImageUrl || (parsedImageUrls.length > 0 ? parsedImageUrls[0] : null);\n const postVisibility = visibility || \"public\";\n const reviewStatus = postVisibility === \"public\" ? \"pending_review\" : \"approved\";\n const submittedAt = new Date().toISOString();\n\n db.prepare(`\n INSERT INTO circle_posts (\n id, user_id, headline, caption, cover_image_url, image_urls, image_aspect_ratio,\n location_label, tags, visibility, review_status, submitted_at, published_at\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n `).run(\n postId,\n userId,\n headline || \"\",\n caption || \"\",\n coverImage,\n JSON.stringify(parsedImageUrls),\n imageAspectRatio ?? null,\n locationLabel || \"\",\n JSON.stringify(tags || []),\n postVisibility,\n reviewStatus,\n submittedAt,\n reviewStatus === \"approved\" ? submittedAt : null\n );\n\n const post = db.prepare(\"SELECT * FROM circle_posts WHERE id = ?\").get(postId);\n const user = db.prepare(\"SELECT nickname, avatar_url FROM users WHERE id = ?\").get(userId);\n\n let resultImageUris = parsedImageUrls;\n if (resultImageUris.length === 0 && post.cover_image_url) {\n resultImageUris = [post.cover_image_url];\n }\n\n res.status(201).json({\n id: post.id,\n author: user.nickname,\n authorId: userId,\n authorAvatar: user.avatar_url,\n headline: post.headline,\n caption: post.caption,\n coverUri: resultImageUris[0] || post.cover_image_url || null,\n imageUris: resultImageUris,\n imageAspectRatio: post.image_aspect_ratio,\n locationLabel: post.location_label,\n visibility: post.visibility || \"public\",\n reviewStatus: post.review_status || reviewStatus,\n pendingReview: (post.review_status || reviewStatus) === \"pending_review\",\n likes: 0,\n isLiked: false,\n isFollowed: false,\n tags: JSON.parse(post.tags || \"[]\"),\n createdAt: post.created_at,\n comments: [],\n });\n } catch (err) {\n console.error(\"[Circle] POST error:\", err.message);\n res.status(500).json({ error: \"发布搭配失败\" });\n }\n});\n\nfunction mapAdminPost(row) {\n let imageUris = [];\n try {\n const parsed = JSON.parse(row.image_urls || \"[]\");\n if (Array.isArray(parsed)) imageUris = parsed;\n } catch { /* ignore */ }\n if (imageUris.length === 0 && row.cover_image_url) imageUris = [row.cover_image_url];\n return {\n id: row.id,\n authorId: row.user_id,\n author: row.nickname || \"\",\n authorEmail: row.author_email || \"\",\n authorAvatar: row.author_avatar || null,\n headline: row.headline || \"\",\n caption: row.caption || \"\",\n coverUri: imageUris[0] || row.cover_image_url || null,\n imageUris,\n imageAspectRatio: row.image_aspect_ratio || 1,\n locationLabel: row.location_label || \"\",\n tags: (() => {\n try { return JSON.parse(row.tags || \"[]\"); } catch { return []; }\n })(),\n visibility: row.visibility || \"public\",\n reviewStatus: row.review_status || \"pending_review\",\n rejectionReason: row.rejection_reason || \"\",\n submittedAt: row.submitted_at || row.created_at,\n publishedAt: row.published_at || null,\n reviewedAt: row.reviewed_at || null,\n reviewedBy: row.reviewed_by || \"\",\n createdAt: row.created_at,\n likes: row.likes_count || 0,\n };\n}\n\nfunction insertPostReviewLog(postId, adminId, action, reason) {\n db.prepare(`\n INSERT INTO post_review_logs (id, post_id, admin_id, action, reason)\n VALUES (?, ?, ?, ?, ?)\n `).run(generateId(), postId, adminId || \"admin\", action, reason || \"\");\n}\n\napp.get(\"/api/admin/circle/posts\", adminReviewMiddleware, (req, res) => {\n const status = normalizeReviewStatus(req.query.status || \"pending_review\");\n try {\n const rows = db.prepare(`\n SELECT p.*, u.nickname, u.email as author_email, u.avatar_url as author_avatar\n FROM circle_posts p\n JOIN users u ON p.user_id = u.id\n WHERE COALESCE(p.review_status, 'approved') = ?\n ORDER BY COALESCE(p.submitted_at, p.created_at) DESC\n LIMIT 200\n `).all(status);\n res.json({ posts: rows.map(mapAdminPost) });\n } catch (err) {\n console.error(\"[AdminReview] List error:\", err.message);\n res.status(500).json({ error: \"Failed to load review posts\" });\n }\n});\n\napp.get(\"/api/admin/circle/posts/:id\", adminReviewMiddleware, (req, res) => {\n try {\n const row = db.prepare(`\n SELECT p.*, u.nickname, u.email as author_email, u.avatar_url as author_avatar\n FROM circle_posts p\n JOIN users u ON p.user_id = u.id\n WHERE p.id = ?\n `).get(req.params.id);\n if (!row) return res.status(404).json({ error: \"Post not found\" });\n const logs = db.prepare(`\n SELECT * FROM post_review_logs\n WHERE post_id = ?\n ORDER BY created_at DESC\n `).all(req.params.id);\n res.json({ post: mapAdminPost(row), logs });\n } catch (err) {\n console.error(\"[AdminReview] Detail error:\", err.message);\n res.status(500).json({ error: \"Failed to load review post\" });\n }\n});\n\napp.post(\"/api/admin/circle/posts/:id/review\", adminReviewMiddleware, (req, res) => {\n const { action, reason, adminId } = req.body || {};\n const postId = req.params.id;\n const now = new Date().toISOString();\n const normalizedAction = String(action || \"\").trim();\n\n if (![\"approve\", \"reject\", \"hide\", \"reopen\"].includes(normalizedAction)) {\n return res.status(400).json({ error: \"Invalid review action\" });\n }\n if (normalizedAction === \"reject\" && !String(reason || \"\").trim()) {\n return res.status(400).json({ error: \"Reject reason is required\" });\n }\n\n try {\n const post = db.prepare(\"SELECT * FROM circle_posts WHERE id = ?\").get(postId);\n if (!post) return res.status(404).json({ error: \"Post not found\" });\n\n const nextStatus =\n normalizedAction === \"approve\" ? \"approved\" :\n normalizedAction === \"reject\" ? \"rejected\" :\n normalizedAction === \"reopen\" ? \"pending_review\" :\n \"hidden\";\n\n if ((post.review_status || \"approved\") === nextStatus) {\n const current = db.prepare(`\n SELECT p.*, u.nickname, u.email as author_email, u.avatar_url as author_avatar\n FROM circle_posts p\n JOIN users u ON p.user_id = u.id\n WHERE p.id = ?\n `).get(postId);\n return res.json({ post: mapAdminPost(current), unchanged: true });\n }\n\n db.prepare(`\n UPDATE circle_posts\n SET review_status = ?,\n reviewed_by = ?,\n reviewed_at = ?,\n rejection_reason = ?,\n published_at = CASE WHEN ? = 'approved' THEN COALESCE(published_at, ?) ELSE published_at END\n WHERE id = ?\n `).run(\n nextStatus,\n adminId || \"admin\",\n now,\n normalizedAction === \"reject\" ? String(reason || \"\").trim() : \"\",\n nextStatus,\n now,\n postId\n );\n\n insertPostReviewLog(postId, adminId || \"admin\", normalizedAction, reason || \"\");\n const updated = db.prepare(`\n SELECT p.*, u.nickname, u.email as author_email, u.avatar_url as author_avatar\n FROM circle_posts p\n JOIN users u ON p.user_id = u.id\n WHERE p.id = ?\n `).get(postId);\n res.json({ post: mapAdminPost(updated) });\n } catch (err) {\n console.error(\"[AdminReview] Review error:\", err.message);\n res.status(500).json({ error: \"Failed to review post\" });\n }\n});\n\napp.post(\"/api/circle/:id/like\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const postId = req.params.id;\n\n try {\n const post = db.prepare(\"SELECT * FROM circle_posts WHERE id = ?\").get(postId);\n if (!post) return res.status(404).json({ error: \"Post not found\" });\n if ((post.review_status || \"approved\") !== \"approved\" || (post.visibility || \"public\") !== \"public\") {\n return res.status(403).json({ error: \"Post is not public\" });\n }\n\n const existingLike = db.prepare(\"SELECT 1 FROM circle_likes WHERE post_id = ? AND user_id = ?\").get(postId, userId);\n\n if (existingLike) {\n db.prepare(\"DELETE FROM circle_likes WHERE post_id = ? AND user_id = ?\").run(postId, userId);\n db.prepare(\"UPDATE circle_posts SET likes_count = likes_count - 1 WHERE id = ?\").run(postId);\n const updatedPost = db.prepare(\"SELECT likes_count FROM circle_posts WHERE id = ?\").get(postId);\n res.json({ liked: false, likesCount: updatedPost.likes_count });\n } else {\n db.prepare(\"INSERT INTO circle_likes (post_id, user_id) VALUES (?, ?)\").run(postId, userId);\n db.prepare(\"UPDATE circle_posts SET likes_count = likes_count + 1 WHERE id = ?\").run(postId);\n const updatedPost = db.prepare(\"SELECT likes_count FROM circle_posts WHERE id = ?\").get(postId);\n const postAuthor = db.prepare(\"SELECT user_id FROM circle_posts WHERE id = ?\").get(postId);\n if (postAuthor && postAuthor.user_id !== userId) {\n const actor = db.prepare(\"SELECT nickname FROM users WHERE id = ?\").get(userId);\n createNotification({\n userId: postAuthor.user_id,\n type: \"post_like\",\n actorId: userId,\n postId,\n content: `${actor.nickname} 赞了你的帖子`,\n });\n }\n res.json({ liked: true, likesCount: updatedPost.likes_count });\n }\n } catch (err) {\n console.error(\"[Circle] Like error:\", err.message);\n res.status(500).json({ error: \"点赞失败\" });\n }\n});\n\napp.post(\"/api/circle/:id/comment\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const postId = req.params.id;\n const { text, parentId } = req.body;\n\n if (!text || !text.trim()) {\n return res.status(400).json({ error: \"评论内容不能为空\" });\n }\n\n try {\n const post = db.prepare(\"SELECT * FROM circle_posts WHERE id = ?\").get(postId);\n if (!post) return res.status(404).json({ error: \"Post not found\" });\n if ((post.review_status || \"approved\") !== \"approved\" || (post.visibility || \"public\") !== \"public\") {\n return res.status(403).json({ error: \"Post is not public\" });\n }\n\n const commentId = generateId();\n db.prepare(\"INSERT INTO circle_comments (id, post_id, user_id, text, parent_id) VALUES (?, ?, ?, ?, ?)\").run(\n commentId, postId, userId, text.trim(), parentId || null\n );\n\n const user = db.prepare(\"SELECT nickname, avatar_url FROM users WHERE id = ?\").get(userId);\n\n if (parentId) {\n const parentComment = db.prepare(\"SELECT user_id FROM circle_comments WHERE id = ?\").get(parentId);\n if (parentComment && parentComment.user_id !== userId) {\n createNotification({\n userId: parentComment.user_id,\n type: \"reply\",\n actorId: userId,\n postId,\n commentId: commentId,\n content: `${user.nickname} 回复了你的评论`,\n });\n }\n }\n\n let replyToUser = null;\n if (parentId) {\n const parentComment = db.prepare(`\n SELECT u.nickname FROM circle_comments c\n JOIN users u ON c.user_id = u.id\n WHERE c.id = ?\n `).get(parentId);\n if (parentComment) replyToUser = parentComment.nickname;\n }\n\n res.status(201).json({\n id: commentId,\n user: user.nickname,\n userAvatar: user.avatar_url,\n text: text.trim(),\n replyToUser,\n parentId: parentId || null,\n createdAt: new Date().toISOString(),\n });\n } catch (err) {\n console.error(\"[Circle] Comment error:\", err.message);\n res.status(500).json({ error: \"评论失败\" });\n }\n});\n\napp.post(\"/api/circle/:postId/comment/:commentId/like\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const { postId, commentId } = req.params;\n\n try {\n const comment = db.prepare(\"SELECT * FROM circle_comments WHERE id = ? AND post_id = ?\").get(commentId, postId);\n if (!comment) return res.status(404).json({ error: \"Comment not found\" });\n\n const existing = db.prepare(\"SELECT 1 FROM circle_comment_likes WHERE comment_id = ? AND user_id = ?\").get(commentId, userId);\n\n if (existing) {\n db.prepare(\"DELETE FROM circle_comment_likes WHERE comment_id = ? AND user_id = ?\").run(commentId, userId);\n db.prepare(\"UPDATE circle_comments SET likes_count = CASE WHEN COALESCE(likes_count, 0) > 0 THEN COALESCE(likes_count, 0) - 1 ELSE 0 END WHERE id = ?\").run(commentId);\n } else {\n db.prepare(\"INSERT INTO circle_comment_likes (comment_id, user_id) VALUES (?, ?)\").run(commentId, userId);\n db.prepare(\"UPDATE circle_comments SET likes_count = likes_count + 1 WHERE id = ?\").run(commentId);\n const commentAuthor = db.prepare(\"SELECT user_id FROM circle_comments WHERE id = ?\").get(commentId);\n if (commentAuthor && commentAuthor.user_id !== userId) {\n const actor = db.prepare(\"SELECT nickname FROM users WHERE id = ?\").get(userId);\n createNotification({\n userId: commentAuthor.user_id,\n type: \"comment_like\",\n actorId: userId,\n postId,\n commentId,\n content: `${actor.nickname} 赞了你的评论`,\n });\n }\n }\n\n const updated = db.prepare(\"SELECT likes_count FROM circle_comments WHERE id = ?\").get(commentId);\n const isLiked = !!db.prepare(\"SELECT 1 FROM circle_comment_likes WHERE comment_id = ? AND user_id = ?\").get(commentId, userId);\n\n res.json({ likesCount: updated.likes_count || 0, liked: isLiked });\n } catch (err) {\n console.error(\"[Circle] Comment like error:\", err.message);\n res.status(500).json({ error: \"操作失败\" });\n }\n});\n\napp.delete(\"/api/circle/:postId/comment/:commentId\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const { postId, commentId } = req.params;\n\n try {\n const comment = db.prepare(\"SELECT * FROM circle_comments WHERE id = ? AND post_id = ? AND user_id = ?\").get(commentId, postId, userId);\n if (!comment) return res.status(404).json({ error: \"Comment not found\" });\n\n db.prepare(\"DELETE FROM circle_comment_likes WHERE comment_id = ?\").run(commentId);\n db.prepare(\"DELETE FROM circle_comments WHERE parent_id = ?\").run(commentId);\n db.prepare(\"DELETE FROM circle_comments WHERE id = ?\").run(commentId);\n\n res.json({ id: commentId, deleted: true });\n } catch (err) {\n console.error(\"[Circle] Comment DELETE error:\", err.message);\n res.status(500).json({ error: \"删除评论失败\" });\n }\n});\n\napp.delete(\"/api/circle/:id\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const postId = req.params.id;\n\n try {\n const post = db.prepare(\"SELECT * FROM circle_posts WHERE id = ? AND user_id = ?\").get(postId, userId);\n if (!post) return res.status(404).json({ error: \"Post not found\" });\n\n db.prepare(\"DELETE FROM circle_comments WHERE post_id = ?\").run(postId);\n db.prepare(\"DELETE FROM circle_likes WHERE post_id = ?\").run(postId);\n db.prepare(\"DELETE FROM circle_posts WHERE id = ?\").run(postId);\n\n res.json({ id: postId, deleted: true });\n } catch (err) {\n console.error(\"[Circle] DELETE error:\", err.message);\n res.status(500).json({ error: \"删除搭配失败\" });\n }\n});\n\napp.get(\"/api/conversations\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n try {\n const conversations = db.prepare(`\n SELECT c.*, u.nickname as other_nickname, u.avatar_url as other_avatar\n FROM conversations c\n LEFT JOIN users u ON (c.user_id_2 = u.id AND c.user_id_1 = ?)\n OR (c.user_id_1 = u.id AND c.user_id_2 = ?)\n WHERE c.user_id_1 = ? OR c.user_id_2 = ?\n ORDER BY c.is_pinned DESC, c.updated_at DESC\n `).all(userId, userId, userId, userId);\n\n const result = conversations.map((conv) => {\n const lastMsg = db.prepare(`\n SELECT * FROM conversation_messages WHERE conversation_id = ?\n ORDER BY created_at DESC LIMIT 1\n `).get(conv.id);\n\n const unreadRow = db.prepare(`\n SELECT COUNT(*) as cnt FROM conversation_messages\n WHERE conversation_id = ? AND sender_id != ? AND is_read = 0\n `).get(conv.id, userId);\n\n let memberAvatars = [];\n if (conv.conversation_type === 'group') {\n const members = db.prepare(`\n SELECT u.avatar_url, u.nickname FROM conversation_participants p\n JOIN users u ON p.user_id = u.id\n WHERE p.conversation_id = ?\n LIMIT 4\n `).all(conv.id);\n memberAvatars = members.map((m) => m.avatar_url || m.nickname || '');\n }\n\n const otherId = conv.user_id_2 && conv.user_id_2 !== userId ? conv.user_id_2 : conv.user_id_1;\n\n return {\n id: conv.id,\n name: conv.conversation_type === 'private' ? (conv.other_nickname || '未知用户') : (conv.group_name || '群聊'),\n avatarUrl: conv.avatar_url || (conv.conversation_type === 'private' ? conv.other_avatar : null),\n kind: conv.conversation_type,\n snippet: lastMsg ? lastMsg.text : '',\n lastMessageTime: lastMsg ? lastMsg.created_at : conv.updated_at,\n lastMessageType: lastMsg ? (lastMsg.message_type || 'text') : 'text',\n isPinned: !!conv.is_pinned,\n isOfficial: !!conv.is_official,\n isStarred: !!conv.is_starred,\n unreadCount: unreadRow.cnt || 0,\n memberAvatars,\n otherUserId: conv.conversation_type === 'private' ? otherId : undefined,\n };\n });\n\n res.json(result);\n } catch (err) {\n console.error(\"[Conversations] GET error:\", err.message);\n res.status(500).json({ error: \"获取会话列表失败\" });\n }\n});\n\napp.post(\"/api/conversations/:id/pin\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const { isPinned } = req.body;\n try {\n const conv = db.prepare(\"SELECT * FROM conversations WHERE id = ? AND (user_id_1 = ? OR user_id_2 = ?)\").get(req.params.id, userId, userId);\n if (!conv) return res.status(404).json({ error: \"Conversation not found\" });\n db.prepare(\"UPDATE conversations SET is_pinned = ? WHERE id = ?\").run(isPinned ? 1 : 0, req.params.id);\n res.json({ success: true, isPinned: !!isPinned });\n } catch (err) {\n console.error(\"[Conversations] PIN error:\", err.message);\n res.status(500).json({ error: \"置顶操作失败\" });\n }\n});\n\napp.post(\"/api/conversations/:id/star\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const { isStarred } = req.body;\n try {\n const conv = db.prepare(\"SELECT * FROM conversations WHERE id = ? AND (user_id_1 = ? OR user_id_2 = ?)\").get(req.params.id, userId, userId);\n if (!conv) return res.status(404).json({ error: \"Conversation not found\" });\n db.prepare(\"UPDATE conversations SET is_starred = ? WHERE id = ?\").run(isStarred ? 1 : 0, req.params.id);\n res.json({ success: true, isStarred: !!isStarred });\n } catch (err) {\n console.error(\"[Conversations] STAR error:\", err.message);\n res.status(500).json({ error: \"星标操作失败\" });\n }\n});\n\napp.post(\"/api/conversations/:id/read\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n try {\n const conv = db.prepare(\"SELECT * FROM conversations WHERE id = ? AND (user_id_1 = ? OR user_id_2 = ?)\").get(req.params.id, userId, userId);\n if (!conv) return res.status(404).json({ error: \"Conversation not found\" });\n db.prepare(\"UPDATE conversation_messages SET is_read = 1 WHERE conversation_id = ? AND sender_id != ?\").run(req.params.id, userId);\n res.json({ success: true });\n } catch (err) {\n console.error(\"[Conversations] READ error:\", err.message);\n res.status(500).json({ error: \"标记已读失败\" });\n }\n});\n\napp.delete(\"/api/conversations/:id\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n try {\n const conv = db.prepare(\"SELECT * FROM conversations WHERE id = ? AND (user_id_1 = ? OR user_id_2 = ?)\").get(req.params.id, userId, userId);\n if (!conv) return res.status(404).json({ error: \"Conversation not found\" });\n db.prepare(\"DELETE FROM conversations WHERE id = ?\").run(req.params.id);\n res.json({ success: true });\n } catch (err) {\n console.error(\"[Conversations] DELETE error:\", err.message);\n res.status(500).json({ error: \"删除会话失败\" });\n }\n});\n\napp.get(\"/api/groups/recommend\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n try {\n const groups = db.prepare(`\n SELECT g.*, \n (SELECT 1 FROM group_members gm WHERE gm.group_id = g.id AND gm.user_id = ?) AS is_joined\n FROM groups g\n ORDER BY g.member_count DESC\n LIMIT 20\n `).all(userId);\n res.json(groups.map(g => ({\n id: g.id,\n name: g.name,\n description: g.description,\n avatarUrl: g.avatar_url,\n memberCount: g.member_count,\n isOfficial: !!g.is_official,\n isJoined: !!g.is_joined,\n })));\n } catch (err) {\n console.error(\"[Groups] Recommend error:\", err.message);\n res.status(500).json({ error: \"获取推荐群失败\" });\n }\n});\n\napp.get(\"/api/groups/search\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const { q } = req.query;\n try {\n const keyword = `%${q || ''}%`;\n const groups = db.prepare(`\n SELECT g.*, \n (SELECT 1 FROM group_members gm WHERE gm.group_id = g.id AND gm.user_id = ?) AS is_joined\n FROM groups g\n WHERE g.name LIKE ? OR g.description LIKE ?\n ORDER BY g.member_count DESC\n LIMIT 20\n `).all(userId, keyword, keyword);\n res.json(groups.map(g => ({\n id: g.id,\n name: g.name,\n description: g.description,\n avatarUrl: g.avatar_url,\n memberCount: g.member_count,\n isOfficial: !!g.is_official,\n isJoined: !!g.is_joined,\n })));\n } catch (err) {\n console.error(\"[Groups] Search error:\", err.message);\n res.status(500).json({ error: \"搜索群聊失败\" });\n }\n});\n\napp.post(\"/api/groups/:id/join\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const groupId = req.params.id;\n try {\n const group = db.prepare(\"SELECT * FROM groups WHERE id = ?\").get(groupId);\n if (!group) return res.status(404).json({ error: \"Group not found\" });\n \n const existing = db.prepare(\"SELECT * FROM group_members WHERE group_id = ? AND user_id = ?\").get(groupId, userId);\n if (existing) return res.json({ success: true, isJoined: true });\n \n db.prepare(\"INSERT INTO group_members (group_id, user_id) VALUES (?, ?)\").run(groupId, userId);\n db.prepare(\"UPDATE groups SET member_count = member_count + 1 WHERE id = ?\").run(groupId);\n res.json({ success: true, isJoined: true });\n } catch (err) {\n console.error(\"[Groups] Join error:\", err.message);\n res.status(500).json({ error: \"加入群聊失败\" });\n }\n});\n\napp.post(\"/api/groups/:id/leave\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const groupId = req.params.id;\n try {\n db.prepare(\"DELETE FROM group_members WHERE group_id = ? AND user_id = ?\").run(groupId, userId);\n db.prepare(\"UPDATE groups SET member_count = member_count - 1 WHERE id = ?\").run(groupId);\n res.json({ success: true });\n } catch (err) {\n console.error(\"[Groups] Leave error:\", err.message);\n res.status(500).json({ error: \"退出群聊失败\" });\n }\n});\n\napp.post(\"/api/groups\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const { name, memberIds = [] } = req.body;\n if (!name || !name.trim()) return res.status(400).json({ error: \"群名称不能为空\" });\n try {\n const groupId = generateId();\n const allMembers = [userId, ...memberIds.filter((id) => id !== userId)];\n db.prepare(\"INSERT INTO groups (id, name, member_count) VALUES (?, ?, ?)\").run(\n groupId,\n name.trim(),\n allMembers.length\n );\n const insertMember = db.prepare(\"INSERT INTO group_members (group_id, user_id) VALUES (?, ?)\");\n for (const memberId of allMembers) {\n insertMember.run(groupId, memberId);\n }\n res.json({ id: groupId, name: name.trim(), memberCount: allMembers.length });\n } catch (err) {\n console.error(\"[Groups] Create error:\", err.message);\n res.status(500).json({ error: \"创建群聊失败\" });\n }\n});\n\napp.get(\"/api/sparks\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n try {\n const sparks = db.prepare(`\n SELECT sr.*, u.nickname as friend_name, u.avatar_url as friend_avatar\n FROM spark_relationships sr\n LEFT JOIN users u ON (u.id = sr.user_id_1 OR u.id = sr.user_id_2)\n WHERE sr.user_id_1 = ? OR sr.user_id_2 = ?\n ORDER BY sr.consecutive_days DESC\n `).all(userId, userId);\n \n res.json(sparks.map(s => ({\n friendId: s.user_id_1 === userId ? s.user_id_2 : s.user_id_1,\n friendName: s.friend_name,\n friendAvatar: s.friend_avatar,\n consecutiveDays: s.consecutive_days || 0,\n hasSpark: (s.consecutive_days || 0) >= 1,\n sparkLevel: Math.min(3, Math.floor((s.consecutive_days || 0) / 30)),\n })));\n } catch (err) {\n console.error(\"[Sparks] GET error:\", err.message);\n res.status(500).json({ error: \"获取火花关系失败\" });\n }\n});\n\napp.post(\"/api/sparks/check\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const { friendId } = req.body;\n if (!friendId) return res.status(400).json({ error: \"friendId required\" });\n \n try {\n const now = new Date().toISOString().slice(0, 10);\n let spark = db.prepare(\"SELECT * FROM spark_relationships WHERE (user_id_1 = ? AND user_id_2 = ?) OR (user_id_1 = ? AND user_id_2 = ?)\").get(userId, friendId, friendId, userId);\n \n if (!spark) {\n const id = generateId();\n db.prepare(\"INSERT INTO spark_relationships (id, user_id_1, user_id_2, consecutive_days, last_message_date) VALUES (?, ?, ?, 1, ?)\").run(id, userId, friendId, now);\n return res.json({ consecutiveDays: 1 });\n }\n \n if (!spark.last_message_date) {\n db.prepare(\"UPDATE spark_relationships SET consecutive_days = 1, last_message_date = ? WHERE id = ?\").run(now, spark.id);\n return res.json({ consecutiveDays: 1 });\n }\n \n const lastDate = spark.last_message_date.slice(0, 10);\n const daysDiff = Math.floor((new Date(now) - new Date(lastDate)) / (1000 * 60 * 60 * 24));\n \n if (daysDiff === 0) {\n return res.json({ consecutiveDays: spark.consecutive_days });\n } else if (daysDiff === 1) {\n const newDays = (spark.consecutive_days || 0) + 1;\n db.prepare(\"UPDATE spark_relationships SET consecutive_days = ?, last_message_date = ? WHERE id = ?\").run(newDays, now, spark.id);\n return res.json({ consecutiveDays: newDays });\n } else {\n db.prepare(\"UPDATE spark_relationships SET consecutive_days = 1, last_message_date = ? WHERE id = ?\").run(now, spark.id);\n return res.json({ consecutiveDays: 1 });\n }\n } catch (err) {\n console.error(\"[Sparks] Check error:\", err.message);\n res.status(500).json({ error: \"更新火花失败\" });\n }\n});\n\napp.get(\"/api/conversations/:id\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const convId = req.params.id;\n\n try {\n const conv = db.prepare(\"SELECT * FROM conversations WHERE id = ? AND (user_id_1 = ? OR user_id_2 = ?)\").get(convId, userId, userId);\n if (!conv) return res.status(404).json({ error: \"Conversation not found\" });\n\n const messages = db.prepare(`\n SELECT m.*, u.nickname\n FROM conversation_messages m\n JOIN users u ON m.sender_id = u.id\n WHERE m.conversation_id = ?\n ORDER BY m.created_at ASC\n `).all(convId);\n\n const otherUser = conv.user_id_2 && conv.user_id_2 !== userId\n ? db.prepare(\"SELECT nickname, avatar_url FROM users WHERE id = ?\").get(conv.user_id_2)\n : db.prepare(\"SELECT nickname, avatar_url FROM users WHERE id = ?\").get(conv.user_id_1);\n\n res.json({\n id: conv.id,\n name: conv.conversation_type === 'private' ? (otherUser?.nickname || '未知用户') : (conv.group_name || '群聊'),\n avatar: conv.conversation_type === 'private' ? otherUser?.avatar_url : null,\n kind: conv.conversation_type,\n messages: messages.map((m) => ({\n id: m.id,\n sender: m.sender_id === userId ? 'me' : 'other',\n text: m.text,\n time: m.created_at,\n senderName: m.nickname,\n })),\n });\n } catch (err) {\n console.error(\"[Conversations] GET messages error:\", err.message);\n res.status(500).json({ error: \"获取消息失败\" });\n }\n});\n\napp.post(\"/api/conversations\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const { friendId, groupName } = req.body;\n\n try {\n if (friendId) {\n const existing = db.prepare(`\n SELECT * FROM conversations WHERE\n (user_id_1 = ? AND user_id_2 = ?) OR (user_id_1 = ? AND user_id_2 = ?)\n `).get(userId, friendId, friendId, userId);\n\n if (existing) {\n return res.json({ id: existing.id, conversationType: existing.conversation_type });\n }\n\n const convId = generateId();\n db.prepare(`\n INSERT INTO conversations (id, user_id_1, user_id_2, conversation_type)\n VALUES (?, ?, ?, 'private')\n `).run(convId, userId, friendId);\n\n res.status(201).json({ id: convId, conversationType: 'private' });\n } else if (groupName) {\n const convId = generateId();\n db.prepare(`\n INSERT INTO conversations (id, user_id_1, conversation_type, group_name)\n VALUES (?, ?, 'group', ?)\n `).run(convId, userId, groupName);\n\n res.status(201).json({ id: convId, conversationType: 'group' });\n } else {\n res.status(400).json({ error: \"friendId or groupName required\" });\n }\n } catch (err) {\n console.error(\"[Conversations] POST error:\", err.message);\n res.status(500).json({ error: \"创建会话失败\" });\n }\n});\n\napp.post(\"/api/conversations/:id/messages\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const convId = req.params.id;\n const { text } = req.body;\n\n if (!text || !text.trim()) {\n return res.status(400).json({ error: \"消息内容不能为空\" });\n }\n\n try {\n const conv = db.prepare(\"SELECT * FROM conversations WHERE id = ?\").get(convId);\n if (!conv) return res.status(404).json({ error: \"Conversation not found\" });\n\n const msgId = generateId();\n const now = new Date().toISOString();\n db.prepare(`\n INSERT INTO conversation_messages (id, conversation_id, sender_id, text, created_at)\n VALUES (?, ?, ?, ?, ?)\n `).run(msgId, convId, userId, text.trim(), now);\n\n db.prepare(\"UPDATE conversations SET last_snippet = ?, updated_at = ? WHERE id = ?\").run(text.trim(), now, convId);\n\n const user = db.prepare(\"SELECT nickname FROM users WHERE id = ?\").get(userId);\n\n res.status(201).json({\n id: msgId,\n sender: 'me',\n text: text.trim(),\n time: now,\n senderName: user.nickname,\n });\n } catch (err) {\n console.error(\"[Conversations] Message error:\", err.message);\n res.status(500).json({ error: \"发送消息失败\" });\n }\n});\n\napp.get(\"/api/friends\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n try {\n const friends = db.prepare(`\n SELECT f.*, u.nickname, u.avatar_url, u.online_status, u.last_seen_at, up.bio, up.location, up.school\n FROM friendships f\n JOIN users u ON f.friend_id = u.id\n LEFT JOIN user_profiles up ON f.friend_id = up.user_id\n WHERE f.user_id = ? AND f.status = 'friend'\n `).all(userId);\n\n const result = friends.map((f) => ({\n id: f.friend_id,\n name: f.nickname,\n avatarUrl: f.avatar_url,\n handle: `@${f.friend_id.slice(-8)}`,\n status: 'friend',\n bio: f.bio || '',\n location: f.location || '',\n school: f.school || '',\n onlineStatus: f.online_status === \"invisible\" ? \"offline\" : (f.online_status || \"offline\"),\n lastSeenAt: f.last_seen_at,\n }));\n\n res.json(result);\n } catch (err) {\n console.error(\"[Friends] GET error:\", err.message);\n res.status(500).json({ error: \"获取好友列表失败\" });\n }\n});\n\napp.post(\"/api/friends/request\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const { friendId } = req.body;\n\n if (!friendId) {\n return res.status(400).json({ error: \"friendId required\" });\n }\n\n try {\n const existing = db.prepare(`\n SELECT * FROM friendships WHERE (user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)\n `).get(userId, friendId, friendId, userId);\n\n if (existing) {\n if (existing.status === 'friend') {\n return res.status(409).json({ error: \"已经是好友\" });\n }\n if (existing.user_id === friendId && existing.friend_id === userId) {\n db.prepare(\"UPDATE friendships SET status = 'friend' WHERE user_id = ? AND friend_id = ?\").run(friendId, userId);\n const user = db.prepare(\"SELECT nickname FROM users WHERE id = ?\").get(userId);\n return res.json({ status: 'friend', friendName: user.nickname });\n }\n }\n\n db.prepare(\"INSERT INTO friendships (user_id, friend_id, status) VALUES (?, ?, 'pending')\").run(userId, friendId);\n res.status(201).json({ status: 'pending' });\n } catch (err) {\n console.error(\"[Friends] Request error:\", err.message);\n res.status(500).json({ error: \"发送好友请求失败\" });\n }\n});\n\napp.delete(\"/api/friends/:friendId\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const friendId = req.params.friendId;\n\n try {\n db.prepare(\"DELETE FROM friendships WHERE (user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)\").run(userId, friendId, friendId, userId);\n res.json({ deleted: true });\n } catch (err) {\n console.error(\"[Friends] DELETE error:\", err.message);\n res.status(500).json({ error: \"删除好友失败\" });\n }\n});\n\napp.get(\"/api/users/recommend\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n try {\n const followRows = db.prepare(\"SELECT following_id FROM follows WHERE follower_id = ?\").all(userId);\n const followingSet = new Set(followRows.map((r) => r.following_id));\n\n const users = db.prepare(`\n SELECT id, nickname, avatar_url, user_code FROM users\n WHERE id != ?\n ORDER BY RANDOM()\n LIMIT 10\n `).all(userId);\n\n const result = users.map((u) => {\n const mutualCount = db.prepare(`\n SELECT COUNT(*) as count FROM follows\n WHERE follower_id = ? AND following_id IN (\n SELECT following_id FROM follows WHERE follower_id = ?\n )\n `).get(u.id, userId).count;\n\n return {\n id: u.id,\n name: u.nickname,\n avatarUrl: u.avatar_url,\n handle: u.user_code || `@${u.id.slice(-8)}`,\n isFollowed: followingSet.has(u.id),\n reason: mutualCount > 0 ? `${mutualCount}个共同互关的人` : \"可能认识的人\",\n mutualFollows: mutualCount,\n };\n });\n\n res.json(result);\n } catch (err) {\n console.error(\"[Users] Recommend error:\", err.message);\n res.status(500).json({ error: \"获取推荐用户失败\" });\n }\n});\n\napp.get(\"/api/settings\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n try {\n let settings = db.prepare(\"SELECT * FROM app_settings WHERE user_id = ?\").get(userId);\n if (!settings) {\n db.prepare(\"INSERT INTO app_settings (user_id) VALUES (?)\").run(userId);\n settings = db.prepare(\"SELECT * FROM app_settings WHERE user_id = ?\").get(userId);\n }\n\n res.json({\n themeMode: settings.theme_mode,\n recommendGenerateHour: settings.recommend_generate_hour,\n lastRecommendDate: settings.last_recommend_date,\n selectedCityId: settings.selected_city_id,\n coordinates: settings.coordinates_lat ? {\n latitude: settings.coordinates_lat,\n longitude: settings.coordinates_lng,\n } : null,\n });\n } catch (err) {\n console.error(\"[Settings] GET error:\", err.message);\n res.status(500).json({ error: \"获取设置失败\" });\n }\n});\n\napp.put(\"/api/settings\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const { themeMode, recommendGenerateHour, lastRecommendDate, selectedCityId, coordinates } = req.body;\n\n try {\n let settings = db.prepare(\"SELECT * FROM app_settings WHERE user_id = ?\").get(userId);\n if (!settings) {\n db.prepare(\"INSERT INTO app_settings (user_id) VALUES (?)\").run(userId);\n }\n\n const updates = [];\n const values = [];\n if (themeMode !== undefined) { updates.push(\"theme_mode = ?\"); values.push(themeMode); }\n if (recommendGenerateHour !== undefined) { updates.push(\"recommend_generate_hour = ?\"); values.push(recommendGenerateHour); }\n if (lastRecommendDate !== undefined) { updates.push(\"last_recommend_date = ?\"); values.push(lastRecommendDate); }\n if (selectedCityId !== undefined) { updates.push(\"selected_city_id = ?\"); values.push(selectedCityId); }\n if (coordinates !== undefined) {\n updates.push(\"coordinates_lat = ?\"); values.push(coordinates?.latitude ?? null);\n updates.push(\"coordinates_lng = ?\"); values.push(coordinates?.longitude ?? null);\n }\n\n if (updates.length > 0) {\n updates.push(\"updated_at = datetime('now')\");\n values.push(userId);\n db.prepare(`UPDATE app_settings SET ${updates.join(\", \")} WHERE user_id = ?`).run(...values);\n }\n\n const updated = db.prepare(\"SELECT * FROM app_settings WHERE user_id = ?\").get(userId);\n res.json({\n themeMode: updated.theme_mode,\n recommendGenerateHour: updated.recommend_generate_hour,\n lastRecommendDate: updated.last_recommend_date,\n selectedCityId: updated.selected_city_id,\n coordinates: updated.coordinates_lat ? {\n latitude: updated.coordinates_lat,\n longitude: updated.coordinates_lng,\n } : null,\n });\n } catch (err) {\n console.error(\"[Settings] PUT error:\", err.message);\n res.status(500).json({ error: \"更新设置失败\" });\n }\n});\n\nfunction parseJsonObjectFromText(text) {\n if (text && typeof text === \"object\") return text;\n const raw = String(text || \"\").trim().replace(/^```(?:json)?\\s*/i, \"\").replace(/\\s*```$/, \"\");\n const first = raw.indexOf(\"{\");\n const last = raw.lastIndexOf(\"}\");\n return JSON.parse(first >= 0 && last > first ? raw.slice(first, last + 1) : raw);\n}\n\nfunction normalizeRecommendCategory(value) {\n const raw = String(value || \"\").trim().toLowerCase();\n if (!raw) return \"\";\n if (/top|上装|外套|卫衣|衬衫|t恤|毛衣|夹克|大衣|针织|背心|马甲/.test(raw)) return \"top\";\n if (/bottom|下装|裤|裙|牛仔|短裤|长裤|半裙|阔腿裤/.test(raw)) return \"bottom\";\n if (/dress|连衣|单体|套装/.test(raw)) return \"dress\";\n return raw;\n}\n\nfunction recommendationCategoryMatches(item, requestedCategory) {\n const requested = normalizeRecommendCategory(requestedCategory);\n if (!requested || requested === \"full\") return true;\n const itemCategory = normalizeRecommendCategory(item.category);\n const analysisCategory = normalizeRecommendCategory(item.analysis?.category || item.analysis?.itemType);\n return itemCategory === requested || analysisCategory === requested;\n}\n\nfunction buildRecommendationReason(item, weatherContext) {\n const analysis = item.analysis || {};\n const weatherText = weatherContext?.weather || \"当前天气\";\n const locationText = weatherContext?.locationName || \"你所在地区\";\n const tempText = Number.isFinite(weatherContext?.temperature) ? `${Math.round(weatherContext.temperature)}°C` : \"当前温度\";\n const colors = Array.isArray(analysis.colors) && analysis.colors.length ? analysis.colors.slice(0, 2).join(\"、\") : \"\";\n const weatherTags = Array.isArray(analysis.weatherTags) && analysis.weatherTags.length ? analysis.weatherTags.slice(0, 2).join(\"、\") : \"\";\n const visibleDetails = Array.isArray(analysis.visibleDetails) && analysis.visibleDetails.length ? analysis.visibleDetails.slice(0, 3).join(\"、\") : \"\";\n const stylingTips = Array.isArray(analysis.stylingTips) && analysis.stylingTips.length ? analysis.stylingTips.slice(0, 2).join(\";\") : \"\";\n const texture = [analysis.material, analysis.thickness, analysis.fit, analysis.breathability].filter(Boolean).join(\"、\");\n const style = analysis.weatherSuitability || analysis.description || analysis.style || item.notes || \"\";\n const parts = [\n `${locationText}${weatherText},体感大约${tempText}`,\n colors ? `这件${item.title}的${colors}配色和天气氛围比较协调` : `这件${item.title}适合作为今天的${item.category || \"单品\"}`,\n visibleDetails ? `可见细节有${visibleDetails}` : \"\",\n texture ? `衣物特征偏${texture}` : \"\",\n weatherTags ? `它的属性更贴近${weatherTags}这类天气` : \"\",\n style ? style : \"\",\n stylingTips ? `搭配上可以考虑:${stylingTips}` : \"\",\n ].filter(Boolean);\n return `${parts.join(\";\")}。`;\n}\n\nasync function rankRecommendationsWithLlm({ candidates, weatherContext, category, preference }) {\n const apiKey = process.env.OPENAI_API_KEY || process.env.WARDROBE_VISION_API_KEY;\n const baseUrl = (process.env.OPENAI_BASE_URL || process.env.WARDROBE_VISION_BASE_URL || \"https://api.xiaomimimo.com/v1\").replace(/\\/$/, \"\");\n const model = process.env.OPENAI_MODEL || process.env.WARDROBE_VISION_MODEL || \"mimo-v2.5\";\n if (!apiKey || candidates.length === 0) return null;\n\n const compactCandidates = candidates.slice(0, 60).map((item) => ({\n id: item.id,\n title: item.title,\n category: item.category,\n notes: item.notes || \"\",\n analysis: item.analysis,\n feedbackSummary: item.feedbackSummary || {},\n }));\n const prompt = [\n \"你是喵喵 App 的穿搭推荐模型。请根据天气、目标日期、用户偏好和衣物结构化分析,从候选衣物中选择最适合的单品。\",\n \"只返回 JSON,不要 Markdown。\",\n \"schema: {\\\"selectedIds\\\":string[],\\\"itemReasons\\\":{\\\"衣物id\\\":\\\"推荐理由\\\"},\\\"reason\\\":string}\",\n \"要求:selectedIds 只能使用候选里的 id;itemReasons 必须为每个 selectedIds 里的 id 写一条单独理由。\",\n \"推荐理由要紧密结合这件衣服的图片分析/颜色/类别/材质/厚薄/版型/可见细节、当前或次日天气、温度、城市和用户偏好;不要写泛泛的模板话。\",\n \"如果 category 是 top/bottom/dress,只选择对应类别。每条理由 80-160 个中文字符,像真实穿搭助理给出的建议,可以包含一条具体搭配建议。\",\n ].join(\"\\n\");\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model,\n messages: [\n { role: \"system\", content: prompt },\n {\n role: \"user\",\n content: JSON.stringify({\n weather: weatherContext,\n category: category || \"full\",\n preference: preference || \"\",\n candidates: compactCandidates,\n }),\n },\n ],\n temperature: 0.2,\n }),\n });\n if (!response.ok) {\n const text = await response.text();\n throw new Error(`LLM recommendation failed ${response.status}: ${text}`);\n }\n const data = await response.json();\n const content = data.choices?.[0]?.message?.content || data.content || \"\";\n return parseJsonObjectFromText(content);\n}\n\napp.post(\"/api/recommendations/match\", authMiddleware, async (req, res) => {\n const userId = req.user.userId;\n const { category, temp, weather, preference, targetDay, weatherDays, locationName, recommendGenerateHour } = req.body || {};\n\n const temperature = temp === undefined || temp === null || temp === \"\" ? 20 : parseFloat(temp);\n if (isNaN(temperature)) {\n return res.status(400).json({ error: \"temp is required and must be a number\" });\n }\n\n try {\n let query = \"SELECT * FROM wardrobe_items WHERE user_id = ? AND analysis IS NOT NULL\";\n const params = [userId];\n\n const items = db.prepare(query).all(...params);\n const weatherContext = {\n locationName: locationName || \"\",\n targetDay: targetDay || \"today\",\n recommendGenerateHour,\n temperature,\n weather: weather || \"\",\n weatherDays: Array.isArray(weatherDays) ? weatherDays.slice(0, 2) : [],\n };\n const weatherTag = (weather || \"\").toLowerCase();\n const preferenceTag = (preference || \"\").toLowerCase();\n const feedbackRows = db.prepare(`\n SELECT wardrobe_item_id, feedback_type, COUNT(*) as count, MAX(created_at) as last_at\n FROM recommendation_feedback\n WHERE user_id = ?\n GROUP BY wardrobe_item_id, feedback_type\n `).all(userId);\n const feedbackByItem = new Map();\n for (const row of feedbackRows) {\n const curr = feedbackByItem.get(row.wardrobe_item_id) || {};\n curr[row.feedback_type] = { count: row.count, lastAt: row.last_at };\n feedbackByItem.set(row.wardrobe_item_id, curr);\n }\n\n const scored = items.map((item) => {\n let analysis;\n try {\n analysis = JSON.parse(item.analysis);\n } catch {\n return null;\n }\n if (!recommendationCategoryMatches({ category: item.category, analysis }, category)) return null;\n\n const tempRange = analysis.temperatureRange || {};\n const minTemp = typeof tempRange.min === \"number\" ? tempRange.min : -Infinity;\n const maxTemp = typeof tempRange.max === \"number\" ? tempRange.max : Infinity;\n\n let tempScore = 0;\n if (temperature >= minTemp && temperature <= maxTemp) {\n tempScore = 1;\n } else {\n const dist = Math.min(Math.abs(temperature - minTemp), Math.abs(temperature - maxTemp));\n tempScore = Math.max(0, 1 - dist / 20);\n }\n\n const weatherTags = (analysis.weatherTags || []).map((t) => t.toLowerCase());\n const weatherScore = weatherTag\n ? (weatherTags.some((t) => t.includes(weatherTag) || weatherTag.includes(t)) ? 1 : 0)\n : 0;\n\n const style = (analysis.style || \"\").toLowerCase();\n const preferenceScore = preferenceTag && (style.includes(preferenceTag) || preferenceTag.includes(style)) ? 1 : 0;\n\n const seasons = (analysis.seasons || []).map((s) => s.toLowerCase());\n const month = new Date().getMonth() + 1;\n const currentSeason = month >= 3 && month <= 5 ? \"spring\" : month >= 6 && month <= 8 ? \"summer\" : month >= 9 && month <= 11 ? \"autumn\" : \"winter\";\n const seasonScore = seasons.includes(currentSeason) || seasons.includes(\"all\") ? 1 : 0;\n\n const itemFeedback = feedbackByItem.get(item.id) || {};\n const positiveCount = itemFeedback.love?.count || 0;\n const negativeCount =\n (itemFeedback.not_suitable?.count || 0) +\n (itemFeedback.too_hot?.count || 0) +\n (itemFeedback.too_cold?.count || 0) +\n (itemFeedback.too_formal?.count || 0);\n let feedbackScore = Math.min(0.24, positiveCount * 0.08) - Math.min(0.5, negativeCount * 0.16);\n if (itemFeedback.too_hot && temperature >= 22) feedbackScore -= 0.18;\n if (itemFeedback.too_cold && temperature <= 18) feedbackScore -= 0.18;\n if (itemFeedback.too_formal && (() => {\n const prefStr = String(preference || \"\");\n const prefLower = prefStr.toLowerCase();\n return prefStr.includes(\"休闲\")\n || prefStr.includes(\"日常\")\n || prefStr.includes(\"随便\")\n || prefLower.includes(\"casual\");\n })()) feedbackScore -= 0.14;\n\n const score = tempScore * 0.36 + weatherScore * 0.26 + preferenceScore * 0.18 + seasonScore * 0.1 + feedbackScore;\n\n return {\n id: item.id,\n title: item.title,\n category: item.category,\n notes: item.notes || \"\",\n coverUri: item.cover_url,\n colorTags: JSON.parse(item.color_tags || \"[]\"),\n seasonTags: JSON.parse(item.season_tags || \"[]\"),\n purchaseDate: item.purchase_date,\n isFavorite: !!item.is_favorite,\n sourceLabel: item.source_label || undefined,\n photos: db.prepare(\"SELECT photo_url FROM wardrobe_photos WHERE item_id = ? ORDER BY created_at ASC\").all(item.id).map((p) => p.photo_url),\n analysis,\n score: Math.round(score * 100) / 100,\n feedbackSummary: itemFeedback,\n recommendationReason: \"\",\n createdAt: item.created_at,\n };\n }).filter(Boolean);\n\n scored.sort((a, b) => b.score - a.score);\n scored.forEach((item) => {\n item.recommendationReason = buildRecommendationReason(item, weatherContext);\n });\n\n let recommendations = scored.slice(0, 4);\n let llmReason = \"\";\n try {\n const llmResult = await rankRecommendationsWithLlm({\n candidates: scored,\n weatherContext,\n category,\n preference,\n });\n const selectedIds = Array.isArray(llmResult?.selectedIds) ? llmResult.selectedIds.map(String) : [];\n if (selectedIds.length) {\n const byId = new Map(scored.map((item) => [item.id, item]));\n const itemReasons = llmResult?.itemReasons && typeof llmResult.itemReasons === \"object\" ? llmResult.itemReasons : {};\n recommendations = selectedIds.map((id) => {\n const item = byId.get(id);\n if (!item) return null;\n const reason = itemReasons[id] || itemReasons[String(id)];\n return {\n ...item,\n recommendationReason: typeof reason === \"string\" && reason.trim() ? reason.trim() : item.recommendationReason,\n };\n }).filter(Boolean).slice(0, 4);\n llmReason = String(llmResult?.reason || \"\");\n }\n } catch (llmErr) {\n console.warn(\"[Recommendations] LLM ranking fallback:\", llmErr.message);\n }\n\n res.json({ recommendations, reason: llmReason, targetDay: weatherContext.targetDay, weather: weatherContext });\n } catch (err) {\n console.error(\"[Recommendations] Match POST error:\", err.message);\n res.status(500).json({ error: \"匹配推荐失败\" });\n }\n});\n\napp.post(\"/api/recommendations/:itemId/feedback\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const { itemId } = req.params;\n const { feedbackType, context } = req.body || {};\n const allowed = new Set([\"love\", \"not_suitable\", \"too_hot\", \"too_cold\", \"too_formal\"]);\n\n if (!allowed.has(feedbackType)) {\n return res.status(400).json({ error: \"Invalid feedbackType\" });\n }\n\n try {\n const item = db.prepare(\"SELECT id FROM wardrobe_items WHERE id = ? AND user_id = ?\").get(itemId, userId);\n if (!item) return res.status(404).json({ error: \"Item not found\" });\n\n const id = generateId();\n db.prepare(`\n INSERT INTO recommendation_feedback (id, user_id, wardrobe_item_id, feedback_type, context)\n VALUES (?, ?, ?, ?, ?)\n `).run(id, userId, itemId, feedbackType, context ? JSON.stringify(context) : \"\");\n\n res.status(201).json({ id, itemId, feedbackType });\n } catch (err) {\n console.error(\"[Recommendations] Feedback error:\", err.message);\n res.status(500).json({ error: \"保存推荐反馈失败\" });\n }\n});\n\napp.get(\"/api/recommendations/match\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const { category, temp, weather, preference } = req.query;\n\n const temperature = parseFloat(temp);\n if (isNaN(temperature)) {\n return res.status(400).json({ error: \"temp is required and must be a number\" });\n }\n\n try {\n let query = \"SELECT * FROM wardrobe_items WHERE user_id = ? AND analysis IS NOT NULL\";\n const params = [userId];\n\n if (category) {\n query += \" AND category = ?\";\n params.push(category);\n }\n\n const items = db.prepare(query).all(...params);\n const weatherTag = (weather || \"\").toLowerCase();\n const preferenceTag = (preference || \"\").toLowerCase();\n\n const scored = items.map((item) => {\n let analysis;\n try {\n analysis = JSON.parse(item.analysis);\n } catch {\n return null;\n }\n\n const tempRange = analysis.temperatureRange || {};\n const minTemp = typeof tempRange.min === \"number\" ? tempRange.min : -Infinity;\n const maxTemp = typeof tempRange.max === \"number\" ? tempRange.max : Infinity;\n\n let tempScore = 0;\n if (temperature >= minTemp && temperature <= maxTemp) {\n tempScore = 1;\n } else {\n const dist = Math.min(Math.abs(temperature - minTemp), Math.abs(temperature - maxTemp));\n tempScore = Math.max(0, 1 - dist / 20);\n }\n\n const weatherTags = (analysis.weatherTags || []).map((t) => t.toLowerCase());\n let weatherScore = 0;\n if (weatherTag) {\n weatherScore = weatherTags.some((t) => t.includes(weatherTag) || weatherTag.includes(t)) ? 1 : 0;\n }\n\n const style = (analysis.style || \"\").toLowerCase();\n let preferenceScore = 0;\n if (preferenceTag) {\n preferenceScore = style.includes(preferenceTag) || preferenceTag.includes(style) ? 1 : 0;\n }\n\n const seasons = (analysis.seasons || []).map((s) => s.toLowerCase());\n let seasonScore = 0;\n const now = new Date();\n const month = now.getMonth() + 1;\n let currentSeason = \"\";\n if (month >= 3 && month <= 5) currentSeason = \"spring\";\n else if (month >= 6 && month <= 8) currentSeason = \"summer\";\n else if (month >= 9 && month <= 11) currentSeason = \"autumn\";\n else currentSeason = \"winter\";\n seasonScore = seasons.includes(currentSeason) || seasons.includes(\"all\") ? 1 : 0;\n\n const score = tempScore * 0.4 + weatherScore * 0.3 + preferenceScore * 0.2 + seasonScore * 0.1;\n\n return {\n id: item.id,\n title: item.title,\n category: item.category,\n coverUri: item.cover_url,\n analysis,\n score: Math.round(score * 100) / 100,\n createdAt: item.created_at,\n };\n }).filter(Boolean);\n\n scored.sort((a, b) => b.score - a.score);\n const top20 = scored.slice(0, 20);\n\n res.json(top20);\n } catch (err) {\n console.error(\"[Recommendations] Match error:\", err.message);\n res.status(500).json({ error: \"匹配推荐失败\" });\n }\n});\n\napp.get(\"/api/recommendations\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n try {\n const rows = db.prepare(\"SELECT * FROM recommendations WHERE user_id = ? ORDER BY created_at DESC\").all(userId);\n const recommendations = rows.map(r => ({\n ...r,\n matchedItemIds: JSON.parse(r.matched_item_ids || \"[]\"),\n }));\n res.json(recommendations);\n } catch (err) {\n console.error(\"[Recommendations] GET error:\", err.message);\n res.status(500).json({ error: \"获取推荐历史失败\" });\n }\n});\n\napp.post(\"/api/recommendations\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const { id, title, summary, stylingNote, matchedItemIds } = req.body;\n try {\n const recId = id || generateId();\n db.prepare(\"INSERT OR REPLACE INTO recommendations (id, user_id, title, summary, styling_note, matched_item_ids) VALUES (?, ?, ?, ?, ?, ?)\").run(\n recId, userId, title || \"\", summary || \"\", stylingNote || \"\",\n JSON.stringify(matchedItemIds || [])\n );\n res.json({ id: recId });\n } catch (err) {\n console.error(\"[Recommendations] POST error:\", err.message);\n res.status(500).json({ error: \"保存推荐失败\" });\n }\n});\n\napp.delete(\"/api/recommendations/:id\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const { id } = req.params;\n try {\n db.prepare(\"DELETE FROM recommendations WHERE id = ? AND user_id = ?\").run(id, userId);\n res.json({ success: true });\n } catch (err) {\n console.error(\"[Recommendations] DELETE error:\", err.message);\n res.status(500).json({ error: \"删除推荐失败\" });\n }\n});\n\napp.post(\"/api/follow\", authMiddleware, (req, res) => {\n const followerId = req.user.userId;\n const { userId } = req.body;\n if (!userId) return res.status(400).json({ error: \"userId is required\" });\n if (userId === followerId) return res.status(400).json({ error: \"不能关注自己\" });\n try {\n // 检查被关注用户是否存在\n const targetUser = db.prepare(\"SELECT id FROM users WHERE id = ?\").get(userId);\n if (!targetUser) {\n return res.status(404).json({ error: \"用户不存在\" });\n }\n db.prepare(\"INSERT OR IGNORE INTO follows (follower_id, following_id) VALUES (?, ?)\").run(followerId, userId);\n const actor = db.prepare(\"SELECT nickname FROM users WHERE id = ?\").get(followerId);\n createNotification({\n userId: userId,\n type: \"follow\",\n actorId: followerId,\n content: `${actor?.nickname || \"有人\"} 关注了你`,\n });\n const followersCount = db.prepare(\"SELECT COUNT(*) as count FROM follows WHERE following_id = ?\").get(userId).count;\n const followingCount = db.prepare(\"SELECT COUNT(*) as count FROM follows WHERE follower_id = ?\").get(followerId).count;\n res.json({ following: true, followersCount, followingCount });\n } catch (err) {\n console.error(\"[Follow] POST error:\", err.message);\n res.status(500).json({ error: \"关注失败: \" + err.message });\n }\n});\n\napp.delete(\"/api/follow/:userId\", authMiddleware, (req, res) => {\n const followerId = req.user.userId;\n const { userId } = req.params;\n try {\n db.prepare(\"DELETE FROM follows WHERE follower_id = ? AND following_id = ?\").run(followerId, userId);\n const followersCount = db.prepare(\"SELECT COUNT(*) as count FROM follows WHERE following_id = ?\").get(userId).count;\n const followingCount = db.prepare(\"SELECT COUNT(*) as count FROM follows WHERE follower_id = ?\").get(followerId).count;\n res.json({ following: false, followersCount, followingCount });\n } catch (err) {\n console.error(\"[Follow] DELETE error:\", err.message);\n res.status(500).json({ error: \"取消关注失败\" });\n }\n});\n\napp.get(\"/api/follow/following\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n try {\n const users = db.prepare(`\n SELECT\n u.id,\n u.nickname as name,\n u.avatar_url,\n u.user_code,\n EXISTS(SELECT 1 FROM follows f2 WHERE f2.follower_id = u.id AND f2.following_id = ?) as is_following_me\n FROM follows f\n JOIN users u ON f.following_id = u.id\n WHERE f.follower_id = ?\n ORDER BY u.nickname\n `).all(userId, userId);\n\n const result = users.map((u) => ({\n id: u.id,\n name: u.name,\n handle: u.user_code || `@${u.id.slice(-8)}`,\n avatarUrl: u.avatar_url,\n isFollowed: true,\n userCode: u.user_code,\n mutual: !!u.is_following_me,\n }));\n\n res.json(result);\n } catch (e) {\n console.error(\"[FollowList] Following error:\", e.message);\n res.status(500).json({ error: \"获取失败\" });\n }\n});\n\napp.get(\"/api/follow/followers\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n try {\n const followingIds = db.prepare(\"SELECT following_id FROM follows WHERE follower_id = ?\").all(userId).map((r) => r.following_id);\n const followingSet = new Set(followingIds);\n\n const users = db.prepare(`\n SELECT\n u.id,\n u.nickname as name,\n u.avatar_url,\n u.user_code\n FROM follows f\n JOIN users u ON f.follower_id = u.id\n WHERE f.following_id = ?\n ORDER BY u.nickname\n `).all(userId);\n\n const result = users.map((u) => ({\n id: u.id,\n name: u.name,\n handle: u.user_code || `@${u.id.slice(-8)}`,\n avatarUrl: u.avatar_url,\n isFollowed: followingSet.has(u.id),\n userCode: u.user_code,\n mutual: followingSet.has(u.id),\n }));\n\n res.json(result);\n } catch (e) {\n console.error(\"[FollowList] Followers error:\", e.message);\n res.status(500).json({ error: \"获取失败\" });\n }\n});\n\napp.get(\"/api/follow/mutual\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n try {\n const users = db.prepare(`\n SELECT\n u.id,\n u.nickname as name,\n u.avatar_url,\n u.user_code\n FROM follows f1\n JOIN follows f2 ON f1.follower_id = ? AND f1.following_id = f2.follower_id AND f2.following_id = ?\n JOIN users u ON f1.following_id = u.id\n ORDER BY u.nickname\n `).all(userId, userId);\n\n const result = users.map((u) => ({\n id: u.id,\n name: u.name,\n handle: u.user_code || `@${u.id.slice(-8)}`,\n avatarUrl: u.avatar_url,\n isFollowed: true,\n userCode: u.user_code,\n mutual: true,\n }));\n\n res.json(result);\n } catch (e) {\n console.error(\"[FollowList] Mutual error:\", e.message);\n res.status(500).json({ error: \"获取失败\" });\n }\n});\n\nconst server = http.createServer(app);\nconst io = new SocketServer(server, {\n cors: { origin: \"*\", methods: [\"GET\", \"POST\"] },\n maxHttpBufferSize: 50 * 1024 * 1024,\n});\n\nconst jwt = require(\"jsonwebtoken\");\n\nio.use((socket, next) => {\n const token = socket.handshake.auth.token;\n if (!token) return next(new Error(\"Authentication required\"));\n try {\n socket.user = jwt.verify(token, JWT_SECRET);\n next();\n } catch {\n next(new Error(\"Invalid token\"));\n }\n});\n\nfunction isConversationParticipant(conversationId, userId) {\n if (!conversationId || !userId) return false;\n const direct = db.prepare(`\n SELECT 1 FROM conversations\n WHERE id = ? AND (user_id_1 = ? OR user_id_2 = ?)\n LIMIT 1\n `).get(conversationId, userId, userId);\n if (direct) return true;\n\n const participant = db.prepare(`\n SELECT 1 FROM conversation_participants\n WHERE conversation_id = ? AND user_id = ?\n LIMIT 1\n `).get(conversationId, userId);\n return !!participant;\n}\n\nio.on(\"connection\", (socket) => {\n const userId = socket.user.userId;\n console.log(`[Socket] User ${userId} connected`);\n socket.join(`user_${userId}`);\n\n socket.on(\"join_conversation\", (convId) => {\n if (!isConversationParticipant(convId, userId)) {\n socket.emit(\"conversation_error\", { conversationId: convId, error: \"Forbidden\" });\n return;\n }\n socket.join(`conv_${convId}`);\n });\n\n socket.on(\"leave_conversation\", (convId) => {\n socket.leave(`conv_${convId}`);\n });\n\n socket.on(\"send_message\", async (data) => {\n const { conversationId, text } = data;\n if (!conversationId || !text || !text.trim()) return;\n\n const msgId = generateId();\n const now = new Date().toISOString();\n\n try {\n const conv = db.prepare(\"SELECT * FROM conversations WHERE id = ?\").get(conversationId);\n if (!conv) return;\n if (!isConversationParticipant(conversationId, userId)) {\n socket.emit(\"conversation_error\", { conversationId, error: \"Forbidden\" });\n return;\n }\n\n db.prepare(`\n INSERT INTO conversation_messages (id, conversation_id, sender_id, text, created_at)\n VALUES (?, ?, ?, ?, ?)\n `).run(msgId, conversationId, userId, text.trim(), now);\n\n db.prepare(\"UPDATE conversations SET last_snippet = ?, updated_at = ? WHERE id = ?\").run(text.trim(), now, conversationId);\n\n const user = db.prepare(\"SELECT nickname, avatar_url FROM users WHERE id = ?\").get(userId);\n\n const message = {\n id: msgId,\n conversationId,\n sender: userId,\n senderName: user?.nickname || \"Unknown\",\n senderAvatar: user?.avatar_url,\n text: text.trim(),\n time: now,\n };\n\n io.to(`conv_${conversationId}`).emit(\"new_message\", message);\n\n const otherUserId = conv.user_id_1 === userId ? conv.user_id_2 : conv.user_id_1;\n if (otherUserId) {\n io.to(`user_${otherUserId}`).emit(\"message_notification\", {\n conversationId,\n snippet: text.trim(),\n from: user?.nickname || \"Unknown\",\n });\n }\n } catch (err) {\n console.error(\"[Socket] Send message error:\", err.message);\n }\n });\n\n socket.on(\"typing\", (data) => {\n const { conversationId } = data;\n if (!conversationId) return;\n if (!isConversationParticipant(conversationId, userId)) return;\n socket.to(`conv_${conversationId}`).emit(\"user_typing\", { userId, conversationId });\n });\n\n socket.on(\"disconnect\", () => {\n console.log(`[Socket] User ${userId} disconnected`);\n });\n});\n\napp.post(\"/api/profile/visit\", authMiddleware, (req, res) => {\n const visitorId = req.user.userId;\n const { userId } = req.body;\n if (!userId || userId === visitorId) return res.status(400).json({ error: \"Invalid userId\" });\n try {\n db.prepare(\"INSERT OR IGNORE INTO profile_visits (visitor_id, visited_id) VALUES (?, ?)\").run(visitorId, userId);\n const actor = db.prepare(\"SELECT nickname FROM users WHERE id = ?\").get(visitorId);\n createNotification({\n userId,\n type: \"visit\",\n actorId: visitorId,\n content: `${actor.nickname} 访问了你的主页`,\n });\n res.json({ visited: true });\n } catch (err) {\n console.error(\"[Profile] Visit error:\", err.message);\n res.status(500).json({ error: \"记录失败\" });\n }\n});\n\napp.get(\"/api/notifications\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n try {\n const notifications = db.prepare(`\n SELECT n.*, u.nickname as actor_name, u.avatar_url as actor_avatar\n FROM notifications n\n JOIN users u ON n.actor_id = u.id\n WHERE n.user_id = ?\n ORDER BY n.created_at DESC\n LIMIT 100\n `).all(userId);\n const unreadCount = db.prepare(\"SELECT COUNT(*) as count FROM notifications WHERE user_id = ? AND is_read = 0\").get(userId).count;\n res.json({\n notifications: notifications.map((n) => ({\n id: n.id,\n type: n.type,\n actorName: n.actor_name,\n actorAvatar: n.actor_avatar,\n postId: n.post_id,\n commentId: n.comment_id,\n content: n.content,\n isRead: !!n.is_read,\n createdAt: n.created_at,\n })),\n unreadCount,\n });\n } catch (err) {\n console.error(\"[Notification] GET error:\", err.message);\n res.status(500).json({ error: \"查询失败\" });\n }\n});\n\napp.post(\"/api/notifications/:id/read\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n const { id } = req.params;\n try {\n db.prepare(\"UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ?\").run(id, userId);\n res.json({ success: true });\n } catch (err) {\n console.error(\"[Notification] Read error:\", err.message);\n res.status(500).json({ error: \"操作失败\" });\n }\n});\n\napp.post(\"/api/notifications/read-all\", authMiddleware, (req, res) => {\n const userId = req.user.userId;\n try {\n db.prepare(\"UPDATE notifications SET is_read = 1 WHERE user_id = ? AND is_read = 0\").run(userId);\n res.json({ success: true });\n } catch (err) {\n console.error(\"[Notification] ReadAll error:\", err.message);\n res.status(500).json({ error: \"操作失败\" });\n }\n});\n\n// ASR 语音转文字代理路由\napp.get(\"/api/asr/health\", authMiddleware, async (req, res) => {\n const asrServiceUrl = process.env.ASR_SERVICE_URL || DEFAULT_ASR_SERVICE_URL;\n\n try {\n const response = await fetch(`${asrServiceUrl}/health`);\n const data = await response.json().catch(() => ({}));\n res.status(response.status).json({\n status: response.ok ? \"ok\" : \"error\",\n serviceUrl: asrServiceUrl,\n ...data,\n });\n } catch (err) {\n res.status(502).json({\n status: \"unavailable\",\n serviceUrl: asrServiceUrl,\n error: \"ASR service unavailable\",\n detail: err.message,\n });\n }\n});\n\napp.post(\"/api/asr\", authMiddleware, async (req, res) => {\n const { audio } = req.body;\n if (!audio) {\n return res.status(400).json({ error: \"Missing audio data\" });\n }\n\n const asrServiceUrl = process.env.ASR_SERVICE_URL || DEFAULT_ASR_SERVICE_URL;\n\n try {\n console.log(\"[ASR Proxy] Forwarding audio to ASR service...\");\n const response = await fetch(`${asrServiceUrl}/asr`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ audio }),\n });\n\n const data = await response.json();\n console.log(\"[ASR Proxy] Result:\", data.text || data.error);\n res.status(response.status).json(data);\n } catch (err) {\n console.error(\"[ASR Proxy] Error:\", err.message);\n res.status(502).json({ error: \"ASR service unavailable\", detail: err.message });\n }\n});\n\napp.use((err, req, res, next) => {\n if (err instanceof multer.MulterError || err?.message === \"Only image uploads are allowed\") {\n return res.status(400).json({ error: err.message });\n }\n return next(err);\n});\n\nfunction getLanUrls(port) {\n const interfaces = os.networkInterfaces();\n return Object.values(interfaces)\n .flat()\n .filter((item) => item && item.family === \"IPv4\" && !item.internal)\n .map((item) => `http://${item.address}:${port}`);\n}\n\nasync function startServer() {\n await ensureLocalAsrService();\n\n server.listen(PORT, \"0.0.0.0\", () => {\n console.log(`Server running on http://0.0.0.0:${PORT}`);\n console.log(`Local: http://localhost:${PORT}`);\n for (const url of getLanUrls(PORT)) {\n console.log(`LAN: ${url}`);\n }\n });\n}\n\nstartServer().catch((err) => {\n console.error(\"[Boot] Failed to start server:\", err);\n stopManagedAsrService();\n process.exit(1);\n});\n\n"}}}} |