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 @@
+
+
+
+
+
+ 发一条消息开始使用 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;
+ },
+ );
+});