diff --git a/.env.example b/.env.example index 8b858dd..27f31d1 100644 --- a/.env.example +++ b/.env.example @@ -199,6 +199,10 @@ H5_ACCESS_PASSWORD=change-me # H5_WECHAT_OPEN_APP_ID= # H5_WECHAT_OPEN_APP_SECRET= +# 微信小程序原生登录(wx.login → /auth/wechat-miniapp/login) +# H5_WECHAT_MINIAPP_APP_ID=wx... +# H5_WECHAT_MINIAPP_APP_SECRET=小程序AppSecret + # 本地开发:pnpm dev 会自动启动 server.mjs + vite # 若只跑前端:pnpm dev:vite(需另开 pnpm dev:server) # H5_DEV_PORTAL=http://127.0.0.1:8081 diff --git a/miniapp/.gitignore b/miniapp/.gitignore new file mode 100644 index 0000000..fc32101 --- /dev/null +++ b/miniapp/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +miniprogram_npm/ +project.private.config.json +utils/config.local.js +*.local diff --git a/miniapp/README.md b/miniapp/README.md new file mode 100644 index 0000000..d348b10 --- /dev/null +++ b/miniapp/README.md @@ -0,0 +1,61 @@ +# TKMind WeChat Mini Program + +This directory contains the native WeChat Mini Program client. It is intentionally isolated from the H5/backend code so development can proceed without changing existing services. + +## Current MVP + +- Native WeChat login entry via `wx.login`. +- H5 account/password login fallback through `/auth/login`. +- Chat submission through `/api/agent/runs`. +- Agent run polling through `/api/agent/runs/:runId`. +- Session detail refresh through `/api/sessions/:sessionId`. +- Session list through `/api/sessions`. +- MindSpace page list through `/api/mindspace/v1/pages`. +- Page preview through Mini Program `web-view`. + +## Backend Assumptions + +The H5 APIs are reused as-is. Native Mini Program login posts the `wx.login` code to `MINIAPP_LOGIN_PATH` in `utils/config.js`; the current default is `/auth/wechat-miniapp/login`. If that backend endpoint is not available yet, use the account/password login fallback during local development. + +## Production / 提审前(当前默认) + +- `utils/config.js` 默认 `LOCAL_DEV = false`,API 指向 **`https://m.tkmind.cn`**。 +- `project.config.json` 中 `urlCheck: true`;DevTools 需配置真实小程序 AppID(非 tourist)。 +- 微信公众平台需配置 request / web-view 合法域名:`m.tkmind.cn`。 +- 此配置**不会**触发 103 发版;仅小程序客户端连线上 Portal。 +- **微信一键登录**还需 Portal 侧配置 `H5_WECHAT_MINIAPP_APP_ID` / `H5_WECHAT_MINIAPP_APP_SECRET` 并实现 `/auth/wechat-miniapp/login`;未部署前可先用账号密码登录。 + +### 开发者工具仍显示「游客模式」时 + +1. 关闭项目,重新「导入项目」并选择 AppID `wx3e79ecd530c88da4`(不要选测试号/游客模式) +2. 或:详情 → 基本信息 → AppID 改为你自己的小程序 +3. 确认 `project.config.json` 与 `project.private.config.json` 中 `appid` 均为 `wx3e79ecd530c88da4` +4. 重新编译后再点「微信一键登录」 + +## Local Debug (optional) + +1. Start local Portal in the repo root: + +```bash +pnpm dev +# or ensure http://127.0.0.1:8081/auth/status responds +``` + +2. Switch miniapp back to local Portal **without** touching production: + + - In `utils/config.js`, set `LOCAL_DEV = true`, **or** + - Copy `utils/config.local.example.js` → `utils/config.local.js` and set `API_BASE_URL`. + + `project.private.config.json` only affects DevTools compiler settings; it is **not** available to runtime `require()`. + +3. WeChat DevTools → 详情 → 本地设置 (local only): + - enable **Do not verify合法域名 / TLS** + - keep **tourist AppID** only if you use account/password login; real AppID is required for `wx.login` + +4. Restart Portal after pulling local server changes. Local Portal only bypasses CSRF for WeChat `servicewechat.com` referrers; production `m.tkmind.cn` is unchanged. + +5. Use an account that exists in your local `.env` database auth. Wrong credentials return `401`, not `403`. + +## Isolation Rule + +Mini Program work should stay inside `miniapp/`. Do not modify backend, H5, Memory, MindSpace, or production release files for this MVP unless explicitly approved. diff --git a/miniapp/app.js b/miniapp/app.js new file mode 100644 index 0000000..5d1d322 --- /dev/null +++ b/miniapp/app.js @@ -0,0 +1,22 @@ +const { getStoredSession, setApiBaseUrl, getMiniProgramAppId, isTouristMode, getApiBaseUrl } = require('./utils/api'); +const { API_BASE_URL } = require('./utils/config'); + +App({ + globalData: { + user: null, + }, + + onLaunch() { + setApiBaseUrl(API_BASE_URL); + const appId = getMiniProgramAppId(); + console.info('[TKMind miniapp] API_BASE_URL =', getApiBaseUrl() || API_BASE_URL); + console.info('[TKMind miniapp] AppID =', appId || '(none)'); + if (isTouristMode()) { + console.warn('[TKMind miniapp] 游客模式:wx.login 仅返回模拟 code,微信一键登录不可用'); + } + const session = getStoredSession(); + if (session?.user) { + this.globalData.user = session.user; + } + }, +}); diff --git a/miniapp/app.json b/miniapp/app.json new file mode 100644 index 0000000..7c7a7bc --- /dev/null +++ b/miniapp/app.json @@ -0,0 +1,36 @@ +{ + "pages": [ + "pages/login/index", + "pages/chat/index", + "pages/sessions/index", + "pages/space/index", + "pages/webview/index" + ], + "window": { + "navigationBarTitleText": "TKMind", + "navigationBarBackgroundColor": "#101820", + "navigationBarTextStyle": "white", + "backgroundColor": "#f5f7fb" + }, + "tabBar": { + "color": "#64748b", + "selectedColor": "#0f766e", + "backgroundColor": "#ffffff", + "borderStyle": "black", + "list": [ + { + "pagePath": "pages/chat/index", + "text": "聊天" + }, + { + "pagePath": "pages/sessions/index", + "text": "会话" + }, + { + "pagePath": "pages/space/index", + "text": "页面" + } + ] + }, + "sitemapLocation": "sitemap.json" +} diff --git a/miniapp/app.wxss b/miniapp/app.wxss new file mode 100644 index 0000000..33a1e0d --- /dev/null +++ b/miniapp/app.wxss @@ -0,0 +1,45 @@ +page { + min-height: 100%; + background: #f5f7fb; + color: #132028; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif; +} + +.page { + min-height: 100vh; + box-sizing: border-box; + padding: 28rpx; +} + +.panel { + background: #ffffff; + border: 1rpx solid #dbe3eb; + border-radius: 16rpx; + box-shadow: 0 10rpx 30rpx rgba(15, 23, 42, 0.06); +} + +.muted { + color: #64748b; +} + +.primary-button { + height: 88rpx; + border-radius: 12rpx; + background: #0f766e; + color: #ffffff; + font-weight: 700; + line-height: 88rpx; +} + +.secondary-button { + height: 78rpx; + border-radius: 12rpx; + background: #e7f3f1; + color: #0f766e; + font-weight: 700; + line-height: 78rpx; +} + +.danger-text { + color: #b42318; +} diff --git a/miniapp/pages/chat/index.js b/miniapp/pages/chat/index.js new file mode 100644 index 0000000..ca69ce9 --- /dev/null +++ b/miniapp/pages/chat/index.js @@ -0,0 +1,252 @@ +const { + checkAuth, + createAgentRun, + getAgentRun, + getMe, + loadSession, + logout +} = require('../../utils/api'); +const { + AGENT_RUN_MAX_POLLS, + AGENT_RUN_POLL_MS, + SELECTED_SESSION_KEY, + API_BASE_URL +} = require('../../utils/config'); +const { createUserMessage, normalizeMessages } = require('../../utils/messages'); +const { buildMessageView, resolveWorkspacePublicUrl } = require('../../utils/message-display'); + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +Page({ + data: { + sessionId: '', + messages: [], + draft: '', + loading: false, + error: '', + scrollIntoView: '' + }, + + async onShow() { + if (this._authCheckPromise) { + await this._authCheckPromise; + return; + } + this._authCheckPromise = this.verifyAccess(); + try { + await this._authCheckPromise; + } finally { + this._authCheckPromise = null; + } + }, + + async verifyAccess() { + if (this._redirectingToLogin) return; + try { + const status = await checkAuth(); + if (!status?.authenticated) { + this._redirectingToLogin = true; + wx.redirectTo({ + url: '/pages/login/index', + complete: () => { + this._redirectingToLogin = false; + }, + }); + return; + } + await this.ensureCurrentUser(); + const selectedSessionId = wx.getStorageSync(SELECTED_SESSION_KEY); + if (selectedSessionId) { + wx.removeStorageSync(SELECTED_SESSION_KEY); + if (selectedSessionId !== this.data.sessionId) { + await this.openSession(selectedSessionId); + } + } else if (this.data.sessionId && this.shouldRefreshRenderedMessages()) { + await this.refreshSession(this.data.sessionId); + } + } catch { + if (this._redirectingToLogin) return; + this._redirectingToLogin = true; + wx.redirectTo({ + url: '/pages/login/index', + complete: () => { + this._redirectingToLogin = false; + }, + }); + } + }, + + getMessageRenderOptions() { + const app = getApp(); + const user = app?.globalData?.user || {}; + return { + publishKey: user.id || user.publishSlug || '', + apiBaseUrl: API_BASE_URL, + }; + }, + + shouldRefreshRenderedMessages() { + return this.data.messages.some( + (item) => + !Array.isArray(item.parts) || + (/\bpublic\/[A-Za-z0-9._-]+\.html\b/i.test(item.text || '') && + (!item.pageLinks || item.pageLinks.length === 0)) + ); + }, + + async ensureCurrentUser() { + const app = getApp(); + if (app?.globalData?.user?.id) return app.globalData.user; + const result = await getMe(); + if (result?.user) { + app.globalData.user = result.user; + } + return result?.user || null; + }, + + handleInput(event) { + this.setData({ draft: event.detail.value }); + }, + + scrollToBottom() { + const index = Math.max(0, this.data.messages.length - 1); + this.setData({ scrollIntoView: `msg-${index}` }); + }, + + async sendMessage() { + const text = this.data.draft.trim(); + if (!text || this.data.loading) return; + + const userMessage = createUserMessage(text); + const userView = buildMessageView(userMessage, this.getMessageRenderOptions()); + const nextMessages = [...this.data.messages, userView]; + this.setData({ messages: nextMessages, draft: '', loading: true, error: '' }); + this.scrollToBottom(); + + try { + const payload = { + session_id: this.data.sessionId || undefined, + user_message: userMessage + }; + console.info('[TKMind miniapp] createAgentRun start', { + sessionId: payload.session_id || null + }); + const run = await createAgentRun(payload); + console.info('[TKMind miniapp] agent run created', run?.id, run?.status); + await this.waitForRun(run); + } catch (error) { + console.error('[TKMind miniapp] send failed', error); + this.setData({ error: error?.message || '发送失败' }); + wx.showToast({ title: error?.message || '发送失败', icon: 'none' }); + } finally { + this.setData({ loading: false }); + this.scrollToBottom(); + } + }, + + async waitForRun(run) { + const runId = run?.id; + if (!runId) throw new Error('后台任务未返回 runId'); + let sessionId = run?.sessionId || this.data.sessionId; + for (let i = 0; i < AGENT_RUN_MAX_POLLS; i += 1) { + const latest = i === 0 ? run : await getAgentRun(runId); + if (latest?.sessionId) { + sessionId = latest.sessionId; + if (sessionId !== this.data.sessionId) { + this.setData({ sessionId }); + } + } + if (latest?.status === 'succeeded') { + if (sessionId) { + await this.refreshSession(sessionId); + } else { + throw new Error('任务已完成,但未返回会话 ID'); + } + return; + } + if (latest?.status === 'failed') { + throw new Error(latest.error || '后台任务失败'); + } + await delay(AGENT_RUN_POLL_MS); + } + throw new Error('任务耗时较长,请稍后在会话中查看'); + }, + + async refreshSession(sessionId) { + const detail = await loadSession(sessionId); + const conversation = + detail?.conversation || + detail?.messages || + detail?.data?.conversation || + detail?.data?.messages || + []; + const visible = conversation.filter( + (message) => message?.metadata?.userVisible !== false + ); + const messages = normalizeMessages(visible, this.getMessageRenderOptions()); + this.setData({ sessionId, messages, error: '' }); + }, + + async openSession(sessionId) { + this.setData({ loading: true, error: '' }); + try { + await this.refreshSession(sessionId); + } catch (error) { + this.setData({ error: error?.message || '会话加载失败' }); + } finally { + this.setData({ loading: false }); + this.scrollToBottom(); + } + }, + + startNewChat() { + wx.removeStorageSync(SELECTED_SESSION_KEY); + this.setData({ sessionId: '', messages: [], draft: '', error: '' }); + }, + + openLink(event) { + let url = String(event.currentTarget.dataset.url || '').trim(); + if (!url) { + wx.showToast({ title: '链接无效', icon: 'none' }); + return; + } + const { publishKey, apiBaseUrl } = this.getMessageRenderOptions(); + if (/^public\//i.test(url)) { + const resolved = resolveWorkspacePublicUrl(url, publishKey, apiBaseUrl); + if (resolved) url = resolved; + } else if (url.startsWith('/')) { + url = `${String(apiBaseUrl).replace(/\/$/, '')}${url}`; + } + wx.navigateTo({ + url: `/pages/webview/index?url=${encodeURIComponent(url)}` + }); + }, + + copyMessage(event) { + const text = String(event.currentTarget.dataset.text || '').trim(); + if (!text) return; + wx.setClipboardData({ + data: text, + success() { + wx.showToast({ title: '已复制', icon: 'success' }); + }, + fail() { + wx.showToast({ title: '复制失败', icon: 'none' }); + } + }); + }, + + async handleLogout() { + this.setData({ loading: true, error: '' }); + try { + await logout(); + wx.redirectTo({ url: '/pages/login/index' }); + } catch (error) { + this.setData({ error: error?.message || '退出失败' }); + } finally { + this.setData({ loading: false }); + } + } +}); diff --git a/miniapp/pages/chat/index.json b/miniapp/pages/chat/index.json new file mode 100644 index 0000000..29fc0ed --- /dev/null +++ b/miniapp/pages/chat/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "TKMind 聊天" +} diff --git a/miniapp/pages/chat/index.wxml b/miniapp/pages/chat/index.wxml new file mode 100644 index 0000000..1d7c22f --- /dev/null +++ b/miniapp/pages/chat/index.wxml @@ -0,0 +1,73 @@ + + + + {{sessionId ? '继续会话' : '新会话'}} + {{error}} + + + + + + + + + + 发一条消息开始使用 TKMind。 + + + + + + {{part.text}} + {{part.text}} + + + + + {{page.title}} + + + + + + + + + + 正在思考... + + + + + + + + + diff --git a/miniapp/pages/chat/index.wxss b/miniapp/pages/chat/index.wxss new file mode 100644 index 0000000..35b4a92 --- /dev/null +++ b/miniapp/pages/chat/index.wxss @@ -0,0 +1,220 @@ +.chat-page { + display: flex; + flex-direction: column; + height: 100vh; + padding: 0; +} + +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16rpx; + padding: 20rpx 24rpx; + background: #ffffff; + border-bottom: 1rpx solid #dbe3eb; +} + +.chat-title { + display: flex; + flex-direction: column; + gap: 4rpx; + min-width: 0; + font-size: 30rpx; + font-weight: 800; +} + +.header-error { + max-width: 420rpx; + color: #b42318; + font-size: 22rpx; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.header-actions { + display: flex; + gap: 12rpx; +} + +.header-button { + width: 112rpx; + height: 58rpx; + line-height: 58rpx; + border-radius: 10rpx; + background: #eef6f5; + color: #0f766e; + font-size: 24rpx; +} + +.messages { + flex: 1; + box-sizing: border-box; + padding: 28rpx; +} + +.empty-state { + margin: 160rpx auto 0; + color: #64748b; + font-size: 28rpx; + text-align: center; +} + +.message-row { + display: flex; + align-items: flex-end; + gap: 12rpx; + margin-bottom: 20rpx; +} + +.message-row.user { + flex-direction: row-reverse; +} + +.message { + max-width: 84%; + padding: 22rpx 24rpx; + border-radius: 16rpx; + font-size: 29rpx; + line-height: 1.55; +} + +.message.user { + background: #0f766e; + color: #ffffff; +} + +.message.assistant { + background: #ffffff; + color: #132028; + border: 1rpx solid #dbe3eb; +} + +.message-body { + white-space: pre-wrap; + word-break: break-word; +} + +.message-text { + white-space: pre-wrap; +} + +.message-link { + color: #0ea5e9; + text-decoration: underline; +} + +.message.user .message-link { + color: #d1fae5; +} + +.page-links { + display: flex; + flex-direction: column; + gap: 12rpx; + margin-top: 18rpx; +} + +.page-link-card { + display: flex; + align-items: center; + justify-content: center; + padding: 20rpx 24rpx; + border-radius: 12rpx; + background: #0f766e; + border: none; +} + +.message.user .page-link-card { + background: rgba(255, 255, 255, 0.22); + border: 1rpx solid rgba(255, 255, 255, 0.28); +} + +.page-link-title { + display: block; + font-size: 28rpx; + font-weight: 700; + color: #ffffff; + text-align: center; +} + +.message.user .page-link-title { + color: #ffffff; +} + +.message-copy { + flex: 0 0 auto; + width: 52rpx; + height: 52rpx; + display: flex; + align-items: center; + justify-content: center; + border-radius: 10rpx; + background: #f8fafc; + border: 1rpx solid #e2e8f0; +} + +.copy-icon { + position: relative; + width: 22rpx; + height: 24rpx; +} + +.copy-icon::before, +.copy-icon::after { + content: ''; + position: absolute; + border: 2rpx solid #64748b; + border-radius: 3rpx; + box-sizing: border-box; +} + +.copy-icon::before { + top: 0; + left: 6rpx; + width: 16rpx; + height: 18rpx; + background: #ffffff; +} + +.copy-icon::after { + top: 6rpx; + left: 0; + width: 16rpx; + height: 18rpx; + background: #f8fafc; +} + +.composer { + display: flex; + align-items: flex-end; + gap: 16rpx; + padding: 18rpx; + border-left: 0; + border-right: 0; + border-bottom: 0; + border-radius: 0; +} + +.input { + flex: 1; + height: 72rpx; + padding: 0 20rpx; + border-radius: 12rpx; + background: #f8fafc; + box-sizing: border-box; + font-size: 28rpx; +} + +.send { + width: 136rpx; + height: 72rpx; + line-height: 72rpx; + border-radius: 12rpx; + background: #0f766e; + color: #fff; + font-weight: 700; + font-size: 28rpx; +} diff --git a/miniapp/pages/login/index.js b/miniapp/pages/login/index.js new file mode 100644 index 0000000..363d926 --- /dev/null +++ b/miniapp/pages/login/index.js @@ -0,0 +1,98 @@ +const { + checkAuth, + loginWithPassword, + loginWithWechatCode, + isTouristMode, + getApiBaseUrl +} = require('../../utils/api'); +const { MINIAPP_LOGIN_PATH } = require('../../utils/config'); + +Page({ + data: { + username: '', + password: '', + error: '', + wechatLoading: false, + passwordLoading: false, + touristWarning: '', + envHint: '' + }, + + onLoad() { + this.refreshEnvHints(); + this.tryAutoLogin(); + }, + + refreshEnvHints() { + const apiBaseUrl = getApiBaseUrl(); + let touristWarning = ''; + let envHint = ''; + + if (isTouristMode()) { + touristWarning = + '游客模式:微信一键登录不可用。请用 AppID wx3e79ecd530c88da4 重新导入项目,并确认 DevTools 登录微信号已是小程序管理员。'; + } + if (/127\.0\.0\.1|localhost/i.test(apiBaseUrl)) { + envHint = '当前连本地 Portal;请确保已运行 pnpm dev 并已重启。'; + } + + this.setData({ touristWarning, envHint }); + }, + + async tryAutoLogin() { + try { + const status = await checkAuth(); + if (status?.authenticated) { + const app = getApp(); + if (status.user) { + app.globalData.user = status.user; + } + wx.switchTab({ url: '/pages/chat/index' }); + } + } catch { + // Stay on the login page when the existing session cannot be verified. + } + }, + + handleUsername(event) { + this.setData({ username: event.detail.value, error: '' }); + }, + + handlePassword(event) { + this.setData({ password: event.detail.value, error: '' }); + }, + + async handleWechatLogin() { + this.setData({ wechatLoading: true, error: '' }); + try { + await loginWithWechatCode(MINIAPP_LOGIN_PATH); + wx.switchTab({ url: '/pages/chat/index' }); + } catch (error) { + this.setData({ + error: + error?.message || + '微信登录暂不可用。若后端尚未开放小程序登录接口,请先使用 H5 账号登录。' + }); + } finally { + this.setData({ wechatLoading: false }); + } + }, + + async handlePasswordLogin() { + const username = this.data.username.trim(); + const password = this.data.password; + if (!username || !password) { + this.setData({ error: '请输入用户名和密码' }); + return; + } + this.setData({ passwordLoading: true, error: '' }); + try { + await loginWithPassword(username, password); + wx.switchTab({ url: '/pages/chat/index' }); + } catch (error) { + this.setData({ error: error?.message || '登录失败,请重试' }); + } finally { + this.setData({ passwordLoading: false }); + } + } +}); diff --git a/miniapp/pages/login/index.json b/miniapp/pages/login/index.json new file mode 100644 index 0000000..261164b --- /dev/null +++ b/miniapp/pages/login/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "登录 TKMind" +} diff --git a/miniapp/pages/login/index.wxml b/miniapp/pages/login/index.wxml new file mode 100644 index 0000000..03b6bbc --- /dev/null +++ b/miniapp/pages/login/index.wxml @@ -0,0 +1,25 @@ + + + TKMind + 微信小程序工作台 + + + + {{touristWarning}} + {{envHint}} + + + + 或使用 H5 账号 + + + + + + {{error}} + + diff --git a/miniapp/pages/login/index.wxss b/miniapp/pages/login/index.wxss new file mode 100644 index 0000000..a3c1be2 --- /dev/null +++ b/miniapp/pages/login/index.wxss @@ -0,0 +1,72 @@ +.login-page { + display: flex; + flex-direction: column; + gap: 36rpx; + padding-top: 96rpx; +} + +.brand { + display: flex; + flex-direction: column; + gap: 12rpx; +} + +.brand-title { + font-size: 68rpx; + font-weight: 800; + color: #101820; +} + +.brand-subtitle { + font-size: 28rpx; + color: #5b6b7b; +} + +.login-card { + display: flex; + flex-direction: column; + gap: 24rpx; + padding: 32rpx; +} + +.divider { + display: flex; + justify-content: center; + color: #94a3b8; + font-size: 24rpx; +} + +.field { + height: 84rpx; + padding: 0 24rpx; + border: 1rpx solid #ccd6e0; + border-radius: 12rpx; + background: #f8fafc; + box-sizing: border-box; +} + +.error { + color: #b42318; + font-size: 24rpx; + line-height: 1.5; +} + +.warn-box, +.info-box { + padding: 20rpx 24rpx; + border-radius: 12rpx; + font-size: 24rpx; + line-height: 1.6; +} + +.warn-box { + background: #fff4e5; + color: #9a4b00; + border: 1rpx solid #ffd699; +} + +.info-box { + background: #eef6ff; + color: #1d4f91; + border: 1rpx solid #c7ddff; +} diff --git a/miniapp/pages/sessions/index.js b/miniapp/pages/sessions/index.js new file mode 100644 index 0000000..f498ad8 --- /dev/null +++ b/miniapp/pages/sessions/index.js @@ -0,0 +1,60 @@ +const { checkAuth, listSessions } = require('../../utils/api'); +const { SELECTED_SESSION_KEY } = require('../../utils/config'); + +function formatTime(value) { + const timestamp = Number(value || 0); + if (!timestamp) return ''; + const date = new Date(timestamp < 10_000_000_000 ? timestamp * 1000 : timestamp); + return `${date.getMonth() + 1}/${date.getDate()} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; +} + +Page({ + data: { + sessions: [], + loading: false, + error: '' + }, + + onShow() { + this.ensureAndLoad(); + }, + + async ensureAndLoad() { + try { + const status = await checkAuth(); + if (!status?.authenticated) { + wx.redirectTo({ url: '/pages/login/index' }); + return; + } + await this.loadSessions(); + } catch { + wx.redirectTo({ url: '/pages/login/index' }); + } + }, + + async loadSessions() { + this.setData({ loading: true, error: '' }); + try { + const result = await listSessions({ limit: 30 }); + const rows = result?.sessions || result?.data || result?.items || []; + this.setData({ + sessions: rows.map((item) => ({ + ...item, + id: item.id || item.sessionId, + updatedAtText: formatTime(item.updatedAt || item.updated_at || item.created || item.createdAt) + })) + }); + } catch (error) { + this.setData({ error: error?.message || '会话加载失败' }); + } finally { + this.setData({ loading: false }); + } + }, + + openSession(event) { + const id = event.currentTarget.dataset.id; + if (!id) return; + wx.setStorageSync(SELECTED_SESSION_KEY, id); + wx.switchTab({ url: '/pages/chat/index' }); + } +}); diff --git a/miniapp/pages/sessions/index.json b/miniapp/pages/sessions/index.json new file mode 100644 index 0000000..b8655af --- /dev/null +++ b/miniapp/pages/sessions/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "历史会话" +} diff --git a/miniapp/pages/sessions/index.wxml b/miniapp/pages/sessions/index.wxml new file mode 100644 index 0000000..60a1f04 --- /dev/null +++ b/miniapp/pages/sessions/index.wxml @@ -0,0 +1,17 @@ + + + 历史会话 + + + + {{error}} + + + 暂无会话 + + + + {{item.title || item.name || '未命名会话'}} + {{item.updatedAtText || ''}} + + diff --git a/miniapp/pages/sessions/index.wxss b/miniapp/pages/sessions/index.wxss new file mode 100644 index 0000000..b7d6994 --- /dev/null +++ b/miniapp/pages/sessions/index.wxss @@ -0,0 +1,50 @@ +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24rpx; +} + +.title { + font-size: 42rpx; + font-weight: 800; +} + +.reload { + width: 132rpx; + height: 64rpx; + line-height: 64rpx; + border-radius: 12rpx; + background: #e7f3f1; + color: #0f766e; + font-size: 26rpx; +} + +.session { + display: flex; + flex-direction: column; + gap: 10rpx; + padding: 24rpx; + margin-bottom: 18rpx; +} + +.session-title { + font-size: 30rpx; + font-weight: 700; +} + +.session-meta { + color: #64748b; + font-size: 24rpx; +} + +.empty { + padding: 40rpx; + text-align: center; + color: #64748b; +} + +.error { + margin-bottom: 20rpx; + color: #b42318; +} diff --git a/miniapp/pages/space/index.js b/miniapp/pages/space/index.js new file mode 100644 index 0000000..bc70dfd --- /dev/null +++ b/miniapp/pages/space/index.js @@ -0,0 +1,50 @@ +const { checkAuth, listMindSpacePages } = require('../../utils/api'); + +Page({ + data: { + pages: [], + loading: false, + error: '' + }, + + onShow() { + this.ensureAndLoad(); + }, + + async ensureAndLoad() { + try { + const status = await checkAuth(); + if (!status?.authenticated) { + wx.redirectTo({ url: '/pages/login/index' }); + return; + } + await this.loadPages(); + } catch { + wx.redirectTo({ url: '/pages/login/index' }); + } + }, + + async loadPages() { + this.setData({ loading: true, error: '' }); + try { + const result = await listMindSpacePages({ limit: 30 }); + const pages = result?.data || result?.pages || []; + this.setData({ pages }); + } catch (error) { + this.setData({ error: error?.message || '页面加载失败' }); + } finally { + this.setData({ loading: false }); + } + }, + + openPage(event) { + const url = event.currentTarget.dataset.url; + if (!url) { + wx.showToast({ title: '该页面暂无可打开链接', icon: 'none' }); + return; + } + wx.navigateTo({ + url: `/pages/webview/index?url=${encodeURIComponent(url)}` + }); + } +}); diff --git a/miniapp/pages/space/index.json b/miniapp/pages/space/index.json new file mode 100644 index 0000000..22dc5df --- /dev/null +++ b/miniapp/pages/space/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "MindSpace 页面" +} diff --git a/miniapp/pages/space/index.wxml b/miniapp/pages/space/index.wxml new file mode 100644 index 0000000..7573611 --- /dev/null +++ b/miniapp/pages/space/index.wxml @@ -0,0 +1,17 @@ + + + MindSpace + + + + {{error}} + + + 暂无页面 + + + + {{item.title || '未命名页面'}} + {{item.status || item.visibility || ''}} + + diff --git a/miniapp/pages/space/index.wxss b/miniapp/pages/space/index.wxss new file mode 100644 index 0000000..43a32cc --- /dev/null +++ b/miniapp/pages/space/index.wxss @@ -0,0 +1,50 @@ +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24rpx; +} + +.title { + font-size: 42rpx; + font-weight: 800; +} + +.reload { + width: 132rpx; + height: 64rpx; + line-height: 64rpx; + border-radius: 12rpx; + background: #e7f3f1; + color: #0f766e; + font-size: 26rpx; +} + +.page-card { + display: flex; + flex-direction: column; + gap: 10rpx; + padding: 24rpx; + margin-bottom: 18rpx; +} + +.page-title { + font-size: 30rpx; + font-weight: 700; +} + +.page-meta { + color: #64748b; + font-size: 24rpx; +} + +.empty { + padding: 40rpx; + text-align: center; + color: #64748b; +} + +.error { + margin-bottom: 20rpx; + color: #b42318; +} diff --git a/miniapp/pages/webview/index.js b/miniapp/pages/webview/index.js new file mode 100644 index 0000000..f20610c --- /dev/null +++ b/miniapp/pages/webview/index.js @@ -0,0 +1,9 @@ +Page({ + data: { + url: '' + }, + + onLoad(query) { + this.setData({ url: decodeURIComponent(query.url || '') }); + } +}); diff --git a/miniapp/pages/webview/index.json b/miniapp/pages/webview/index.json new file mode 100644 index 0000000..7c24b44 --- /dev/null +++ b/miniapp/pages/webview/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "页面预览" +} diff --git a/miniapp/pages/webview/index.wxml b/miniapp/pages/webview/index.wxml new file mode 100644 index 0000000..c165ac7 --- /dev/null +++ b/miniapp/pages/webview/index.wxml @@ -0,0 +1 @@ + diff --git a/miniapp/project.config.json b/miniapp/project.config.json new file mode 100644 index 0000000..2d0d287 --- /dev/null +++ b/miniapp/project.config.json @@ -0,0 +1,45 @@ +{ + "description": "TKMind WeChat Mini Program", + "packOptions": { + "ignore": [ + { + "value": "node_modules", + "type": "folder" + } + ], + "include": [] + }, + "setting": { + "urlCheck": true, + "es6": true, + "enhance": true, + "postcss": true, + "minified": true, + "compileWorklet": false, + "uglifyFileName": false, + "uploadWithSourceMap": true, + "packNpmManually": false, + "packNpmRelationList": [], + "minifyWXSS": true, + "minifyWXML": true, + "localPlugins": false, + "disableUseStrict": false, + "useCompilerPlugins": false, + "condition": false, + "swc": false, + "disableSWC": true, + "babelSetting": { + "ignore": [], + "disablePlugins": [], + "outputPath": "" + } + }, + "compileType": "miniprogram", + "miniprogramRoot": "./", + "libVersion": "3.16.2", + "appid": "wx3e79ecd530c88da4", + "projectname": "tkmind-miniapp", + "condition": {}, + "simulatorPluginLibVersion": {}, + "editorSetting": {} +} \ No newline at end of file diff --git a/miniapp/sitemap.json b/miniapp/sitemap.json new file mode 100644 index 0000000..3869a2e --- /dev/null +++ b/miniapp/sitemap.json @@ -0,0 +1,8 @@ +{ + "rules": [ + { + "action": "disallow", + "page": "*" + } + ] +} diff --git a/miniapp/utils/api.js b/miniapp/utils/api.js new file mode 100644 index 0000000..3bb6cd0 --- /dev/null +++ b/miniapp/utils/api.js @@ -0,0 +1,288 @@ +const SESSION_KEY = 'tkmind_session'; +const { createUuid, SESSION_COOKIE_NAME } = require('./config'); + +let apiBaseUrl = ''; +let cookie = ''; + +function buildSessionCookie(token) { + const value = String(token ?? '').trim(); + if (!value) return ''; + return `${SESSION_COOKIE_NAME}=${encodeURIComponent(value)}`; +} + +function applySessionToken(token) { + const nextCookie = buildSessionCookie(token); + if (!nextCookie) return; + cookie = nextCookie; + const previous = getStoredSession() || {}; + saveSession({ ...previous, cookie: nextCookie }); +} + +function restoreSessionCookie() { + try { + const session = wx.getStorageSync(SESSION_KEY); + if (session?.cookie) { + cookie = session.cookie; + } + } catch { + // Ignore storage read errors during cold start. + } +} + +restoreSessionCookie(); + +function setApiBaseUrl(value) { + apiBaseUrl = String(value || '').replace(/\/$/, ''); +} + +function getMiniProgramAppId() { + try { + return wx.getAccountInfoSync?.()?.miniProgram?.appId || ''; + } catch { + return ''; + } +} + +function isTouristMode() { + const appId = getMiniProgramAppId(); + return !appId || appId === 'touristappid'; +} + +function getApiBaseUrl() { + return apiBaseUrl; +} + +function formatRequestError(res, url) { + const status = Number(res.statusCode || 0); + const code = res.data?.error?.code || ''; + const serverMessage = res.data?.message || res.data?.error?.message || ''; + if (status === 403 && code === 'csrf_failed') { + if (/m\.tkmind\.cn/i.test(apiBaseUrl)) { + return '线上 Portal 尚未部署小程序登录接口(403 来源校验失败)。请先用账号密码登录,或改用本地 Portal 联调。'; + } + return serverMessage || '请求被服务器拒绝(来源校验失败)'; + } + if (status === 404) { + return '登录接口不存在,请重启本地 Portal(pnpm dev)后再试'; + } + if (serverMessage) return serverMessage; + if (status === 503) return '服务暂不可用,请稍后重试或使用账号密码登录'; + if (status === 405) { + return `接口不支持当前请求方法,请检查 API_BASE_URL 是否指向 Portal:${url}`; + } + return `请求失败 (${status || 'network'})`; +} + +function getStoredSession() { + const session = wx.getStorageSync(SESSION_KEY); + if (session && typeof session === 'object') { + cookie = session.cookie || cookie; + return session; + } + return null; +} + +function saveSession(session) { + cookie = session?.cookie || cookie; + wx.setStorageSync(SESSION_KEY, session || {}); +} + +function clearSession() { + cookie = ''; + wx.removeStorageSync(SESSION_KEY); +} + +function normalizeSetCookie(headers = {}) { + const raw = headers['Set-Cookie'] || headers['set-cookie']; + if (!raw) return ''; + const items = Array.isArray(raw) ? raw : [String(raw)]; + let sessionCookie = ''; + for (const item of items) { + const firstPart = String(item).trim().split(';')[0].trim(); + const separator = firstPart.indexOf('='); + if (separator <= 0) continue; + const name = firstPart.slice(0, separator); + const value = firstPart.slice(separator + 1); + if (name === SESSION_COOKIE_NAME && value) { + sessionCookie = `${name}=${value}`; + } + } + return sessionCookie; +} + +function persistLoginResult(result, loginType) { + if (result?.sessionToken) { + applySessionToken(result.sessionToken); + } + saveSession({ + cookie, + user: result?.user || null, + loginType, + }); + const app = getApp?.(); + if (app && result?.user) { + app.globalData.user = result.user; + } +} + +function request(path, options = {}) { + const url = /^https?:\/\//i.test(path) ? path : `${apiBaseUrl}${path}`; + const header = { + Accept: 'application/json', + ...(options.body ? { 'Content-Type': 'application/json' } : {}), + ...(cookie ? { Cookie: cookie } : {}), + ...(options.header || {}) + }; + + return new Promise((resolve, reject) => { + wx.request({ + url, + method: options.method || 'GET', + data: options.body == null ? undefined : options.body, + header, + timeout: options.timeout || 20000, + success(res) { + const nextCookie = normalizeSetCookie(res.header); + if (nextCookie) { + cookie = nextCookie; + const previous = getStoredSession() || {}; + saveSession({ ...previous, cookie }); + } + const status = Number(res.statusCode || 0); + if (status >= 200 && status < 300) { + resolve(res.data); + return; + } + const message = formatRequestError(res, url); + const error = new Error(message); + error.status = status; + error.body = res.data; + reject(error); + }, + fail(err) { + reject(new Error(err?.errMsg || '网络请求失败')); + } + }); + }); +} + +function portalRequest(path, options = {}) { + return request(path, options); +} + +function apiRequest(path, options = {}) { + return request(`/api${path}`, options); +} + +async function checkAuth() { + return portalRequest('/auth/status'); +} + +async function getMe() { + return portalRequest('/auth/me'); +} + +async function loginWithPassword(username, password) { + const result = await portalRequest('/auth/login', { + method: 'POST', + body: { username, password } + }); + persistLoginResult(result, 'password'); + return result; +} + +function wxLogin() { + return new Promise((resolve, reject) => { + wx.login({ + success(res) { + if (res.code) resolve(res.code); + else reject(new Error('微信登录未返回 code')); + }, + fail(err) { + reject(new Error(err?.errMsg || '微信登录失败')); + } + }); + }); +} + +async function loginWithWechatCode(loginPath) { + if (isTouristMode()) { + throw new Error( + '当前是游客模式(无 AppID 关联)。请在微信开发者工具用 AppID wx3e79ecd530c88da4 重新导入项目,并确认登录 DevTools 的微信号已是该小程序管理员。' + ); + } + const code = await wxLogin(); + const result = await portalRequest(loginPath, { + method: 'POST', + body: { code } + }); + persistLoginResult(result, 'wechat'); + return result; +} + +async function logout() { + try { + await portalRequest('/auth/logout', { method: 'POST' }); + } finally { + clearSession(); + } +} + +async function listSessions(options = {}) { + const limit = options.limit || 30; + const cursor = options.cursor ? `&cursor=${encodeURIComponent(options.cursor)}` : ''; + return apiRequest(`/sessions?limit=${encodeURIComponent(limit)}${cursor}`); +} + +async function loadSession(sessionId) { + return apiRequest(`/sessions/${encodeURIComponent(sessionId)}`); +} + +async function createAgentRun(input = {}) { + const body = { + ...input, + request_id: input.request_id || createUuid(), + }; + if (!body.session_id) { + delete body.session_id; + } + const result = await apiRequest('/agent/runs', { + method: 'POST', + body, + timeout: 60000 + }); + return result?.run ?? result; +} + +async function getAgentRun(runId) { + const result = await apiRequest(`/agent/runs/${encodeURIComponent(runId)}`); + return result?.run ?? result; +} + +async function listMindSpacePages(options = {}) { + const limit = options.limit || 30; + const offset = options.offset || 0; + return apiRequest( + `/mindspace/v1/pages?limit=${encodeURIComponent(limit)}&offset=${encodeURIComponent(offset)}` + ); +} + +module.exports = { + setApiBaseUrl, + getApiBaseUrl, + getMiniProgramAppId, + isTouristMode, + getStoredSession, + saveSession, + clearSession, + checkAuth, + getMe, + loginWithPassword, + loginWithWechatCode, + logout, + listSessions, + loadSession, + createAgentRun, + getAgentRun, + listMindSpacePages +}; diff --git a/miniapp/utils/config.js b/miniapp/utils/config.js new file mode 100644 index 0000000..f6efee0 --- /dev/null +++ b/miniapp/utils/config.js @@ -0,0 +1,39 @@ +// Production default for miniapp release / 提审. +// Local Portal debug: set LOCAL_DEV = true, or copy config.local.example.js → config.local.js +const LOCAL_DEV = false; + +function createUuid() { + const bytes = []; + for (let i = 0; i < 16; i += 1) { + bytes.push(Math.floor(Math.random() * 256)); + } + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = bytes.map((byte) => byte.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +const defaults = { + API_BASE_URL: LOCAL_DEV ? 'http://127.0.0.1:8081' : 'https://m.tkmind.cn', + MINIAPP_LOGIN_PATH: '/auth/wechat-miniapp/login', + SESSION_COOKIE_NAME: 'tkmind_user_session', + SELECTED_SESSION_KEY: 'tkmind_selected_session_id', + AGENT_RUN_POLL_MS: 1200, + AGENT_RUN_MAX_POLLS: 120, +}; + +let localOverrides = {}; +try { + localOverrides = require('./config.local.js'); +} catch { + // Optional gitignored overrides in utils/config.local.js +} + +module.exports = { + createUuid, + ...defaults, + ...localOverrides, + ...(localOverrides.API_BASE_URL + ? { API_BASE_URL: String(localOverrides.API_BASE_URL).replace(/\/$/, '') } + : {}), +}; diff --git a/miniapp/utils/config.local.example.js b/miniapp/utils/config.local.example.js new file mode 100644 index 0000000..4c7423a --- /dev/null +++ b/miniapp/utils/config.local.example.js @@ -0,0 +1,5 @@ +// Copy to config.local.js (gitignored) to force local Portal while preparing a release. +// Remove or rename config.local.js to use production https://m.tkmind.cn from config.js. +module.exports = { + API_BASE_URL: 'http://127.0.0.1:8081', +}; diff --git a/miniapp/utils/message-display.js b/miniapp/utils/message-display.js new file mode 100644 index 0000000..47dd1cf --- /dev/null +++ b/miniapp/utils/message-display.js @@ -0,0 +1,336 @@ +const TASK_ROUTING_HINT_RE = /^【TKMind 路由提示】[\s\S]*?\n\n/; +const MEMIND_TASK_ORCHESTRATION_RE = /^【Memind 任务编排】[\s\S]*?(?:用户任务:|用户任务:)\s*/u; +const MINDSPACE_CONTEXT_RE = /^\[MindSpace 上下文\][\s\S]*?\n\n/; +const USER_IDENTITY_BLOCK_RE = /^\[用户身份\][\s\S]*?\n\n/; +const IMAGE_URL_LINES_RE = /\n*\[图片\d+]: [^\n]+/g; +const FILE_ATTACHMENT_LINES_RE = /\n*\[文件\d+: [^\]]+\]: [^\n]+/g; +const ASSISTANT_DELIVERABLE_RE = + /\[[^\]]+\]\(https?:\/\/[^)]+\)|https?:\/\/(?:m\.)?[^/\s]*tkmind\.(?:cn|ai)\/(?:MindSpace|u)\/|\bpublic\/[A-Za-z0-9._-]+\.html\b/i; +const INTERNAL_ASSISTANT_MARKERS = [ + /data-mindspace-page-tag/i, + /mindspace-cover/i, + /\bload_skill\b/i, + /static-page-publish/i, + /\.agents\/skills/i, + /SKILL\.md/i, + /注意到技能/u, + /技能更新/u, + /页脚要用.*platform-brand/u, +]; +const URL_PATTERN = + /https?:\/\/[^\s<>"')\]]+\/(?:MindSpace|temp)\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html)/gi; +const PUBLICATION_URL_PATTERN = + /https?:\/\/[^\s<>"')\]]+\/u\/([a-z0-9._-]+)\/pages\/([^\s<>"')\]]+)/gi; +const MARKDOWN_LINK_RE = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g; +const COMBINED_LINK_RE = + /\[([^\]]+)\]\((https?:\/\/[^)]+)\)|(https?:\/\/[^\s<>"')\]]+)|`?(public\/[A-Za-z0-9._-]+\.html)`?|(\/?MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/(public\/[A-Za-z0-9._-]+\.html))/gi; +const RELATIVE_PUBLIC_HTML_RE = /\b(public\/[A-Za-z0-9._-]+\.html)\b/gi; + +function stripImageUrlLines(text) { + return String(text ?? '') + .replace(IMAGE_URL_LINES_RE, '') + .replace(FILE_ATTACHMENT_LINES_RE, '') + .trim(); +} + +function stripUserFacingPrefixes(text) { + let next = String(text ?? ''); + next = next.replace(USER_IDENTITY_BLOCK_RE, '').trimStart(); + next = next.replace(TASK_ROUTING_HINT_RE, '').trimStart(); + next = next.replace(MEMIND_TASK_ORCHESTRATION_RE, '').trimStart(); + while (MINDSPACE_CONTEXT_RE.test(next)) { + next = next.replace(MINDSPACE_CONTEXT_RE, '').trimStart(); + } + return next.trim(); +} + +function deriveAssistantFacingText(text) { + const trimmed = String(text ?? '').trim(); + if (!trimmed) return ''; + if (ASSISTANT_DELIVERABLE_RE.test(trimmed)) return trimmed; + if (INTERNAL_ASSISTANT_MARKERS.some((pattern) => pattern.test(trimmed))) return ''; + return trimmed; +} + +function decodeSegment(segment) { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +function normalizeWorkspaceRelativePath(relativePath) { + const parts = String(relativePath ?? '') + .replace(/^\/+/, '') + .split('/') + .filter((part) => part && part !== '.' && part !== '..'); + if (parts.length === 0) return ''; + if (parts[0]?.toLowerCase() === 'public') return parts.join('/'); + if (parts.length === 1 && parts[0].toLowerCase().endsWith('.html')) return `public/${parts[0]}`; + return parts.join('/'); +} + +function resolveWorkspacePublicUrl(relativePath, publishKey, apiBaseUrl) { + const normalized = normalizeWorkspaceRelativePath(relativePath); + if (!publishKey || !normalized.startsWith('public/') || !/\.html$/i.test(normalized)) { + return null; + } + const base = String(apiBaseUrl || '').replace(/\/$/, ''); + if (!base) return null; + const encodedPath = normalized.split('/').map((part) => encodeURIComponent(part)).join('/'); + return `${base}/MindSpace/${encodeURIComponent(publishKey)}/${encodedPath}`; +} + +function buildPageLinkFromRelativePath(relativePath, publishKey, apiBaseUrl) { + const normalized = normalizeWorkspaceRelativePath(relativePath); + const publicUrl = resolveWorkspacePublicUrl(normalized, publishKey, apiBaseUrl); + if (!publicUrl) return null; + const filename = normalized.split('/').pop() || normalized; + return { + publicUrl, + filename, + title: filename.replace(/\.html$/i, '').replace(/[-_]+/g, ' '), + relativePath: normalized, + }; +} + +function extractRelativePublicPageLinks(content, publishKey, apiBaseUrl) { + if (!publishKey || !apiBaseUrl) return []; + const links = []; + const seen = new Set(); + for (const match of String(content ?? '').matchAll(RELATIVE_PUBLIC_HTML_RE)) { + const link = buildPageLinkFromRelativePath(match[1], publishKey, apiBaseUrl); + if (!link || seen.has(link.publicUrl)) continue; + seen.add(link.publicUrl); + links.push(link); + } + return links; +} + +function mergePageLinks(...groups) { + const links = []; + const seen = new Set(); + for (const group of groups) { + for (const link of group || []) { + if (!link?.publicUrl || seen.has(link.publicUrl)) continue; + seen.add(link.publicUrl); + links.push(link); + } + } + return links; +} + +function normalizeStaticHtmlRelativePath(relativePath) { + const parts = String(relativePath ?? '') + .replace(/^\/+/, '') + .split('/') + .filter((part) => part && part !== '.' && part !== '..'); + if (parts.length === 0) return ''; + if (parts[0]?.toLowerCase() === 'public') return ['public', ...parts.slice(1)].join('/'); + if (parts.length === 1 && parts[0].toLowerCase().endsWith('.html')) return `public/${parts[0]}`; + return parts.join('/'); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function encodeUrlPath(relativePath) { + return relativePath + .split('/') + .filter(Boolean) + .map((part) => encodeURIComponent(part)) + .join('/'); +} + +function canonicalizeStaticPageUrl(publicUrl, originalRelativePath) { + const canonicalRelativePath = normalizeStaticHtmlRelativePath(originalRelativePath); + const originalClean = originalRelativePath.replace(/^\/+/, ''); + if (!canonicalRelativePath || canonicalRelativePath === originalClean) return publicUrl; + const suffix = escapeRegExp(encodeUrlPath(originalClean)); + return publicUrl.replace(new RegExp(`${suffix}$`), encodeUrlPath(canonicalRelativePath)); +} + +function extractStaticPageLinks(content) { + const links = []; + const seen = new Set(); + const source = String(content ?? ''); + for (const match of source.matchAll(URL_PATTERN)) { + const relativePath = decodeSegment(match[2]); + const filename = relativePath.split('/').pop() || relativePath; + const publicUrl = canonicalizeStaticPageUrl(match[0], relativePath); + if (seen.has(publicUrl)) continue; + seen.add(publicUrl); + links.push({ + publicUrl, + filename, + title: filename.replace(/\.html$/i, '').replace(/[-_]+/g, ' '), + }); + } + for (const match of source.matchAll(PUBLICATION_URL_PATTERN)) { + const slug = decodeSegment(match[2]).replace(/\/$/, ''); + const publicUrl = match[0].replace(/\/$/, ''); + if (seen.has(publicUrl)) continue; + seen.add(publicUrl); + const filename = slug.split('/').pop() || slug; + links.push({ + publicUrl, + filename, + title: filename.replace(/\.html$/i, '').replace(/[-_]+/g, ' '), + }); + } + return links; +} + +function normalizeUrlForMatch(url) { + return String(url ?? '').replace(/\/$/, '').trim(); +} + +function cleanupOrphanMarkdownDecorations(text) { + return String(text ?? '') + .replace(/(?:\*\*){1,}/g, '') + .replace(/^[👉🔗\s\-•*::]+$/gm, '') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +function stripPageDeliverableLinks(text, pageLinks) { + if (!pageLinks?.length) return String(text ?? '').trim(); + const urlSet = new Set(pageLinks.map((link) => normalizeUrlForMatch(link.publicUrl))); + let next = String(text ?? ''); + const markdownWithDecorationRe = + /[👉🔗\s]*(?:\*\*)?\[([^\]]+)\]\((https?:\/\/[^)]+)\)(?:\*\*)?/g; + next = next.replace(markdownWithDecorationRe, (full, _label, url) => + urlSet.has(normalizeUrlForMatch(url)) ? '' : full, + ); + for (const url of urlSet) { + next = next.replace(new RegExp(escapeRegExp(url) + '\\/?', 'gi'), ''); + } + return cleanupOrphanMarkdownDecorations(next); +} + +function parseMessageParts(text, options = {}) { + const { publishKey = '', apiBaseUrl = '' } = options; + const input = String(text ?? ''); + if (!input) return []; + const parts = []; + let lastIndex = 0; + let match; + COMBINED_LINK_RE.lastIndex = 0; + while ((match = COMBINED_LINK_RE.exec(input)) !== null) { + if (match.index > lastIndex) { + parts.push({ type: 'text', text: input.slice(lastIndex, match.index) }); + } + if (match[1] && match[2]) { + parts.push({ type: 'link', text: match[1], url: match[2] }); + } else if (match[3]) { + parts.push({ type: 'link', text: match[3], url: match[3] }); + } else if (match[4]) { + const relativePath = match[4].replace(/`/g, ''); + const link = buildPageLinkFromRelativePath(relativePath, publishKey, apiBaseUrl); + parts.push({ + type: 'link', + text: link?.title || relativePath, + url: link?.publicUrl || relativePath, + }); + } else if (match[5] && match[6]) { + const encodedPath = match[5].replace(/^\/+/, ''); + const publicUrl = resolveWorkspacePublicUrl( + encodedPath.slice(encodedPath.indexOf('public/')), + match[6], + apiBaseUrl, + ) || (apiBaseUrl ? `${String(apiBaseUrl).replace(/\/$/, '')}/${encodedPath}` : match[5]); + parts.push({ + type: 'link', + text: buildPageLinkFromRelativePath( + encodedPath.slice(encodedPath.indexOf('public/')), + match[6], + apiBaseUrl, + )?.title || encodedPath.split('/').pop(), + url: publicUrl, + }); + } + lastIndex = match.index + match[0].length; + } + if (lastIndex < input.length) { + parts.push({ type: 'text', text: input.slice(lastIndex) }); + } + return parts.length ? parts : [{ type: 'text', text: input }]; +} + +function getRawMessageText(message) { + if (!message) return ''; + if (typeof message.text === 'string') return message.text; + if (Array.isArray(message.content)) { + return message.content + .filter((part) => part && part.type === 'text') + .map((part) => part.text || '') + .join('\n'); + } + return ''; +} + +function getDisplayText(message) { + const rawText = getRawMessageText(message); + if (message?.role === 'user') { + if (message.metadata?.displayText != null) { + return stripImageUrlLines(message.metadata.displayText); + } + return stripImageUrlLines(stripUserFacingPrefixes(rawText)); + } + if (message?.metadata?.displayText != null) { + return stripImageUrlLines(deriveAssistantFacingText(message.metadata.displayText)); + } + return stripImageUrlLines(deriveAssistantFacingText(rawText)); +} + +function buildMessageView(message, options = {}) { + const { publishKey = '', apiBaseUrl = '' } = options; + const rawText = getRawMessageText(message); + const displayText = getDisplayText(message); + const sourceText = displayText || rawText; + const pageLinks = mergePageLinks( + extractStaticPageLinks(sourceText), + extractRelativePublicPageLinks(sourceText, publishKey, apiBaseUrl), + ); + let text = displayText; + if (!text && pageLinks.length > 0) { + text = '页面已生成,可点击下方链接查看。'; + } + if (!text.trim()) return null; + + const markdownTitleByUrl = {}; + for (const match of String(sourceText).matchAll(MARKDOWN_LINK_RE)) { + markdownTitleByUrl[match[2]] = match[1]; + } + const enrichedPageLinks = pageLinks.map((link) => ({ + ...link, + title: markdownTitleByUrl[link.publicUrl] || link.title, + })); + const renderText = stripPageDeliverableLinks(text, enrichedPageLinks); + const pageLinkUrls = new Set(enrichedPageLinks.map((link) => normalizeUrlForMatch(link.publicUrl))); + const parts = parseMessageParts(renderText, { publishKey, apiBaseUrl }).filter( + (part) => + part.type !== 'link' || !pageLinkUrls.has(normalizeUrlForMatch(part.url)), + ); + + return { + id: message.id || `${message.role}_${message.created || Date.now()}`, + role: message.role || 'assistant', + text: renderText, + copyText: renderText, + parts, + pageLinks: enrichedPageLinks, + created: message.created || 0, + }; +} + +module.exports = { + buildMessageView, + extractStaticPageLinks, + extractRelativePublicPageLinks, + getDisplayText, + parseMessageParts, + resolveWorkspacePublicUrl, +}; diff --git a/miniapp/utils/messages.js b/miniapp/utils/messages.js new file mode 100644 index 0000000..107848e --- /dev/null +++ b/miniapp/utils/messages.js @@ -0,0 +1,41 @@ +const { createUuid } = require('./config'); +const { buildMessageView } = require('./message-display'); + +function createUserMessage(text) { + return { + id: createUuid(), + role: 'user', + created: Math.floor(Date.now() / 1000), + content: [{ type: 'text', text }], + metadata: { + source: 'wechat-miniapp', + userVisible: true, + agentVisible: true, + displayText: text + } + }; +} + +function messageText(message) { + if (!message) return ''; + if (typeof message.text === 'string') return message.text; + if (Array.isArray(message.content)) { + return message.content + .filter((part) => part && part.type === 'text') + .map((part) => part.text || '') + .join(''); + } + return ''; +} + +function normalizeMessages(messages = [], options = {}) { + return messages + .map((message) => buildMessageView(message, options)) + .filter(Boolean); +} + +module.exports = { + createUserMessage, + messageText, + normalizeMessages +}; diff --git a/server.mjs b/server.mjs index 7b7580c..ec97526 100644 --- a/server.mjs +++ b/server.mjs @@ -172,6 +172,7 @@ import { WECHAT_NOTIFY_SUCCESS_V2, } from './wechat-pay.mjs'; import { createWechatOAuthService, isWechatUserAgent, loadWechatOAuthConfig } from './wechat-oauth.mjs'; +import { exchangeMiniProgramCode, loadWechatMiniappConfig } from './wechat-miniapp.mjs'; import { loadWechatMpConfig } from './wechat-mp-config.mjs'; import { loadWechatMpModule } from './wechat-mp-loader.mjs'; import { validateWechatShareSignatureUrl } from './wechat-share.mjs'; @@ -262,6 +263,15 @@ app.use((req, res, next) => { next(); }); +function isWechatMiniProgramSource(value) { + if (!value) return false; + try { + return new URL(value).hostname.endsWith('servicewechat.com'); + } catch { + return false; + } +} + function csrfOriginCheck(req, res, next) { if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next(); const host = req.get('host'); @@ -269,6 +279,9 @@ function csrfOriginCheck(req, res, next) { const referer = req.get('referer'); if (!origin && !referer) return next(); const requestHostname = (host ?? '').split(':')[0]; + if ([origin, referer].some(isWechatMiniProgramSource)) { + return next(); + } const allowed = [origin, referer].some((value) => { if (!value) return false; try { @@ -884,7 +897,12 @@ app.post('/auth/login', jsonBody, async (req, res) => { return res.status(401).json({ message: result.message }); } setUserLoginCookies(res, req, result.token); - return res.json({ authenticated: true, user: result.user, mode: 'user' }); + return res.json({ + authenticated: true, + user: result.user, + mode: 'user', + sessionToken: result.token, + }); } if (!legacyAuth) { @@ -904,6 +922,48 @@ app.post('/auth/login', jsonBody, async (req, res) => { return res.json({ authenticated: true, mode: 'legacy' }); }); +app.post('/auth/wechat-miniapp/login', jsonBody, async (req, res) => { + await userAuthReady; + if (!userAuth) { + return res.status(503).json({ message: '未启用用户系统' }); + } + const miniappConfig = loadWechatMiniappConfig(); + if (!miniappConfig.enabled) { + return res.status(503).json({ message: '小程序登录未配置,请联系管理员' }); + } + const code = typeof req.body?.code === 'string' ? req.body.code.trim() : ''; + if (!code) { + return res.status(400).json({ message: '缺少微信登录 code' }); + } + try { + const session = await exchangeMiniProgramCode({ + appId: miniappConfig.appId, + appSecret: miniappConfig.appSecret, + code, + }); + const result = await userAuth.loginByWechatMiniProgram({ + appId: miniappConfig.appId, + openid: session.openid, + unionid: session.unionid, + }); + if (!result.ok) { + return res.status(401).json({ message: result.message || '微信登录失败' }); + } + setUserLoginCookies(res, req, result.token); + return res.json({ + authenticated: true, + user: result.user, + mode: 'user', + isNewUser: Boolean(result.isNewUser), + sessionToken: result.token, + }); + } catch (err) { + const message = err instanceof Error ? err.message : '微信登录失败'; + const status = err?.code === 'wechat_miniapp_code_failed' ? 401 : 400; + return res.status(status).json({ message }); + } +}); + app.post('/auth/register', jsonBody, async (req, res) => { await userAuthReady; if (!userAuth) { diff --git a/user-auth.mjs b/user-auth.mjs index 92d3a22..f6a9595 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -2865,11 +2865,52 @@ export function createUserAuth(pool, options = {}) { return result; }; + const loginByWechatMiniProgram = async ({ + appId, + openid, + unionid, + now = Date.now(), + }) => { + const existing = await findWechatUserByOpenid(appId, openid); + if (existing) { + if (existing.status === 'disabled') { + return { ok: false, message: '账户已禁用,请联系管理员' }; + } + return loginBoundWechatUser({ + userId: existing.userId, + appId, + openid, + unionid, + nickname: existing.nickname, + avatarUrl: null, + now, + }); + } + + const registered = await registerViaWechat({ + appId, + openid, + unionid, + nickname: '微信用户', + avatarUrl: null, + now, + }); + if (!registered.ok) return registered; + const token = await issueUserSession(registered.user.id, registered.user.role, now); + return { + ok: true, + token, + user: registered.user, + isNewUser: true, + }; + }; + return { USER_COOKIE, register, login, loginByWechat, + loginByWechatMiniProgram, resolveWechatAuth, completeWechatRegister, completeWechatBindAccount, diff --git a/wechat-miniapp.mjs b/wechat-miniapp.mjs new file mode 100644 index 0000000..1c1077e --- /dev/null +++ b/wechat-miniapp.mjs @@ -0,0 +1,63 @@ +import { fetch } from 'undici'; + +const JSCODE2SESSION_URL = 'https://api.weixin.qq.com/sns/jscode2session'; + +export function loadWechatMiniappConfig(env = process.env) { + const appId = + env.H5_WECHAT_MINIAPP_APP_ID?.trim() ?? + env.H5_WECHAT_APP_ID?.trim() ?? + ''; + const appSecret = + env.H5_WECHAT_MINIAPP_APP_SECRET?.trim() ?? + env.H5_WECHAT_APP_SECRET?.trim() ?? + ''; + return { + enabled: Boolean(appId && appSecret), + appId, + appSecret, + }; +} + +export async function exchangeMiniProgramCode( + { appId, appSecret, code, fetchImpl = fetch } = {}, +) { + const jsCode = String(code ?? '').trim(); + if (!appId || !appSecret) { + throw Object.assign(new Error('小程序登录未配置 AppID/AppSecret'), { + code: 'wechat_miniapp_not_configured', + }); + } + if (!jsCode) { + throw Object.assign(new Error('缺少微信登录 code'), { code: 'invalid_code' }); + } + + const url = new URL(JSCODE2SESSION_URL); + url.searchParams.set('appid', appId); + url.searchParams.set('secret', appSecret); + url.searchParams.set('js_code', jsCode); + url.searchParams.set('grant_type', 'authorization_code'); + + const response = await fetchImpl(url); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw Object.assign(new Error('微信登录服务暂不可用'), { + code: 'wechat_miniapp_http_failed', + status: response.status, + }); + } + if (payload.errcode) { + throw Object.assign(new Error(payload.errmsg || '微信 code 无效或已过期'), { + code: 'wechat_miniapp_code_failed', + errcode: payload.errcode, + }); + } + const openid = String(payload.openid ?? '').trim(); + if (!openid) { + throw Object.assign(new Error('微信未返回 openid'), { code: 'wechat_miniapp_openid_missing' }); + } + return { + openid, + sessionKey: payload.session_key ?? null, + unionid: payload.unionid ? String(payload.unionid).trim() : null, + }; +} diff --git a/wechat-miniapp.test.mjs b/wechat-miniapp.test.mjs new file mode 100644 index 0000000..7260b07 --- /dev/null +++ b/wechat-miniapp.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { exchangeMiniProgramCode, loadWechatMiniappConfig } from './wechat-miniapp.mjs'; + +test('loadWechatMiniappConfig prefers miniapp-specific env vars', () => { + const config = loadWechatMiniappConfig({ + H5_WECHAT_MINIAPP_APP_ID: 'wx-mini', + H5_WECHAT_MINIAPP_APP_SECRET: 'secret-mini', + H5_WECHAT_APP_ID: 'wx-other', + H5_WECHAT_APP_SECRET: 'secret-other', + }); + assert.equal(config.enabled, true); + assert.equal(config.appId, 'wx-mini'); + assert.equal(config.appSecret, 'secret-mini'); +}); + +test('exchangeMiniProgramCode calls jscode2session and returns openid', async () => { + const calls = []; + const result = await exchangeMiniProgramCode({ + appId: 'wxtest', + appSecret: 'secret', + code: 'abc123', + fetchImpl: async (url) => { + calls.push(String(url)); + return { + ok: true, + async json() { + return { openid: 'openid-1', session_key: 'sk', unionid: 'union-1' }; + }, + }; + }, + }); + assert.equal(result.openid, 'openid-1'); + assert.equal(result.unionid, 'union-1'); + assert.match(calls[0], /jscode2session\?/); + assert.match(calls[0], /js_code=abc123/); +}); + +test('exchangeMiniProgramCode surfaces WeChat errcode', async () => { + await assert.rejects( + () => + exchangeMiniProgramCode({ + appId: 'wxtest', + appSecret: 'secret', + code: 'bad', + fetchImpl: async () => ({ + ok: true, + async json() { + return { errcode: 40029, errmsg: 'invalid code' }; + }, + }), + }), + (error) => { + assert.equal(error.code, 'wechat_miniapp_code_failed'); + assert.match(error.message, /invalid code/); + return true; + }, + ); +});