Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03a79322e4 | |||
| 97d0e8d970 | |||
| 57a60d41d7 | |||
| 3163445a9d | |||
| c842119929 |
@@ -29,3 +29,49 @@ export function resolvePostAgentRunChatState({
|
||||
export function shouldPromoteSessionIdToStreaming(chatState) {
|
||||
return chatState !== 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent-run POST tracks portal request_id; Goose SSE may emit a different chat_request_id.
|
||||
* Adopt the upstream id so Message/Finish events are not dropped in the UI.
|
||||
*
|
||||
* @param {{ activeRequestId?: string | null; chatState?: string; eventType?: string; eventRequestId?: string | null; activeRequestIds?: string[] }} input
|
||||
* @returns {{ activeRequestId: string | null; promoteStreaming: boolean; allowMissingGrace: boolean }}
|
||||
*/
|
||||
export function reconcileSessionEventRequestContext({
|
||||
activeRequestId = null,
|
||||
chatState = 'idle',
|
||||
eventType,
|
||||
eventRequestId = null,
|
||||
activeRequestIds = [],
|
||||
} = {}) {
|
||||
if (eventType === 'ActiveRequests') {
|
||||
if (!activeRequestId && activeRequestIds.length > 0) {
|
||||
return { activeRequestId: activeRequestIds[0], promoteStreaming: true, allowMissingGrace: false };
|
||||
}
|
||||
if (activeRequestId && activeRequestIds.includes(activeRequestId)) {
|
||||
return { activeRequestId, promoteStreaming: false, allowMissingGrace: false };
|
||||
}
|
||||
if (activeRequestId && !activeRequestIds.includes(activeRequestId)) {
|
||||
if (chatState === 'waiting' && activeRequestIds.length > 0) {
|
||||
return { activeRequestId: activeRequestIds[0], promoteStreaming: true, allowMissingGrace: false };
|
||||
}
|
||||
return {
|
||||
activeRequestId,
|
||||
promoteStreaming: false,
|
||||
allowMissingGrace: chatState !== 'waiting',
|
||||
};
|
||||
}
|
||||
return { activeRequestId, promoteStreaming: false, allowMissingGrace: false };
|
||||
}
|
||||
|
||||
if (
|
||||
eventRequestId &&
|
||||
(chatState === 'waiting' || chatState === 'streaming') &&
|
||||
(eventType === 'Message' || eventType === 'Finish') &&
|
||||
(!activeRequestId || eventRequestId !== activeRequestId)
|
||||
) {
|
||||
return { activeRequestId: eventRequestId, promoteStreaming: chatState === 'waiting', allowMissingGrace: false };
|
||||
}
|
||||
|
||||
return { activeRequestId, promoteStreaming: false, allowMissingGrace: false };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
reconcileSessionEventRequestContext,
|
||||
resolvePostAgentRunChatState,
|
||||
shouldPromoteSessionIdToStreaming,
|
||||
} from './chat-agent-run-gate.mjs';
|
||||
@@ -29,3 +30,24 @@ test('shouldPromoteSessionIdToStreaming skips re-streaming after Finish', () =>
|
||||
assert.equal(shouldPromoteSessionIdToStreaming('waiting'), true);
|
||||
assert.equal(shouldPromoteSessionIdToStreaming('streaming'), true);
|
||||
});
|
||||
|
||||
test('reconcileSessionEventRequestContext adopts Goose request id while agent-run gate waits', () => {
|
||||
assert.deepEqual(
|
||||
reconcileSessionEventRequestContext({
|
||||
activeRequestId: 'portal-req',
|
||||
chatState: 'waiting',
|
||||
eventType: 'ActiveRequests',
|
||||
activeRequestIds: ['goose-req'],
|
||||
}),
|
||||
{ activeRequestId: 'goose-req', promoteStreaming: true, allowMissingGrace: false },
|
||||
);
|
||||
assert.deepEqual(
|
||||
reconcileSessionEventRequestContext({
|
||||
activeRequestId: 'portal-req',
|
||||
chatState: 'waiting',
|
||||
eventType: 'Message',
|
||||
eventRequestId: 'goose-req',
|
||||
}),
|
||||
{ activeRequestId: 'goose-req', promoteStreaming: true, allowMissingGrace: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import { execFileSync } from 'node:child_process';
|
||||
import mysql from 'mysql2/promise';
|
||||
import { createScheduleService } from './schedule-service.mjs';
|
||||
import { resolveScheduleTimestamp } from './schedule-time.mjs';
|
||||
import { shouldAutoCreateReminderAtStart } from './schedule-service.mjs';
|
||||
import { renderLongImage } from './mindspace-long-image.mjs';
|
||||
import { createUserDataSpaceService } from './user-data-space-service.mjs';
|
||||
import { writePageAccessPolicy, readPageAccessPolicy } from './page-data-policy-store.mjs';
|
||||
@@ -426,6 +427,11 @@ if (isScheduleConfigured()) {
|
||||
location: { type: 'string', description: '地点,可选' },
|
||||
sourceMessageId: { type: 'string', description: '服务号消息 ID;有值时必须原样传入' },
|
||||
sourceText: { type: 'string', description: '原始用户文本,可选' },
|
||||
remindAt: { type: 'number', description: '提醒触发时间 Unix 毫秒时间戳,可选(优先使用 remindLocal)' },
|
||||
remindLocal: { type: 'string', description: '本地提醒时间 YYYY-MM-DD HH:mm;需要到点提醒时推荐与 startLocal 同传' },
|
||||
offsetMinutes: { type: 'number', description: '相对事项时间的提前分钟数,可选' },
|
||||
channel: { type: 'string', description: '提醒通道,默认 wechat,可选' },
|
||||
noReminder: { type: 'boolean', description: '明确只记事项、不创建到点提醒时传 true' },
|
||||
},
|
||||
required: ['title'],
|
||||
},
|
||||
@@ -730,6 +736,33 @@ async function callTool(name, args) {
|
||||
sourceText: args.sourceText ?? null,
|
||||
metadata: { source: 'schedule_assistant_skill' },
|
||||
});
|
||||
let remindAt = resolveScheduleTimestamp({
|
||||
epochMs: args.remindAt,
|
||||
localString: args.remindLocal,
|
||||
timezone,
|
||||
fieldName: '提醒时间',
|
||||
});
|
||||
if (
|
||||
remindAt == null
|
||||
&& shouldAutoCreateReminderAtStart({
|
||||
title: args.title,
|
||||
description: args.description,
|
||||
startAt,
|
||||
noReminder: Boolean(args.noReminder),
|
||||
})
|
||||
) {
|
||||
remindAt = startAt;
|
||||
}
|
||||
if (remindAt != null) {
|
||||
const reminder = await getScheduleService().createReminder({
|
||||
userId: PRIVATE_DATA_USER_ID,
|
||||
itemId: item.id,
|
||||
remindAt,
|
||||
offsetMinutes: args.offsetMinutes ?? null,
|
||||
channel: args.channel ?? 'wechat',
|
||||
});
|
||||
return [{ type: 'text', text: JSON.stringify({ item, reminder }, null, 2) }];
|
||||
}
|
||||
return [{ type: 'text', text: JSON.stringify(item, null, 2) }];
|
||||
}
|
||||
case 'schedule_create_reminder': {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
miniprogram_npm/
|
||||
project.private.config.json
|
||||
utils/config.local.js
|
||||
*.local
|
||||
|
||||
@@ -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.
|
||||
|
||||
+7
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages/chat/index",
|
||||
"pages/login/index",
|
||||
"pages/chat/index",
|
||||
"pages/sessions/index",
|
||||
"pages/space/index",
|
||||
"pages/webview/index"
|
||||
|
||||
+133
-22
@@ -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));
|
||||
@@ -27,24 +30,82 @@ Page({
|
||||
},
|
||||
|
||||
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) {
|
||||
wx.redirectTo({ url: '/pages/login/index' });
|
||||
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 {
|
||||
wx.redirectTo({ url: '/pages/login/index' });
|
||||
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 });
|
||||
},
|
||||
@@ -59,22 +120,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 +146,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 +176,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 +206,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 {
|
||||
|
||||
@@ -14,23 +14,60 @@
|
||||
<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 {{item.role}}">
|
||||
<text>{{item.text}}</text>
|
||||
<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 assistant">
|
||||
<text>正在思考...</text>
|
||||
<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">
|
||||
<textarea
|
||||
<input
|
||||
class="input"
|
||||
value="{{draft}}"
|
||||
placeholder="输入你的问题"
|
||||
auto-height
|
||||
placeholder="输入你的问题,回车发送"
|
||||
maxlength="2000"
|
||||
confirm-type="send"
|
||||
disabled="{{loading}}"
|
||||
bindinput="handleInput"
|
||||
bindconfirm="sendMessage"
|
||||
/>
|
||||
<button class="send" loading="{{loading}}" bindtap="sendMessage">发送</button>
|
||||
<button class="send" loading="{{loading}}" disabled="{{loading}}" bindtap="sendMessage">发送</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
const {
|
||||
checkAuth,
|
||||
loginWithPassword,
|
||||
loginWithWechatCode
|
||||
loginWithWechatCode,
|
||||
isTouristMode,
|
||||
getApiBaseUrl
|
||||
} = require('../../utils/api');
|
||||
const { MINIAPP_LOGIN_PATH } = require('../../utils/config');
|
||||
|
||||
@@ -11,13 +13,40 @@ 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) {
|
||||
const app = getApp();
|
||||
if (status.user) {
|
||||
app.globalData.user = status.user;
|
||||
}
|
||||
wx.switchTab({ url: '/pages/chat/index' });
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
</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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
+109
-23
@@ -1,8 +1,36 @@
|
||||
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(/\/$/, '');
|
||||
}
|
||||
@@ -15,6 +43,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') {
|
||||
@@ -37,14 +95,34 @@ function clearSession() {
|
||||
function normalizeSetCookie(headers = {}) {
|
||||
const raw = headers['Set-Cookie'] || headers['set-cookie'];
|
||||
if (!raw) return '';
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((item) => String(item).split(';')[0]).join('; ');
|
||||
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;
|
||||
}
|
||||
return String(raw)
|
||||
.split(/,(?=\s*[^;,]+=)/)
|
||||
.map((item) => item.trim().split(';')[0])
|
||||
.filter(Boolean)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function request(path, options = {}) {
|
||||
@@ -60,7 +138,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 +153,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;
|
||||
@@ -114,7 +187,7 @@ async function loginWithPassword(username, password) {
|
||||
method: 'POST',
|
||||
body: { username, password }
|
||||
});
|
||||
saveSession({ cookie, user: result?.user || result || null, loginType: 'password' });
|
||||
persistLoginResult(result, 'password');
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -133,16 +206,17 @@ 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, {
|
||||
method: 'POST',
|
||||
body: { code }
|
||||
});
|
||||
saveSession({ cookie, user: result?.user || result || null, loginType: 'wechat' });
|
||||
persistLoginResult(result, 'wechat');
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -164,16 +238,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 +269,9 @@ async function listMindSpacePages(options = {}) {
|
||||
|
||||
module.exports = {
|
||||
setApiBaseUrl,
|
||||
getApiBaseUrl,
|
||||
getMiniProgramAppId,
|
||||
isTouristMode,
|
||||
getStoredSession,
|
||||
saveSession,
|
||||
clearSession,
|
||||
|
||||
+35
-3
@@ -1,7 +1,39 @@
|
||||
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;
|
||||
|
||||
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
|
||||
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(/\/$/, '') }
|
||||
: {}),
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1,6 +1,9 @@
|
||||
const { createUuid } = require('./config');
|
||||
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 = {
|
||||
|
||||
@@ -10,6 +10,19 @@ import {
|
||||
|
||||
const DEFAULT_TIMEZONE = 'Asia/Shanghai';
|
||||
|
||||
const REMINDER_INTENT_PATTERN = /提醒|闹钟|吃药|叫我/;
|
||||
|
||||
export function shouldAutoCreateReminderAtStart({
|
||||
title = '',
|
||||
description = '',
|
||||
startAt = null,
|
||||
noReminder = false,
|
||||
} = {}) {
|
||||
if (noReminder || startAt == null) return false;
|
||||
const text = `${String(title ?? '')} ${String(description ?? '')}`;
|
||||
return REMINDER_INTENT_PATTERN.test(text);
|
||||
}
|
||||
|
||||
function nowMs() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,41 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createScheduleService } from './schedule-service.mjs';
|
||||
import { createScheduleService, shouldAutoCreateReminderAtStart } from './schedule-service.mjs';
|
||||
|
||||
test('shouldAutoCreateReminderAtStart detects reminder intent from title or description', () => {
|
||||
assert.equal(
|
||||
shouldAutoCreateReminderAtStart({
|
||||
title: '吃药提醒',
|
||||
description: '今天上午10点吃药',
|
||||
startAt: 1,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoCreateReminderAtStart({
|
||||
title: '开会',
|
||||
description: '明天下午三点',
|
||||
startAt: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoCreateReminderAtStart({
|
||||
title: '吃药提醒',
|
||||
description: '今天上午10点吃药',
|
||||
startAt: 1,
|
||||
noReminder: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoCreateReminderAtStart({
|
||||
title: '买菜',
|
||||
startAt: null,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('listUserNotifications accepts mysql JSON columns returned as objects', async () => {
|
||||
const service = createScheduleService({
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 多场景验证:日程事项 + 到点提醒创建链路
|
||||
* - Agent MCP 自动补提醒
|
||||
* - 服务号快捷路径不受影响
|
||||
* - 提醒 worker 可读 pending 记录
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import mysql from 'mysql2/promise';
|
||||
import { handleWechatScheduleIntent } from '../wechat/handlers/schedule.mjs';
|
||||
import { parseScheduleIntent, shouldUseScheduleAssistant } from '../schedule-intent.mjs';
|
||||
import {
|
||||
createScheduleService,
|
||||
shouldAutoCreateReminderAtStart,
|
||||
} from '../schedule-service.mjs';
|
||||
import { resolveScheduleTimestamp } from '../schedule-time.mjs';
|
||||
|
||||
const tz = 'Asia/Shanghai';
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function pass(label, detail = '') {
|
||||
passed += 1;
|
||||
console.log(`✔ ${label}${detail ? `: ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
function fail(label, detail = '') {
|
||||
failed += 1;
|
||||
console.error(`✘ ${label}${detail ? `: ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
function loadDatabaseUrl() {
|
||||
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
|
||||
const envPath = new URL('../.env', import.meta.url);
|
||||
if (!fs.existsSync(envPath)) return null;
|
||||
const envText = fs.readFileSync(envPath, 'utf8');
|
||||
const dbLine = envText.match(/^DATABASE_URL=(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, '');
|
||||
return dbLine || null;
|
||||
}
|
||||
|
||||
async function simulateCreateItemWithReminder({
|
||||
scheduleService,
|
||||
userId,
|
||||
args,
|
||||
}) {
|
||||
const startAt = resolveScheduleTimestamp({
|
||||
epochMs: args.startAt,
|
||||
localString: args.startLocal,
|
||||
timezone: tz,
|
||||
fieldName: '开始时间',
|
||||
});
|
||||
const item = await scheduleService.createItem({
|
||||
userId,
|
||||
kind: args.kind ?? 'task',
|
||||
title: args.title,
|
||||
description: args.description ?? null,
|
||||
startAt,
|
||||
timezone: tz,
|
||||
sourceChannel: 'agent',
|
||||
metadata: { source: 'verify_schedule_reminder_create' },
|
||||
});
|
||||
|
||||
let remindAt = resolveScheduleTimestamp({
|
||||
epochMs: args.remindAt,
|
||||
localString: args.remindLocal,
|
||||
timezone: tz,
|
||||
fieldName: '提醒时间',
|
||||
});
|
||||
if (
|
||||
remindAt == null
|
||||
&& shouldAutoCreateReminderAtStart({
|
||||
title: args.title,
|
||||
description: args.description,
|
||||
startAt,
|
||||
noReminder: Boolean(args.noReminder),
|
||||
})
|
||||
) {
|
||||
remindAt = startAt;
|
||||
}
|
||||
|
||||
let reminder = null;
|
||||
if (remindAt != null) {
|
||||
reminder = await scheduleService.createReminder({
|
||||
userId,
|
||||
itemId: item.id,
|
||||
remindAt,
|
||||
channel: args.channel ?? 'wechat',
|
||||
});
|
||||
}
|
||||
return { item, reminder, startAt, remindAt };
|
||||
}
|
||||
|
||||
async function cleanupTestRows(pool, userId, marker = 'verify_schedule_reminder_create') {
|
||||
const [items] = await pool.query(
|
||||
`SELECT id FROM h5_schedule_items
|
||||
WHERE user_id = ? AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.source')) = ?`,
|
||||
[userId, marker],
|
||||
);
|
||||
const itemIds = items.map((row) => row.id);
|
||||
if (itemIds.length) {
|
||||
await pool.query(
|
||||
`DELETE FROM h5_schedule_reminders WHERE item_id IN (${itemIds.map(() => '?').join(', ')})`,
|
||||
itemIds,
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM h5_schedule_items WHERE id IN (${itemIds.map(() => '?').join(', ')})`,
|
||||
itemIds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function testAutoRemindMatrix() {
|
||||
const cases = [
|
||||
{
|
||||
label: '吃药提醒 + 10点 → 自动补提醒',
|
||||
input: { title: '吃药提醒', description: '今天上午10点吃药', startAt: 1 },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
label: '开会无提醒词 → 不自动补',
|
||||
input: { title: '开会', description: '明天下午三点', startAt: 1 },
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
label: 'noReminder=true → 不自动补',
|
||||
input: { title: '吃药提醒', description: '今天上午10点吃药', startAt: 1, noReminder: true },
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
label: '无 startAt → 不自动补',
|
||||
input: { title: '吃药提醒', startAt: null },
|
||||
expected: false,
|
||||
},
|
||||
];
|
||||
for (const item of cases) {
|
||||
const actual = shouldAutoCreateReminderAtStart(item.input);
|
||||
if (actual === item.expected) pass(item.label);
|
||||
else fail(item.label, `expected ${item.expected}, got ${actual}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function testWechatHandlerIsolation() {
|
||||
const calls = [];
|
||||
const scheduleService = {
|
||||
async createItem(payload) {
|
||||
calls.push(['createItem', payload.title]);
|
||||
return { id: 'item-1', ...payload };
|
||||
},
|
||||
async createDailyTodoDigest(payload) {
|
||||
calls.push(['createDailyTodoDigest', payload.hour, payload.minute]);
|
||||
return { id: 'digest-1', hour: payload.hour, minute: payload.minute };
|
||||
},
|
||||
async createBalanceLowAlert(payload) {
|
||||
calls.push(['createBalanceLowAlert', payload.thresholdCents]);
|
||||
return { id: 'balance-1', thresholdCents: payload.thresholdCents };
|
||||
},
|
||||
async buildTodoDigestText() {
|
||||
return '今天有 1 条待办。';
|
||||
},
|
||||
};
|
||||
|
||||
const todoReply = await handleWechatScheduleIntent({
|
||||
intent: { agentText: '帮我记一下 跟进合同', msgId: 'msg-todo' },
|
||||
user: { userId: 'user-1' },
|
||||
scheduleService,
|
||||
});
|
||||
if (todoReply?.includes('未设置提醒') && calls.length === 1 && calls[0][0] === 'createItem') {
|
||||
pass('服务号快捷待办', '仍只 createItem,不创建到点提醒');
|
||||
} else {
|
||||
fail('服务号快捷待办', JSON.stringify({ todoReply, calls }));
|
||||
}
|
||||
|
||||
const digestReply = await handleWechatScheduleIntent({
|
||||
intent: { agentText: '每天早上7点把当天待办发给我', msgId: 'msg-digest' },
|
||||
user: { userId: 'user-1' },
|
||||
scheduleService,
|
||||
});
|
||||
if (digestReply?.includes('已设置') && calls.some((c) => c[0] === 'createDailyTodoDigest')) {
|
||||
pass('服务号每日待办摘要', '仍走 digest 订阅,不受 MCP 改动影响');
|
||||
} else {
|
||||
fail('服务号每日待办摘要', JSON.stringify({ digestReply, calls }));
|
||||
}
|
||||
|
||||
const agentReply = await handleWechatScheduleIntent({
|
||||
intent: { agentText: '今天10点提醒我吃药', msgId: 'msg-agent' },
|
||||
user: { userId: 'user-1' },
|
||||
scheduleService,
|
||||
});
|
||||
if (agentReply === null) {
|
||||
pass('服务号一次性提醒', '仍 fall through 给 Agent(schedule_agent)');
|
||||
} else {
|
||||
fail('服务号一次性提醒', `expected null, got ${agentReply}`);
|
||||
}
|
||||
|
||||
const intent = parseScheduleIntent('今天10点提醒我吃药');
|
||||
if (shouldUseScheduleAssistant('今天10点提醒我吃药')) {
|
||||
pass('意图路由', `一次性提醒走 Agent 路径(parseScheduleIntent=${intent.action},由 prompt 加载 schedule-assistant)`);
|
||||
} else {
|
||||
fail('意图路由', JSON.stringify(intent));
|
||||
}
|
||||
}
|
||||
|
||||
async function testDatabaseScenarios(pool, userId) {
|
||||
const scheduleService = createScheduleService(pool, { defaultTimezone: tz });
|
||||
await cleanupTestRows(pool, userId);
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
label: 'Agent:吃药提醒仅 create_item → 自动补 reminder',
|
||||
args: {
|
||||
title: '吃药提醒',
|
||||
description: '今天上午11点吃药',
|
||||
startLocal: '2026-07-12 11:00',
|
||||
},
|
||||
expectReminder: true,
|
||||
},
|
||||
{
|
||||
label: 'Agent:显式 remindLocal → 创建 reminder',
|
||||
args: {
|
||||
title: '喝水',
|
||||
description: '今天下午3点喝水',
|
||||
startLocal: '2026-07-12 15:00',
|
||||
remindLocal: '2026-07-12 15:00',
|
||||
},
|
||||
expectReminder: true,
|
||||
},
|
||||
{
|
||||
label: 'Agent:开会无提醒词 → 不自动补 reminder',
|
||||
args: {
|
||||
title: '开会',
|
||||
description: '明天下午三点项目会',
|
||||
startLocal: '2026-07-13 15:00',
|
||||
},
|
||||
expectReminder: false,
|
||||
},
|
||||
{
|
||||
label: 'Agent:noReminder=true → 不创建 reminder',
|
||||
args: {
|
||||
title: '吃药提醒',
|
||||
description: '先记一下',
|
||||
startLocal: '2026-07-12 12:00',
|
||||
noReminder: true,
|
||||
},
|
||||
expectReminder: false,
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const result = await simulateCreateItemWithReminder({
|
||||
scheduleService,
|
||||
userId,
|
||||
args: scenario.args,
|
||||
});
|
||||
const hasReminder = Boolean(result.reminder);
|
||||
if (hasReminder === scenario.expectReminder) {
|
||||
pass(
|
||||
scenario.label,
|
||||
hasReminder
|
||||
? `reminder ${new Date(result.remindAt).toLocaleString('zh-CN', { timeZone: tz })}`
|
||||
: '仅事项',
|
||||
);
|
||||
} else {
|
||||
fail(scenario.label, `expectReminder=${scenario.expectReminder}, actual=${hasReminder}`);
|
||||
}
|
||||
}
|
||||
|
||||
const due = await scheduleService.listDueReminders({
|
||||
now: resolveScheduleTimestamp({
|
||||
localString: '2026-07-12 11:05',
|
||||
timezone: tz,
|
||||
fieldName: '扫描时间',
|
||||
}),
|
||||
limit: 20,
|
||||
});
|
||||
const testDue = due.filter((row) => row.userId === userId);
|
||||
if (testDue.length >= 1) {
|
||||
pass('提醒 worker 扫描', `listDueReminders 可读 ${testDue.length} 条测试提醒`);
|
||||
} else {
|
||||
fail('提醒 worker 扫描', '未读到 pending 测试提醒');
|
||||
}
|
||||
|
||||
await cleanupTestRows(pool, userId);
|
||||
}
|
||||
|
||||
async function testLiveAgentIfRequested() {
|
||||
if (!['1', 'true', 'yes', 'on'].includes(String(process.env.VERIFY_SCHEDULE_LIVE_AGENT ?? '').toLowerCase())) {
|
||||
pass('Live Agent(跳过)', '设置 VERIFY_SCHEDULE_LIVE_AGENT=1 可启用');
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = process.env.H5_PORT ? `http://127.0.0.1:${process.env.H5_PORT}` : 'http://127.0.0.1:8081';
|
||||
const { loginViaApi, createAgentRun, waitForRunTerminal, createReporter } = await import('./scenario-test-lib.mjs');
|
||||
const reporter = createReporter();
|
||||
let auth;
|
||||
try {
|
||||
auth = await loginViaApi(
|
||||
baseUrl,
|
||||
{ username: 'john8', password: process.env.JOHN8_PASSWORD ?? '888888' },
|
||||
reporter,
|
||||
);
|
||||
} catch (err) {
|
||||
pass('Live Agent john8(跳过)', `登录失败:${err.message}`);
|
||||
return;
|
||||
}
|
||||
const message = '今天11点30分提醒我吃药';
|
||||
const run = await createAgentRun(baseUrl, auth.cookie, { message });
|
||||
const finalRun = await waitForRunTerminal(baseUrl, auth.cookie, run.runId, 180_000);
|
||||
if (finalRun.status !== 'succeeded') {
|
||||
fail('Live Agent john8', `run status=${finalRun.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const dbUrl = loadDatabaseUrl();
|
||||
const pool = mysql.createPool(dbUrl);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT i.title, r.remind_at, r.status
|
||||
FROM h5_schedule_items i
|
||||
LEFT JOIN h5_schedule_reminders r ON r.item_id = i.id
|
||||
WHERE i.user_id = ? AND i.deleted_at IS NULL AND i.title LIKE '%吃药%'
|
||||
ORDER BY i.created_at DESC LIMIT 3`,
|
||||
[auth.user?.id],
|
||||
);
|
||||
await pool.end();
|
||||
|
||||
const withReminder = rows.filter((row) => row.remind_at != null);
|
||||
if (withReminder.length >= 1) {
|
||||
pass('Live Agent john8', `最新吃药事项含 reminder,status=${withReminder[0].status}`);
|
||||
} else {
|
||||
fail('Live Agent john8', `items=${rows.length}, reminders=0`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('=== 日程提醒多场景验证 ===\n');
|
||||
|
||||
testAutoRemindMatrix();
|
||||
await testWechatHandlerIsolation();
|
||||
|
||||
const dbUrl = loadDatabaseUrl();
|
||||
if (!dbUrl) {
|
||||
fail('数据库场景', '未配置 DATABASE_URL');
|
||||
} else {
|
||||
const pool = mysql.createPool(dbUrl);
|
||||
const [users] = await pool.query('SELECT id FROM h5_users WHERE username = ? LIMIT 1', ['john8']);
|
||||
if (!users[0]) {
|
||||
fail('数据库场景', 'john8 用户不存在');
|
||||
} else {
|
||||
await testDatabaseScenarios(pool, users[0].id);
|
||||
}
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
await testLiveAgentIfRequested();
|
||||
|
||||
console.log('\n=== 汇总 ===');
|
||||
console.log(`通过: ${passed}`);
|
||||
console.log(`失败: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
+7
-1
@@ -897,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) {
|
||||
@@ -950,6 +955,7 @@ app.post('/auth/wechat-miniapp/login', jsonBody, async (req, res) => {
|
||||
user: result.user,
|
||||
mode: 'user',
|
||||
isNewUser: Boolean(result.isNewUser),
|
||||
sessionToken: result.token,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '微信登录失败';
|
||||
|
||||
@@ -42,10 +42,11 @@ description: 处理待办、提醒、日程类消息;先澄清缺失时间,
|
||||
3. 创建事项时:
|
||||
- 纯待办用 `kind: task`
|
||||
- 明确约会/会议/出发等时间安排可用 `kind: event`
|
||||
4. 需要提醒时:
|
||||
- 先创建事项
|
||||
- 再调用 `schedule_create_reminder`
|
||||
- 只有两个工具都成功,才对用户确认成功
|
||||
4. 需要提醒时(推荐单步写入,避免漏掉第二步):
|
||||
- 优先在一次 `schedule_create_item` 中同时传 `startLocal` 与 `remindLocal`(到点提醒通常相同)
|
||||
- 若提醒时间与事项开始时间不同,再单独调用 `schedule_create_reminder`
|
||||
- 只有事项和提醒都写入成功,才对用户确认成功
|
||||
- 用户明确说「不提醒 / 只记一下」时传 `noReminder: true`
|
||||
5. 如果提示里给出了 `sourceMessageId`,调用 `schedule_create_item` 时必须原样传入。
|
||||
6. 查询时调用 `schedule_list_items`,只按结果回答,不要编造“已经存在”。
|
||||
|
||||
|
||||
+30
-16
@@ -52,6 +52,7 @@ import { buildContextPrefix } from '../utils/mindspaceChatContext';
|
||||
import { buildUserAddressPrefix } from '../utils/userAddress';
|
||||
import { buildAutoChatSkillPrefix } from '../../chat-skills.mjs';
|
||||
import {
|
||||
reconcileSessionEventRequestContext,
|
||||
resolvePostAgentRunChatState,
|
||||
shouldPromoteSessionIdToStreaming,
|
||||
} from '../../chat-agent-run-gate.mjs';
|
||||
@@ -989,19 +990,22 @@ export function useTKMindChat(
|
||||
(event) => {
|
||||
let rid = activeRequestId.current;
|
||||
if (event.type === 'ActiveRequests') {
|
||||
if (!rid && event.request_ids.length > 0) {
|
||||
// SSE reconnected while agent was running — adopt the active request.
|
||||
const activeRequestContext = reconcileSessionEventRequestContext({
|
||||
activeRequestId: rid,
|
||||
chatState: chatStateRef.current,
|
||||
eventType: event.type,
|
||||
activeRequestIds: event.request_ids,
|
||||
});
|
||||
if (
|
||||
activeRequestContext.activeRequestId &&
|
||||
activeRequestContext.activeRequestId !== rid
|
||||
) {
|
||||
clearActiveRequestMissingTimer();
|
||||
activeRequestId.current = event.request_ids[0];
|
||||
setChatState('streaming');
|
||||
activeRequestId.current = activeRequestContext.activeRequestId;
|
||||
rid = activeRequestContext.activeRequestId;
|
||||
} else if (rid && event.request_ids.includes(rid)) {
|
||||
clearActiveRequestMissingTimer();
|
||||
} else if (rid && !event.request_ids.includes(rid)) {
|
||||
// While waiting for the agent run gate, keep request context alive so
|
||||
// Goose streaming events are not dropped before the UI subscribes.
|
||||
if (chatStateRef.current === 'waiting') {
|
||||
return;
|
||||
}
|
||||
} else if (activeRequestContext.allowMissingGrace) {
|
||||
// The backend can briefly report no active request between tool phases.
|
||||
// Confirm the absence before turning the UI idle, otherwise MindSpace
|
||||
// refreshes the page while tools are still mutating it.
|
||||
@@ -1016,18 +1020,28 @@ export function useTKMindChat(
|
||||
}, ACTIVE_REQUEST_MISSING_GRACE_MS);
|
||||
}
|
||||
}
|
||||
if (activeRequestContext.promoteStreaming) {
|
||||
setChatState('streaming');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const eventRequestId = getSessionEventRequestId(event);
|
||||
const eventRequestContext = reconcileSessionEventRequestContext({
|
||||
activeRequestId: rid,
|
||||
chatState: chatStateRef.current,
|
||||
eventType: event.type,
|
||||
eventRequestId,
|
||||
});
|
||||
if (
|
||||
!rid &&
|
||||
eventRequestId &&
|
||||
(chatStateRef.current === 'waiting' || chatStateRef.current === 'streaming') &&
|
||||
(event.type === 'Message' || event.type === 'Finish')
|
||||
eventRequestContext.activeRequestId &&
|
||||
eventRequestContext.activeRequestId !== rid
|
||||
) {
|
||||
activeRequestId.current = eventRequestId;
|
||||
rid = eventRequestId;
|
||||
clearActiveRequestMissingTimer();
|
||||
activeRequestId.current = eventRequestContext.activeRequestId;
|
||||
rid = eventRequestContext.activeRequestId;
|
||||
if (eventRequestContext.promoteStreaming) {
|
||||
setChatState('streaming');
|
||||
}
|
||||
}
|
||||
if (
|
||||
rid ||
|
||||
|
||||
@@ -38,6 +38,7 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
|
||||
'【日程技能要求】这条消息涉及待办、提醒或日程。',
|
||||
'开始前先加载 `schedule-assistant` skill,并严格按 skill 里的边界执行。',
|
||||
'写入工具时优先使用 startLocal / endLocal / remindLocal(YYYY-MM-DD HH:mm),不要自行估算 Unix 毫秒。',
|
||||
'需要到点提醒时,优先在一次 schedule_create_item 中同时传 startLocal 与 remindLocal;不要只创建事项就结束。',
|
||||
'只有在 `schedule_create_item` / `schedule_create_reminder` 等工具成功返回后,才能告诉用户“已经设置好了”。',
|
||||
intent?.msgId ? `调用 schedule_create_item 时必须传入 sourceMessageId: ${intent.msgId}` : '',
|
||||
'',
|
||||
|
||||
Reference in New Issue
Block a user