Merge origin/main into feature/0711-dev before landing schedule reminder fix.

This commit is contained in:
john
2026-07-12 08:11:50 +08:00
36 changed files with 2168 additions and 1 deletions
+4
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
node_modules/
miniprogram_npm/
project.private.config.json
utils/config.local.js
*.local
+61
View File
@@ -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.
+22
View File
@@ -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;
}
},
});
+36
View File
@@ -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"
}
+45
View File
@@ -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;
}
+252
View File
@@ -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 });
}
}
});
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "TKMind 聊天"
}
+73
View File
@@ -0,0 +1,73 @@
<view class="page chat-page">
<view class="chat-header">
<view class="chat-title">
<text>{{sessionId ? '继续会话' : '新会话'}}</text>
<text wx:if="{{error}}" class="header-error">{{error}}</text>
</view>
<view class="header-actions">
<button class="header-button" bindtap="startNewChat">新聊天</button>
<button class="header-button" bindtap="handleLogout">退出</button>
</view>
</view>
<scroll-view class="messages" scroll-y scroll-into-view="{{scrollIntoView}}">
<view wx:if="{{messages.length === 0 && !loading}}" class="empty-state">
<text>发一条消息开始使用 TKMind。</text>
</view>
<view wx:for="{{messages}}" wx:key="id" id="msg-{{index}}" class="message-row {{item.role}}">
<view class="message {{item.role}}">
<view class="message-body">
<block wx:for="{{item.parts || []}}" wx:for-item="part" wx:for-index="partIndex" wx:key="partIndex">
<text wx:if="{{part.type === 'text'}}" class="message-text">{{part.text}}</text>
<text
wx:elif="{{part.type === 'link'}}"
class="message-link"
bindtap="openLink"
data-url="{{part.url}}"
>{{part.text}}</text>
</block>
</view>
<view wx:if="{{item.pageLinks && item.pageLinks.length}}" class="page-links">
<view
wx:for="{{item.pageLinks}}"
wx:for-item="page"
wx:key="publicUrl"
class="page-link-card"
bindtap="openLink"
data-url="{{page.publicUrl}}"
>
<text class="page-link-title">{{page.title}}</text>
</view>
</view>
</view>
<view
class="message-copy"
bindtap="copyMessage"
data-text="{{item.copyText}}"
aria-role="button"
aria-label="复制"
>
<view class="copy-icon"></view>
</view>
</view>
<view wx:if="{{loading}}" class="message-row assistant">
<view class="message assistant">
<text class="message-text">正在思考...</text>
</view>
</view>
</scroll-view>
<view class="composer panel">
<input
class="input"
value="{{draft}}"
placeholder="输入你的问题,回车发送"
maxlength="2000"
confirm-type="send"
disabled="{{loading}}"
bindinput="handleInput"
bindconfirm="sendMessage"
/>
<button class="send" loading="{{loading}}" disabled="{{loading}}" bindtap="sendMessage">发送</button>
</view>
</view>
+220
View File
@@ -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;
}
+98
View File
@@ -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 });
}
}
});
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "登录 TKMind"
}
+25
View File
@@ -0,0 +1,25 @@
<view class="page login-page">
<view class="brand">
<text class="brand-title">TKMind</text>
<text class="brand-subtitle">微信小程序工作台</text>
</view>
<view class="panel login-card">
<view wx:if="{{touristWarning}}" class="warn-box">{{touristWarning}}</view>
<view wx:if="{{envHint}}" class="info-box">{{envHint}}</view>
<button class="primary-button" loading="{{wechatLoading}}" bindtap="handleWechatLogin">
微信一键登录
</button>
<view class="divider"><text>或使用 H5 账号</text></view>
<input class="field" placeholder="用户名 / 邮箱" value="{{username}}" bindinput="handleUsername" />
<input class="field" placeholder="密码" password value="{{password}}" bindinput="handlePassword" />
<button class="secondary-button" loading="{{passwordLoading}}" bindtap="handlePasswordLogin">
账号登录
</button>
<text wx:if="{{error}}" class="error">{{error}}</text>
</view>
</view>
+72
View File
@@ -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;
}
+60
View File
@@ -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' });
}
});
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "历史会话"
}
+17
View File
@@ -0,0 +1,17 @@
<view class="page">
<view class="toolbar">
<text class="title">历史会话</text>
<button class="reload" loading="{{loading}}" bindtap="loadSessions">刷新</button>
</view>
<view wx:if="{{error}}" class="error">{{error}}</view>
<view wx:if="{{sessions.length === 0 && !loading}}" class="empty panel">
<text>暂无会话</text>
</view>
<view wx:for="{{sessions}}" wx:key="id" class="session panel" bindtap="openSession" data-id="{{item.id}}">
<text class="session-title">{{item.title || item.name || '未命名会话'}}</text>
<text class="session-meta">{{item.updatedAtText || ''}}</text>
</view>
</view>
+50
View File
@@ -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;
}
+50
View File
@@ -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)}`
});
}
});
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "MindSpace 页面"
}
+17
View File
@@ -0,0 +1,17 @@
<view class="page">
<view class="toolbar">
<text class="title">MindSpace</text>
<button class="reload" loading="{{loading}}" bindtap="loadPages">刷新</button>
</view>
<view wx:if="{{error}}" class="error">{{error}}</view>
<view wx:if="{{pages.length === 0 && !loading}}" class="empty panel">
<text>暂无页面</text>
</view>
<view wx:for="{{pages}}" wx:key="id" class="page-card panel" bindtap="openPage" data-url="{{item.publicUrl || item.previewUrl}}">
<text class="page-title">{{item.title || '未命名页面'}}</text>
<text class="page-meta">{{item.status || item.visibility || ''}}</text>
</view>
</view>
+50
View File
@@ -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;
}
+9
View File
@@ -0,0 +1,9 @@
Page({
data: {
url: ''
},
onLoad(query) {
this.setData({ url: decodeURIComponent(query.url || '') });
}
});
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "页面预览"
}
+1
View File
@@ -0,0 +1 @@
<web-view src="{{url}}" />
+45
View File
@@ -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": {}
}
+8
View File
@@ -0,0 +1,8 @@
{
"rules": [
{
"action": "disallow",
"page": "*"
}
]
}
+288
View File
@@ -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
};
+39
View File
@@ -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(/\/$/, '') }
: {}),
};
+5
View File
@@ -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',
};
+336
View File
@@ -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,
};
+41
View File
@@ -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
};
+61 -1
View File
@@ -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) {
+41
View File
@@ -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,
+63
View File
@@ -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,
};
}
+59
View File
@@ -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;
},
);
});