diff --git a/miniapp/.gitignore b/miniapp/.gitignore
index 8818d42..fc32101 100644
--- a/miniapp/.gitignore
+++ b/miniapp/.gitignore
@@ -1,4 +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
index fa299e1..d348b10 100644
--- a/miniapp/README.md
+++ b/miniapp/README.md
@@ -17,6 +17,45 @@ This directory contains the native WeChat Mini Program client. It is intentional
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
index 1159c30..5d1d322 100644
--- a/miniapp/app.js
+++ b/miniapp/app.js
@@ -1,4 +1,4 @@
-const { getStoredSession, setApiBaseUrl } = require('./utils/api');
+const { getStoredSession, setApiBaseUrl, getMiniProgramAppId, isTouristMode, getApiBaseUrl } = require('./utils/api');
const { API_BASE_URL } = require('./utils/config');
App({
@@ -8,6 +8,12 @@ App({
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/pages/chat/index.js b/miniapp/pages/chat/index.js
index c6c0fed..379036f 100644
--- a/miniapp/pages/chat/index.js
+++ b/miniapp/pages/chat/index.js
@@ -2,15 +2,18 @@ const {
checkAuth,
createAgentRun,
getAgentRun,
+ getMe,
loadSession,
logout
} = require('../../utils/api');
const {
AGENT_RUN_MAX_POLLS,
AGENT_RUN_POLL_MS,
- SELECTED_SESSION_KEY
+ 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));
@@ -33,18 +36,49 @@ Page({
wx.redirectTo({ url: '/pages/login/index' });
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 {
wx.redirectTo({ url: '/pages/login/index' });
}
},
+ 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 });
},
@@ -59,22 +93,24 @@ Page({
if (!text || this.data.loading) return;
const userMessage = createUserMessage(text);
- const nextMessages = [
- ...this.data.messages,
- { id: userMessage.id, role: 'user', 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 || null,
- user_message: userMessage,
- source: 'wechat-miniapp'
+ 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);
- await this.waitForRun(run?.id || run?.runId);
+ 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 {
@@ -83,21 +119,28 @@ Page({
}
},
- async waitForRun(runId) {
+ async waitForRun(run) {
+ const runId = run?.id;
if (!runId) throw new Error('后台任务未返回 runId');
- let sessionId = this.data.sessionId;
+ let sessionId = run?.sessionId || this.data.sessionId;
for (let i = 0; i < AGENT_RUN_MAX_POLLS; i += 1) {
- const run = await getAgentRun(runId);
- if (run?.sessionId && run.sessionId !== sessionId) {
- sessionId = run.sessionId;
- this.setData({ sessionId });
+ const latest = i === 0 ? run : await getAgentRun(runId);
+ if (latest?.sessionId) {
+ sessionId = latest.sessionId;
+ if (sessionId !== this.data.sessionId) {
+ this.setData({ sessionId });
+ }
}
- if (run?.status === 'succeeded') {
- if (sessionId) await this.refreshSession(sessionId);
+ if (latest?.status === 'succeeded') {
+ if (sessionId) {
+ await this.refreshSession(sessionId);
+ } else {
+ throw new Error('任务已完成,但未返回会话 ID');
+ }
return;
}
- if (run?.status === 'failed') {
- throw new Error(run.error || '后台任务失败');
+ if (latest?.status === 'failed') {
+ throw new Error(latest.error || '后台任务失败');
}
await delay(AGENT_RUN_POLL_MS);
}
@@ -106,7 +149,16 @@ Page({
async refreshSession(sessionId) {
const detail = await loadSession(sessionId);
- const messages = normalizeMessages(detail?.messages || detail?.data?.messages || []);
+ 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: '' });
},
@@ -127,6 +179,38 @@ Page({
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 {
diff --git a/miniapp/pages/chat/index.wxml b/miniapp/pages/chat/index.wxml
index f40fd7b..aaa2ffc 100644
--- a/miniapp/pages/chat/index.wxml
+++ b/miniapp/pages/chat/index.wxml
@@ -14,23 +14,60 @@
发一条消息开始使用 TKMind。
-
- {{item.text}}
+
+
+
+
+ {{part.text}}
+ {{part.text}}
+
+
+
+
+ {{page.title}}
+
+
+
+
+
+
-
- 正在思考...
+
+
+ 正在思考...
+
-
-
+
diff --git a/miniapp/pages/chat/index.wxss b/miniapp/pages/chat/index.wxss
index 3e12b05..35b4a92 100644
--- a/miniapp/pages/chat/index.wxss
+++ b/miniapp/pages/chat/index.wxss
@@ -62,29 +62,131 @@
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%;
- margin-bottom: 20rpx;
padding: 22rpx 24rpx;
border-radius: 16rpx;
font-size: 29rpx;
line-height: 1.55;
- white-space: pre-wrap;
}
.message.user {
- margin-left: auto;
background: #0f766e;
color: #ffffff;
}
.message.assistant {
- margin-right: auto;
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;
@@ -98,9 +200,8 @@
.input {
flex: 1;
- min-height: 72rpx;
- max-height: 220rpx;
- padding: 18rpx 20rpx;
+ height: 72rpx;
+ padding: 0 20rpx;
border-radius: 12rpx;
background: #f8fafc;
box-sizing: border-box;
diff --git a/miniapp/pages/login/index.js b/miniapp/pages/login/index.js
index 76d7c01..94ee6b6 100644
--- a/miniapp/pages/login/index.js
+++ b/miniapp/pages/login/index.js
@@ -1,7 +1,9 @@
const {
checkAuth,
loginWithPassword,
- loginWithWechatCode
+ loginWithWechatCode,
+ isTouristMode,
+ getApiBaseUrl
} = require('../../utils/api');
const { MINIAPP_LOGIN_PATH } = require('../../utils/config');
@@ -11,10 +13,33 @@ Page({
password: '',
error: '',
wechatLoading: false,
- passwordLoading: false
+ passwordLoading: false,
+ touristWarning: '',
+ envHint: ''
},
- async onLoad() {
+ 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) {
diff --git a/miniapp/pages/login/index.wxml b/miniapp/pages/login/index.wxml
index 5709503..03b6bbc 100644
--- a/miniapp/pages/login/index.wxml
+++ b/miniapp/pages/login/index.wxml
@@ -5,6 +5,9 @@
+ {{touristWarning}}
+ {{envHint}}
+
diff --git a/miniapp/pages/login/index.wxss b/miniapp/pages/login/index.wxss
index 31065d4..a3c1be2 100644
--- a/miniapp/pages/login/index.wxss
+++ b/miniapp/pages/login/index.wxss
@@ -50,3 +50,23 @@
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/project.config.json b/miniapp/project.config.json
index 11635af..2d0d287 100644
--- a/miniapp/project.config.json
+++ b/miniapp/project.config.json
@@ -3,22 +3,43 @@
"packOptions": {
"ignore": [
{
- "type": "folder",
- "value": "node_modules"
+ "value": "node_modules",
+ "type": "folder"
}
- ]
+ ],
+ "include": []
},
"setting": {
"urlCheck": true,
"es6": true,
"enhance": true,
"postcss": true,
- "minified": 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.8.8",
- "appid": "touristappid",
+ "libVersion": "3.16.2",
+ "appid": "wx3e79ecd530c88da4",
"projectname": "tkmind-miniapp",
- "condition": {}
-}
+ "condition": {},
+ "simulatorPluginLibVersion": {},
+ "editorSetting": {}
+}
\ No newline at end of file
diff --git a/miniapp/utils/api.js b/miniapp/utils/api.js
index 4bf1a61..61c15ff 100644
--- a/miniapp/utils/api.js
+++ b/miniapp/utils/api.js
@@ -1,4 +1,5 @@
const SESSION_KEY = 'tkmind_session';
+const { createUuid } = require('./uuid');
let apiBaseUrl = '';
let cookie = '';
@@ -15,6 +16,36 @@ function getMiniProgramAppId() {
}
}
+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') {
@@ -60,7 +91,7 @@ function request(path, options = {}) {
wx.request({
url,
method: options.method || 'GET',
- data: options.body || undefined,
+ data: options.body == null ? undefined : options.body,
header,
timeout: options.timeout || 20000,
success(res) {
@@ -75,12 +106,7 @@ function request(path, options = {}) {
resolve(res.data);
return;
}
- const message =
- res.data?.message ||
- res.data?.error?.message ||
- (status === 405
- ? `接口不支持当前请求方法,请检查 API_BASE_URL 是否指向 Portal 服务:${url}`
- : `请求失败 (${status || 'network'})`);
+ const message = formatRequestError(res, url);
const error = new Error(message);
error.status = status;
error.body = res.data;
@@ -133,9 +159,10 @@ function wxLogin() {
}
async function loginWithWechatCode(loginPath) {
- const appId = getMiniProgramAppId();
- if (!appId || appId === 'touristappid') {
- throw new Error('当前是游客模式,微信一键登录需要在 project.config.json 配置真实小程序 AppID。');
+ if (isTouristMode()) {
+ throw new Error(
+ '当前是游客模式(无 AppID 关联)。请在微信开发者工具用 AppID wx3e79ecd530c88da4 重新导入项目,并确认登录 DevTools 的微信号已是该小程序管理员。'
+ );
}
const code = await wxLogin();
const result = await portalRequest(loginPath, {
@@ -164,16 +191,25 @@ async function loadSession(sessionId) {
return apiRequest(`/sessions/${encodeURIComponent(sessionId)}`);
}
-async function createAgentRun(input) {
- return apiRequest('/agent/runs', {
+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: input,
+ body,
timeout: 60000
});
+ return result?.run ?? result;
}
async function getAgentRun(runId) {
- return apiRequest(`/agent/runs/${encodeURIComponent(runId)}`);
+ const result = await apiRequest(`/agent/runs/${encodeURIComponent(runId)}`);
+ return result?.run ?? result;
}
async function listMindSpacePages(options = {}) {
@@ -186,6 +222,9 @@ async function listMindSpacePages(options = {}) {
module.exports = {
setApiBaseUrl,
+ getApiBaseUrl,
+ getMiniProgramAppId,
+ isTouristMode,
getStoredSession,
saveSession,
clearSession,
diff --git a/miniapp/utils/config.js b/miniapp/utils/config.js
index c0d7032..1014d2f 100644
--- a/miniapp/utils/config.js
+++ b/miniapp/utils/config.js
@@ -1,7 +1,26 @@
-module.exports = {
- API_BASE_URL: 'https://m.tkmind.cn',
+// 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;
+
+const defaults = {
+ API_BASE_URL: LOCAL_DEV ? 'http://127.0.0.1:8081' : 'https://m.tkmind.cn',
MINIAPP_LOGIN_PATH: '/auth/wechat-miniapp/login',
SELECTED_SESSION_KEY: 'tkmind_selected_session_id',
AGENT_RUN_POLL_MS: 1200,
- AGENT_RUN_MAX_POLLS: 120
+ AGENT_RUN_MAX_POLLS: 120,
+};
+
+let localOverrides = {};
+try {
+ localOverrides = require('./config.local.js');
+} catch {
+ // Optional gitignored overrides in utils/config.local.js
+}
+
+module.exports = {
+ ...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
index cc48b29..b5b0625 100644
--- a/miniapp/utils/messages.js
+++ b/miniapp/utils/messages.js
@@ -1,6 +1,9 @@
+const { createUuid } = require('./uuid');
+const { buildMessageView } = require('./message-display');
+
function createUserMessage(text) {
return {
- id: `mp_${Date.now()}_${Math.random().toString(16).slice(2)}`,
+ id: createUuid(),
role: 'user',
created: Math.floor(Date.now() / 1000),
content: [{ type: 'text', text }],
@@ -25,13 +28,10 @@ function messageText(message) {
return '';
}
-function normalizeMessages(messages = []) {
- return messages.map((message) => ({
- id: message.id || `${message.role}_${message.created || Date.now()}`,
- role: message.role || 'assistant',
- text: messageText(message),
- created: message.created || 0
- }));
+function normalizeMessages(messages = [], options = {}) {
+ return messages
+ .map((message) => buildMessageView(message, options))
+ .filter(Boolean);
}
module.exports = {
diff --git a/miniapp/utils/uuid.js b/miniapp/utils/uuid.js
new file mode 100644
index 0000000..1c0b4b4
--- /dev/null
+++ b/miniapp/utils/uuid.js
@@ -0,0 +1,21 @@
+function createUuid() {
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
+ return crypto.randomUUID();
+ }
+ const bytes = new Uint8Array(16);
+ if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
+ crypto.getRandomValues(bytes);
+ } else {
+ for (let i = 0; i < 16; i += 1) {
+ bytes[i] = Math.floor(Math.random() * 256);
+ }
+ }
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
+ const hex = Array.from(bytes, (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)}`;
+}
+
+module.exports = {
+ createUuid,
+};