Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b0f269180 | |||
| c7e4a063bf | |||
| fe5d32b451 | |||
| 30fcbaf613 | |||
| e0d4868908 | |||
| e7d5c09e56 | |||
| 475328830a | |||
| 5674d53a64 | |||
| 6250b5989c | |||
| ddb3a330f4 | |||
| 8ac159a5ed | |||
| f6dcf5b14a | |||
| f2d0c99f6c | |||
| 77f1ea8350 |
@@ -0,0 +1,69 @@
|
|||||||
|
name: Memind CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: memind-ci-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
name: Test, build, and release guards
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out exact commit
|
||||||
|
run: |
|
||||||
|
git init .
|
||||||
|
git remote add origin https://git.tkmind.cn/tkmind/memind.git
|
||||||
|
git fetch --depth=2 origin "${{ gitea.sha }}"
|
||||||
|
git checkout --detach FETCH_HEAD
|
||||||
|
test "$(git rev-parse HEAD)" = "${{ gitea.sha }}"
|
||||||
|
|
||||||
|
- name: Install system test dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install --yes --no-install-recommends sqlite3
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
- name: Install locked dependencies
|
||||||
|
run: |
|
||||||
|
npm ci --include=optional
|
||||||
|
SHARP_ARM64_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/@img/sharp-linux-arm64'].version")"
|
||||||
|
LIBVIPS_ARM64_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/@img/sharp-libvips-linux-arm64'].version")"
|
||||||
|
npm install --no-save --package-lock=false \
|
||||||
|
"@img/sharp-linux-arm64@${SHARP_ARM64_VERSION}" \
|
||||||
|
"@img/sharp-libvips-linux-arm64@${LIBVIPS_ARM64_VERSION}"
|
||||||
|
node -e "import('sharp').then((sharp) => sharp.default({ create: { width: 1, height: 1, channels: 4, background: '#000' } }).png().toBuffer())"
|
||||||
|
|
||||||
|
- name: Check patch formatting
|
||||||
|
run: git diff --check HEAD^
|
||||||
|
|
||||||
|
- name: Run full test suite
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Build production frontend
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Verify MindSpace publish guards
|
||||||
|
run: npm run verify:mindspace-publish-guards
|
||||||
|
|
||||||
|
- name: Verify MindSpace page sync guards
|
||||||
|
run: npm run verify:mindspace-page-sync-guards
|
||||||
|
|
||||||
|
- name: Verify Page Data delivery
|
||||||
|
run: npm run verify:page-data
|
||||||
|
|
||||||
|
- name: Check published download links
|
||||||
|
run: npm run check:mindspace-public-links
|
||||||
@@ -361,6 +361,17 @@ export function stripKnownChatSkillPrompt(text) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Older persisted Page Data messages can contain a prompt from a previous
|
||||||
|
// template revision. Keep this compatibility path anchored to the stable
|
||||||
|
// skill header and final delivery sentence so the user's request is not
|
||||||
|
// mistaken for executor-only instructions.
|
||||||
|
if (/^请使用\s+page-data-collect\s+技能[::]/u.test(next)) {
|
||||||
|
const legacyEndMarker = '并说明后台入口与口令。';
|
||||||
|
const markerIndex = next.indexOf(legacyEndMarker);
|
||||||
|
if (markerIndex >= 0) {
|
||||||
|
next = next.slice(markerIndex + legacyEndMarker.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
return next.trim();
|
return next.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,26 @@ test('deriveUserFacingText removes routing hint and skill preface from agent pay
|
|||||||
assert.equal(deriveUserFacingText(agentPayload), userText);
|
assert.equal(deriveUserFacingText(agentPayload), userText);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('deriveUserFacingText removes page-data prompt persisted as display text', () => {
|
||||||
|
const userText = '帮我做一个日记页面,可以每天写日记,其他人可以评价';
|
||||||
|
const persistedDisplayText = `${buildAutoChatSkillPrefix(userText, ['page-data-collect'])}${userText}`;
|
||||||
|
assert.match(persistedDisplayText, /page-data-collect/);
|
||||||
|
assert.equal(deriveUserFacingText(persistedDisplayText), userText);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deriveUserFacingText removes a legacy page-data prompt after the template changes', () => {
|
||||||
|
const userText = '帮我做一个日记页面,可以每天写日记,其他人可以评价';
|
||||||
|
const persistedDisplayText = [
|
||||||
|
'请使用 page-data-collect 技能:在 MindSpace 页面中实现可提交、可持久化的数据收集。',
|
||||||
|
'流程:loadskill → privatedataexecute 建表 → privatedataregisterdataset → writefile/editfile。',
|
||||||
|
'完成后只返回 workspaceUrl,并说明后台入口与口令。',
|
||||||
|
userText,
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
assert.equal(stripKnownChatSkillPrompt(persistedDisplayText), userText);
|
||||||
|
assert.equal(deriveUserFacingText(persistedDisplayText), userText);
|
||||||
|
});
|
||||||
|
|
||||||
test('deriveUserFacingText removes Memind task orchestration prefix', () => {
|
test('deriveUserFacingText removes Memind task orchestration prefix', () => {
|
||||||
const userText = '帮我生成深度搜索报告';
|
const userText = '帮我生成深度搜索报告';
|
||||||
const agentPayload = [
|
const agentPayload = [
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ function publicationResponse(row) {
|
|||||||
viewCount: Number(row.view_count ?? 0),
|
viewCount: Number(row.view_count ?? 0),
|
||||||
publishedAt: Number(row.published_at),
|
publishedAt: Number(row.published_at),
|
||||||
offlineAt: row.offline_at == null ? null : Number(row.offline_at),
|
offlineAt: row.offline_at == null ? null : Number(row.offline_at),
|
||||||
|
userConfirmedAt: row.user_confirmed_at == null ? null : Number(row.user_confirmed_at),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -982,6 +983,7 @@ export function createPublicationService(pool, options = {}) {
|
|||||||
const [pubRows] = await pool.query(
|
const [pubRows] = await pool.query(
|
||||||
`SELECT pr.id, pr.url_slug, pr.public_url, pr.page_version_id, pr.access_mode,
|
`SELECT pr.id, pr.url_slug, pr.public_url, pr.page_version_id, pr.access_mode,
|
||||||
pr.status, pr.view_count, pr.published_at, pr.offline_at, pr.expires_at,
|
pr.status, pr.view_count, pr.published_at, pr.offline_at, pr.expires_at,
|
||||||
|
pr.user_confirmed_at,
|
||||||
pv.bundle_asset_id, av.id AS asset_version_id, av.storage_key
|
pv.bundle_asset_id, av.id AS asset_version_id, av.storage_key
|
||||||
FROM h5_publish_records pr
|
FROM h5_publish_records pr
|
||||||
JOIN h5_page_records p ON p.id = pr.page_id AND p.user_id = pr.user_id
|
JOIN h5_page_records p ON p.id = pr.page_id AND p.user_id = pr.user_id
|
||||||
@@ -1371,7 +1373,7 @@ export function createPublicationService(pool, options = {}) {
|
|||||||
const cleanupExpiredUnconfirmedPublications = async (now = Date.now()) => {
|
const cleanupExpiredUnconfirmedPublications = async (now = Date.now()) => {
|
||||||
const [result] = await pool.query(
|
const [result] = await pool.query(
|
||||||
`UPDATE h5_publish_records
|
`UPDATE h5_publish_records
|
||||||
SET access_mode = 'private', expires_at = NULL, updated_at = ?
|
SET access_mode = 'owner_only', expires_at = NULL, updated_at = ?
|
||||||
WHERE access_mode = 'public'
|
WHERE access_mode = 'public'
|
||||||
AND expires_at IS NOT NULL
|
AND expires_at IS NOT NULL
|
||||||
AND expires_at <= ?
|
AND expires_at <= ?
|
||||||
|
|||||||
@@ -34,6 +34,72 @@ test('accepts all documented access modes', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getCurrent exposes whether the owner confirmed publication visibility', async () => {
|
||||||
|
const service = createPublicationService({
|
||||||
|
async query(sql, params) {
|
||||||
|
assert.match(sql, /SELECT pr\.\*/);
|
||||||
|
assert.deepEqual(params, ['page-1', 'user-1']);
|
||||||
|
return [[{
|
||||||
|
id: 'pub-1',
|
||||||
|
page_id: 'page-1',
|
||||||
|
page_version_id: 'version-1',
|
||||||
|
url_slug: 'journal',
|
||||||
|
public_url: '/u/john/pages/journal',
|
||||||
|
access_mode: 'public',
|
||||||
|
expires_at: null,
|
||||||
|
status: 'online',
|
||||||
|
view_count: 2,
|
||||||
|
published_at: 1000,
|
||||||
|
offline_at: null,
|
||||||
|
user_confirmed_at: 2000,
|
||||||
|
}]];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const publication = await service.getCurrent('user-1', 'page-1');
|
||||||
|
assert.equal(publication.userConfirmedAt, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getCurrent returns null confirmation for an unconfirmed publication', async () => {
|
||||||
|
const service = createPublicationService({
|
||||||
|
async query() {
|
||||||
|
return [[{
|
||||||
|
id: 'pub-1',
|
||||||
|
page_id: 'page-1',
|
||||||
|
page_version_id: 'version-1',
|
||||||
|
url_slug: 'journal',
|
||||||
|
public_url: '/u/john/pages/journal',
|
||||||
|
access_mode: 'public',
|
||||||
|
expires_at: null,
|
||||||
|
status: 'online',
|
||||||
|
view_count: 0,
|
||||||
|
published_at: 1000,
|
||||||
|
offline_at: null,
|
||||||
|
user_confirmed_at: null,
|
||||||
|
}]];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const publication = await service.getCurrent('user-1', 'page-1');
|
||||||
|
assert.equal(publication.userConfirmedAt, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cleanupExpiredUnconfirmedPublications falls back to owner-only access', async () => {
|
||||||
|
let executedSql = '';
|
||||||
|
const service = createPublicationService({
|
||||||
|
async query(sql, params) {
|
||||||
|
executedSql = sql;
|
||||||
|
assert.deepEqual(params, [3000, 3000]);
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.cleanupExpiredUnconfirmedPublications(3000);
|
||||||
|
assert.deepEqual(result, { cleaned: 1 });
|
||||||
|
assert.match(executedSql, /SET access_mode = 'owner_only'/);
|
||||||
|
assert.doesNotMatch(executedSql, /SET access_mode = 'private'/);
|
||||||
|
});
|
||||||
|
|
||||||
test('hashes access passwords with a random salt', () => {
|
test('hashes access passwords with a random salt', () => {
|
||||||
const first = publicationInternals.hashPassword('Publish-Password-2026');
|
const first = publicationInternals.hashPassword('Publish-Password-2026');
|
||||||
const second = publicationInternals.hashPassword('Publish-Password-2026');
|
const second = publicationInternals.hashPassword('Publish-Password-2026');
|
||||||
|
|||||||
Generated
+8
-8
@@ -13,7 +13,7 @@
|
|||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"framer-motion": "^12.42.0",
|
"framer-motion": "^12.42.0",
|
||||||
"http-proxy-middleware": "^3.0.3",
|
"http-proxy-middleware": "^3.0.7",
|
||||||
"jsonrepair": "^3.14.0",
|
"jsonrepair": "^3.14.0",
|
||||||
"lucide-react": "^1.21.0",
|
"lucide-react": "^1.21.0",
|
||||||
"mysql2": "^3.22.5",
|
"mysql2": "^3.22.5",
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"redis": "^4.7.1",
|
"redis": "^4.7.1",
|
||||||
"sharp": "^0.35.2",
|
"sharp": "^0.35.2",
|
||||||
"undici": "^6.26.0"
|
"undici": "^6.27.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
@@ -3343,9 +3343,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/http-proxy-middleware": {
|
"node_modules/http-proxy-middleware": {
|
||||||
"version": "3.0.6",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.7.tgz",
|
||||||
"integrity": "sha512-jhO3QfahaHWfQjEnyGW0vpYIYaXcnA6FEfehrBthOokGppvmI6zcV+1yb6TWn3vyeh8yQUoEqH51DNHOCjivxg==",
|
"integrity": "sha512-iwbQltVlx8bCrqePUM8C+hllHvdawVhQJaLrj1X7qllkvFQdXFsr16pW/mo9+JDVjN+QO2XUx9jd8SmoFkE5qw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/http-proxy": "^1.17.15",
|
"@types/http-proxy": "^1.17.15",
|
||||||
@@ -4691,9 +4691,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "6.26.0",
|
"version": "6.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
|
||||||
"integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==",
|
"integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.17"
|
"node": ">=18.17"
|
||||||
|
|||||||
+2
-2
@@ -93,7 +93,7 @@
|
|||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"framer-motion": "^12.42.0",
|
"framer-motion": "^12.42.0",
|
||||||
"http-proxy-middleware": "^3.0.3",
|
"http-proxy-middleware": "^3.0.7",
|
||||||
"jsonrepair": "^3.14.0",
|
"jsonrepair": "^3.14.0",
|
||||||
"lucide-react": "^1.21.0",
|
"lucide-react": "^1.21.0",
|
||||||
"mysql2": "^3.22.5",
|
"mysql2": "^3.22.5",
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"redis": "^4.7.1",
|
"redis": "^4.7.1",
|
||||||
"sharp": "^0.35.2",
|
"sharp": "^0.35.2",
|
||||||
"undici": "^6.26.0"
|
"undici": "^6.27.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
|
|||||||
Generated
+10
-10
@@ -24,8 +24,8 @@ importers:
|
|||||||
specifier: ^12.42.0
|
specifier: ^12.42.0
|
||||||
version: 12.42.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
version: 12.42.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
http-proxy-middleware:
|
http-proxy-middleware:
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.7
|
||||||
version: 3.0.6
|
version: 3.0.7
|
||||||
jsonrepair:
|
jsonrepair:
|
||||||
specifier: ^3.14.0
|
specifier: ^3.14.0
|
||||||
version: 3.14.0
|
version: 3.14.0
|
||||||
@@ -60,8 +60,8 @@ importers:
|
|||||||
specifier: ^0.35.2
|
specifier: ^0.35.2
|
||||||
version: 0.35.2
|
version: 0.35.2
|
||||||
undici:
|
undici:
|
||||||
specifier: ^6.26.0
|
specifier: ^6.27.0
|
||||||
version: 6.26.0
|
version: 6.27.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.0.10
|
specifier: ^19.0.10
|
||||||
@@ -1201,8 +1201,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
http-proxy-middleware@3.0.6:
|
http-proxy-middleware@3.0.7:
|
||||||
resolution: {integrity: sha512-jhO3QfahaHWfQjEnyGW0vpYIYaXcnA6FEfehrBthOokGppvmI6zcV+1yb6TWn3vyeh8yQUoEqH51DNHOCjivxg==}
|
resolution: {integrity: sha512-iwbQltVlx8bCrqePUM8C+hllHvdawVhQJaLrj1X7qllkvFQdXFsr16pW/mo9+JDVjN+QO2XUx9jd8SmoFkE5qw==}
|
||||||
engines: {node: ^14.18.0 || ^16.10.0 || >=18.0.0}
|
engines: {node: ^14.18.0 || ^16.10.0 || >=18.0.0}
|
||||||
|
|
||||||
http-proxy@1.18.1:
|
http-proxy@1.18.1:
|
||||||
@@ -1635,8 +1635,8 @@ packages:
|
|||||||
undici-types@7.24.6:
|
undici-types@7.24.6:
|
||||||
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
||||||
|
|
||||||
undici@6.26.0:
|
undici@6.27.0:
|
||||||
resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==}
|
resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==}
|
||||||
engines: {node: '>=18.17'}
|
engines: {node: '>=18.17'}
|
||||||
|
|
||||||
unpipe@1.0.0:
|
unpipe@1.0.0:
|
||||||
@@ -2678,7 +2678,7 @@ snapshots:
|
|||||||
statuses: 2.0.2
|
statuses: 2.0.2
|
||||||
toidentifier: 1.0.1
|
toidentifier: 1.0.1
|
||||||
|
|
||||||
http-proxy-middleware@3.0.6:
|
http-proxy-middleware@3.0.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/http-proxy': 1.17.17
|
'@types/http-proxy': 1.17.17
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
@@ -3125,7 +3125,7 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@7.24.6: {}
|
undici-types@7.24.6: {}
|
||||||
|
|
||||||
undici@6.26.0: {}
|
undici@6.27.0: {}
|
||||||
|
|
||||||
unpipe@1.0.0: {}
|
unpipe@1.0.0: {}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ const messageTs = read('src/utils/message.ts');
|
|||||||
assertIncludes(messageTs, 'deriveUserFacingText', 'message.ts');
|
assertIncludes(messageTs, 'deriveUserFacingText', 'message.ts');
|
||||||
assertIncludes(messageTs, 'deriveAssistantFacingText', 'message.ts');
|
assertIncludes(messageTs, 'deriveAssistantFacingText', 'message.ts');
|
||||||
assertIncludes(messageTs, 'chat-finish-sync.mjs', 'message.ts');
|
assertIncludes(messageTs, 'chat-finish-sync.mjs', 'message.ts');
|
||||||
|
assertIncludes(
|
||||||
|
messageTs,
|
||||||
|
'deriveUserFacingText(message.metadata.displayText)',
|
||||||
|
'message.ts metadata displayText guard',
|
||||||
|
);
|
||||||
|
|
||||||
const conversationDisplay = read('conversation-display.mjs');
|
const conversationDisplay = read('conversation-display.mjs');
|
||||||
assertIncludes(conversationDisplay, 'TASK_ROUTING_HINT_RE', 'conversation-display.mjs');
|
assertIncludes(conversationDisplay, 'TASK_ROUTING_HINT_RE', 'conversation-display.mjs');
|
||||||
@@ -44,6 +49,18 @@ assertIncludes(conversationDisplay, 'deriveAssistantFacingText', 'conversation-d
|
|||||||
|
|
||||||
const chatSkills = read('chat-skills.mjs');
|
const chatSkills = read('chat-skills.mjs');
|
||||||
assertIncludes(chatSkills, 'stripKnownChatSkillPrompt', 'chat-skills.mjs');
|
assertIncludes(chatSkills, 'stripKnownChatSkillPrompt', 'chat-skills.mjs');
|
||||||
|
assertIncludes(chatSkills, "legacyEndMarker = '并说明后台入口与口令。'", 'chat-skills.mjs');
|
||||||
|
|
||||||
|
const chatPanel = read('src/components/ChatPanel.tsx');
|
||||||
|
assertIncludes(chatPanel, "import { VoiceInputButton } from './VoiceInputButton'", 'ChatPanel.tsx');
|
||||||
|
assertIncludes(chatPanel, '<VoiceInputButton', 'ChatPanel.tsx');
|
||||||
|
assertIncludes(chatPanel, 'onLiveTranscript={handleVoiceLiveTranscript}', 'ChatPanel.tsx');
|
||||||
|
assertIncludes(chatPanel, 'onTranscript={handleVoiceTranscript}', 'ChatPanel.tsx');
|
||||||
|
assertIncludes(chatPanel, 'setInput(mergeVoiceText(text))', 'ChatPanel.tsx voice transcript wiring');
|
||||||
|
|
||||||
|
const voiceInputButton = read('src/components/VoiceInputButton.tsx');
|
||||||
|
assertIncludes(voiceInputButton, 'useVoiceSession({', 'VoiceInputButton.tsx');
|
||||||
|
assertIncludes(voiceInputButton, 'onTranscript?.(transcript)', 'VoiceInputButton.tsx');
|
||||||
|
|
||||||
const server = read('server.mjs');
|
const server = read('server.mjs');
|
||||||
assertIncludes(server, 'canUseSnapshotCache', 'server.mjs');
|
assertIncludes(server, 'canUseSnapshotCache', 'server.mjs');
|
||||||
|
|||||||
+77
-1242
File diff suppressed because it is too large
Load Diff
+257
@@ -0,0 +1,257 @@
|
|||||||
|
import type { InsufficientBalanceDetails, SessionEvent } from '../types';
|
||||||
|
|
||||||
|
export const API = '/api';
|
||||||
|
const DEFAULT_API_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly code?: string;
|
||||||
|
readonly details?: InsufficientBalanceDetails | Record<string, unknown>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
status: number,
|
||||||
|
message: string,
|
||||||
|
code?: string,
|
||||||
|
details?: InsufficientBalanceDetails | Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
super(sanitizeUserFacingErrorMessage(message));
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeUserFacingErrorMessage(message: string) {
|
||||||
|
const normalized = String(message ?? '').trim();
|
||||||
|
const serviceName = [103, 111, 111, 115, 101]
|
||||||
|
.map((code) => String.fromCharCode(code))
|
||||||
|
.join('');
|
||||||
|
const servicePattern = new RegExp(`${serviceName}d?`, 'i');
|
||||||
|
if (!normalized) return normalized;
|
||||||
|
if (!servicePattern.test(normalized)) return normalized;
|
||||||
|
if (/超时|timeout/i.test(normalized)) {
|
||||||
|
return '后端连接超时,请确认后端服务正常后重试';
|
||||||
|
}
|
||||||
|
if (/不可用|连接失败|failed to fetch|networkerror|fetch failed|upstream|econn|enotfound/i.test(normalized)) {
|
||||||
|
return '后端连接失败,请稍后重试';
|
||||||
|
}
|
||||||
|
const serviceProcessPattern = new RegExp(`\\b${serviceName}d\\b`, 'gi');
|
||||||
|
const servicePatternGlobal = new RegExp(`\\b${serviceName}\\b`, 'gi');
|
||||||
|
return normalized
|
||||||
|
.replace(serviceProcessPattern, '后端服务')
|
||||||
|
.replace(servicePatternGlobal, '后端');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseErrorResponse(res: Response): Promise<{
|
||||||
|
message: string;
|
||||||
|
code?: string;
|
||||||
|
details?: InsufficientBalanceDetails | Record<string, unknown>;
|
||||||
|
}> {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(text) as Record<string, unknown>;
|
||||||
|
const nested =
|
||||||
|
body.error && typeof body.error === 'object'
|
||||||
|
? (body.error as Record<string, unknown>)
|
||||||
|
: body;
|
||||||
|
const message =
|
||||||
|
typeof nested.message === 'string'
|
||||||
|
? nested.message
|
||||||
|
: typeof body.message === 'string'
|
||||||
|
? body.message
|
||||||
|
: text;
|
||||||
|
const code =
|
||||||
|
typeof nested.code === 'string'
|
||||||
|
? nested.code
|
||||||
|
: typeof body.code === 'string'
|
||||||
|
? body.code
|
||||||
|
: undefined;
|
||||||
|
const details = nested.details ?? body.details;
|
||||||
|
if (code === 'INSUFFICIENT_BALANCE') {
|
||||||
|
return {
|
||||||
|
message: sanitizeUserFacingErrorMessage(message),
|
||||||
|
code,
|
||||||
|
details: {
|
||||||
|
code: 'INSUFFICIENT_BALANCE' as const,
|
||||||
|
balanceCents: Number((details as Record<string, unknown>)?.balanceCents ?? body.balanceCents ?? 0),
|
||||||
|
minRechargeCents: Number(
|
||||||
|
(details as Record<string, unknown>)?.minRechargeCents ?? body.minRechargeCents ?? 500,
|
||||||
|
),
|
||||||
|
suggestedTiers: Array.isArray((details as Record<string, unknown>)?.suggestedTiers)
|
||||||
|
? ((details as Record<string, unknown>).suggestedTiers as unknown[]).map((value) => Number(value))
|
||||||
|
: Array.isArray(body.suggestedTiers)
|
||||||
|
? body.suggestedTiers.map((value) => Number(value))
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
message: sanitizeUserFacingErrorMessage(message),
|
||||||
|
code,
|
||||||
|
details: details && typeof details === 'object'
|
||||||
|
? (details as InsufficientBalanceDetails | Record<string, unknown>)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { message: sanitizeUserFacingErrorMessage(text || res.statusText) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let unauthorizedHandler: (() => void) | null = null;
|
||||||
|
let unauthorizedHandling = false;
|
||||||
|
|
||||||
|
export function setUnauthorizedHandler(handler: (() => void) | null) {
|
||||||
|
unauthorizedHandler = handler;
|
||||||
|
if (handler) unauthorizedHandling = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetUnauthorizedGuard() {
|
||||||
|
unauthorizedHandling = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUnauthorized() {
|
||||||
|
if (!unauthorizedHandler || unauthorizedHandling) return;
|
||||||
|
unauthorizedHandling = true;
|
||||||
|
unauthorizedHandler();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWithTimeout(
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit,
|
||||||
|
timeoutMs = DEFAULT_API_TIMEOUT_MS,
|
||||||
|
): Promise<Response> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const upstreamSignal = init?.signal;
|
||||||
|
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
const abortFromUpstream = () => controller.abort();
|
||||||
|
if (upstreamSignal) {
|
||||||
|
if (upstreamSignal.aborted) controller.abort();
|
||||||
|
else upstreamSignal.addEventListener('abort', abortFromUpstream, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await fetch(input, {
|
||||||
|
...init,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
upstreamSignal?.removeEventListener('abort', abortFromUpstream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function portalFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetchWithTimeout(path, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new ApiError(0, formatNetworkError(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
notifyUnauthorized();
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new ApiError(401, text || '未授权,请重新登录');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const parsed = await parseErrorResponse(res);
|
||||||
|
throw new ApiError(
|
||||||
|
res.status,
|
||||||
|
parsed.message || `${res.status} ${res.statusText}`,
|
||||||
|
parsed.code,
|
||||||
|
parsed.details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
if (text.trimStart().startsWith('<')) {
|
||||||
|
throw new ApiError(
|
||||||
|
res.status,
|
||||||
|
`接口 ${path} 返回了页面而非 JSON,请重启后端(pnpm dev 或 node server.mjs)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(res.status, '服务器响应格式错误');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNetworkError(err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '网络请求失败';
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
|
return '后端连接超时,请确认后端服务正常后重试';
|
||||||
|
}
|
||||||
|
if (message.includes('Failed to fetch') || message.includes('NetworkError')) {
|
||||||
|
return '无法连接后端服务,请先运行: pnpm dev 或 node server.mjs';
|
||||||
|
}
|
||||||
|
return sanitizeUserFacingErrorMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeSessionEvent(event: SessionEvent): SessionEvent {
|
||||||
|
if (event.type !== 'Error') return event;
|
||||||
|
return { ...event, error: sanitizeUserFacingErrorMessage(event.error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetch<T>(
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
options?: { timeoutMs?: number },
|
||||||
|
): Promise<T> {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetchWithTimeout(
|
||||||
|
`${API}${path}`,
|
||||||
|
{
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options?.timeoutMs,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
throw new ApiError(0, formatNetworkError(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
notifyUnauthorized();
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new ApiError(401, text || '未授权,请重新登录');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const parsed = await parseErrorResponse(res);
|
||||||
|
throw new ApiError(
|
||||||
|
res.status,
|
||||||
|
parsed.message || `${res.status} ${res.statusText}`,
|
||||||
|
parsed.code,
|
||||||
|
parsed.details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
const rawText = await res.text().catch(() => '');
|
||||||
|
if (rawText.trimStart().startsWith('<')) {
|
||||||
|
throw new ApiError(
|
||||||
|
res.status,
|
||||||
|
`接口 ${API}${path} 返回了页面而非 JSON,请重启后端(pnpm dev 或 node server.mjs)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawText) as T;
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(res.status, '服务器响应格式错误');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { MindSpaceAgentJob } from '../types';
|
||||||
|
import { apiFetch } from './core';
|
||||||
|
import type { MindSpaceListPage } from './mindspace-pages';
|
||||||
|
|
||||||
|
export async function createMindSpaceAgentJob(input: {
|
||||||
|
jobType: string;
|
||||||
|
instruction: string;
|
||||||
|
allowedAssetIds: string[];
|
||||||
|
outputType?: 'page_draft' | 'html_page' | 'markdown';
|
||||||
|
outputCategoryId?: string;
|
||||||
|
idempotencyKey?: string;
|
||||||
|
locale?: string;
|
||||||
|
timezone?: string;
|
||||||
|
capabilities?: {
|
||||||
|
network?: boolean;
|
||||||
|
shell?: boolean;
|
||||||
|
createPage?: boolean;
|
||||||
|
};
|
||||||
|
}): Promise<MindSpaceAgentJob> {
|
||||||
|
const result = await apiFetch<{ data: MindSpaceAgentJob }>('/mindspace/v1/agent/jobs', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
job_type: input.jobType,
|
||||||
|
instruction: input.instruction,
|
||||||
|
allowed_asset_ids: input.allowedAssetIds,
|
||||||
|
output_type: input.outputType ?? 'page_draft',
|
||||||
|
output_category_id: input.outputCategoryId,
|
||||||
|
idempotency_key: input.idempotencyKey,
|
||||||
|
locale: input.locale,
|
||||||
|
timezone: input.timezone,
|
||||||
|
capabilities: input.capabilities,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMindSpaceAgentJob(jobId: string): Promise<MindSpaceAgentJob> {
|
||||||
|
const result = await apiFetch<{ data: MindSpaceAgentJob }>(
|
||||||
|
`/mindspace/v1/agent/jobs/${encodeURIComponent(jobId)}`,
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMindSpaceAgentJobs(options?: {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): Promise<{ items: MindSpaceAgentJob[]; page: MindSpaceListPage }> {
|
||||||
|
const limit = options?.limit ?? 10;
|
||||||
|
const offset = options?.offset ?? 0;
|
||||||
|
const result = await apiFetch<{ data: MindSpaceAgentJob[]; page?: MindSpaceListPage }>(
|
||||||
|
`/mindspace/v1/agent/jobs?limit=${encodeURIComponent(String(limit))}&offset=${encodeURIComponent(String(offset))}`,
|
||||||
|
);
|
||||||
|
return { items: result.data, page: result.page ?? {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runMindSpaceAgentJob(
|
||||||
|
jobId: string,
|
||||||
|
): Promise<{ started: boolean; jobId: string }> {
|
||||||
|
const result = await apiFetch<{ data: { started: boolean; jobId: string } }>(
|
||||||
|
`/mindspace/v1/agent/jobs/${encodeURIComponent(jobId)}/run`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({}) },
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelMindSpaceAgentJob(jobId: string): Promise<MindSpaceAgentJob> {
|
||||||
|
const result = await apiFetch<{ data: MindSpaceAgentJob }>(
|
||||||
|
`/mindspace/v1/agent/jobs/${encodeURIComponent(jobId)}/cancel`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({}) },
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryMindSpaceAgentJob(jobId: string): Promise<MindSpaceAgentJob> {
|
||||||
|
const result = await apiFetch<{ data: MindSpaceAgentJob }>(
|
||||||
|
`/mindspace/v1/agent/jobs/${encodeURIComponent(jobId)}/retry`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({}) },
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import type {
|
||||||
|
MindSpace,
|
||||||
|
MindSpaceAsset,
|
||||||
|
MindSpaceCleanupItem,
|
||||||
|
MindSpaceConversationPackage,
|
||||||
|
MindSpaceQuota,
|
||||||
|
MindSpaceScheduleReminder,
|
||||||
|
MindSpaceUpload,
|
||||||
|
} from '../types';
|
||||||
|
import { CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES } from '../utils/imageUpload';
|
||||||
|
import { API, ApiError, apiFetch } from './core';
|
||||||
|
|
||||||
|
function formatBytes(bytes: number) {
|
||||||
|
if (!Number.isFinite(bytes) || bytes < 0) return '0B';
|
||||||
|
if (bytes >= 1024 * 1024) {
|
||||||
|
const mb = bytes / 1024 / 1024;
|
||||||
|
return `${Number.isInteger(mb) ? mb : mb.toFixed(1)}MB`;
|
||||||
|
}
|
||||||
|
if (bytes >= 1024) {
|
||||||
|
const kb = bytes / 1024;
|
||||||
|
return `${Number.isInteger(kb) ? kb : kb.toFixed(1)}KB`;
|
||||||
|
}
|
||||||
|
return `${bytes}B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumberDetail(details: ApiError['details'], key: string) {
|
||||||
|
if (!details || typeof details !== 'object') return null;
|
||||||
|
const value = (details as Record<string, unknown>)[key];
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageUploadFile(file: File) {
|
||||||
|
if (file.type.startsWith('image/')) return true;
|
||||||
|
return /\.(png|jpe?g|webp|gif)$/i.test(file.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMindSpaceUploadError(error: unknown, file: File): Error {
|
||||||
|
if (!(error instanceof ApiError)) {
|
||||||
|
return error instanceof Error ? error : new Error('上传失败,请重试');
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageMax = formatBytes(CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES);
|
||||||
|
if (error.code === 'quota_exceeded' || error.status === 429) {
|
||||||
|
const requiredBytes = readNumberDetail(error.details, 'requiredBytes');
|
||||||
|
const availableBytes = readNumberDetail(error.details, 'availableBytes');
|
||||||
|
const detail =
|
||||||
|
requiredBytes !== null && availableBytes !== null
|
||||||
|
? `当前剩余 ${formatBytes(availableBytes)},本次需要 ${formatBytes(requiredBytes)}。`
|
||||||
|
: '';
|
||||||
|
return new ApiError(
|
||||||
|
error.status,
|
||||||
|
`剩余空间不足,${detail}请减少图片数量或压缩后重试。`,
|
||||||
|
error.code,
|
||||||
|
error.details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === 'file_too_large' || error.status === 413) {
|
||||||
|
const message = isImageUploadFile(file)
|
||||||
|
? `图片文件过大,单张图片不能超过 ${imageMax},请压缩后重试。`
|
||||||
|
: `文件过大,请压缩到单文件上限以内后重试。`;
|
||||||
|
return new ApiError(error.status, message, error.code, error.details);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/MindSpace\s*服务异常/.test(error.message) || error.code === 'internal_error') {
|
||||||
|
return new ApiError(
|
||||||
|
error.status,
|
||||||
|
`上传失败,请减少图片数量或压缩图片后重试;单张图片上限 ${imageMax}。`,
|
||||||
|
error.code,
|
||||||
|
error.details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMindSpace(): Promise<MindSpace> {
|
||||||
|
const result = await apiFetch<{ data: MindSpace }>('/mindspace/v1/space');
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMindSpaceConversationPackage(
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<MindSpaceConversationPackage> {
|
||||||
|
const result = await apiFetch<{ data: MindSpaceConversationPackage }>(
|
||||||
|
`/mindspace/v1/conversation-packages/${encodeURIComponent(sessionId)}`,
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMindSpaceConversationPackageManifestDownloadUrl(sessionId: string): string {
|
||||||
|
return `${API}/mindspace/v1/conversation-packages/${encodeURIComponent(sessionId)}/manifest.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ignoreMindSpaceScheduleReminder(reminderId: string) {
|
||||||
|
const result = await apiFetch<{ data: MindSpaceScheduleReminder }>(
|
||||||
|
`/mindspace/v1/schedule/reminders/${encodeURIComponent(reminderId)}/ignore`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMindSpaceScheduleReminders(ids: string[]) {
|
||||||
|
const result = await apiFetch<{ data: { deleted: number } }>(
|
||||||
|
'/mindspace/v1/schedule/reminders/bulk-delete',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMindSpaceCleanupItems(): Promise<{
|
||||||
|
items: MindSpaceCleanupItem[];
|
||||||
|
totalBytes: number;
|
||||||
|
}> {
|
||||||
|
const result = await apiFetch<{
|
||||||
|
data: { items: MindSpaceCleanupItem[]; totalBytes: number };
|
||||||
|
}>('/mindspace/v1/space/cleanup');
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runMindSpaceCleanup(itemIds: string[]): Promise<{
|
||||||
|
removedCount: number;
|
||||||
|
freedBytes: number;
|
||||||
|
quota?: MindSpaceQuota;
|
||||||
|
}> {
|
||||||
|
const result = await apiFetch<{
|
||||||
|
data: { removedCount: number; freedBytes: number; quota?: MindSpaceQuota };
|
||||||
|
}>('/mindspace/v1/space/cleanup', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ item_ids: itemIds }),
|
||||||
|
});
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMindSpaceAssets(
|
||||||
|
categoryCode?: string,
|
||||||
|
): Promise<MindSpaceAsset[]> {
|
||||||
|
const query = categoryCode ? `?category_code=${encodeURIComponent(categoryCode)}` : '';
|
||||||
|
const result = await apiFetch<{ data: MindSpaceAsset[] }>(`/mindspace/v1/assets${query}`);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadMindSpaceAsset(
|
||||||
|
categoryId: string,
|
||||||
|
file: File,
|
||||||
|
options: {
|
||||||
|
maxImageBytes?: number;
|
||||||
|
onProgress?: (progress: number) => void;
|
||||||
|
sessionId?: string | null;
|
||||||
|
messageId?: string | null;
|
||||||
|
} = {},
|
||||||
|
): Promise<MindSpaceAsset> {
|
||||||
|
const maxImageBytes = options.maxImageBytes ?? CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES;
|
||||||
|
if (file.type.startsWith('image/') && file.size > maxImageBytes) {
|
||||||
|
throw new ApiError(
|
||||||
|
413,
|
||||||
|
`图片文件过大,当前 ${(file.size / 1024 / 1024).toFixed(2)}MB,超过 ${maxImageBytes / 1024 / 1024}MB 上传上限。`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let created: { data: MindSpaceUpload };
|
||||||
|
try {
|
||||||
|
created = await apiFetch<{ data: MindSpaceUpload }>('/mindspace/v1/uploads', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
category_id: categoryId,
|
||||||
|
filename: file.name,
|
||||||
|
size_bytes: file.size,
|
||||||
|
declared_mime_type: file.type || null,
|
||||||
|
...(options.sessionId ? { session_id: options.sessionId } : {}),
|
||||||
|
...(options.messageId ? { message_id: options.messageId } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw normalizeMindSpaceUploadError(error, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadFileContent(created.data.uploadUrl, file, options.onProgress);
|
||||||
|
const completed = await apiFetch<{ data: MindSpaceAsset }>(
|
||||||
|
`/mindspace/v1/uploads/${created.data.id}/complete`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({}) },
|
||||||
|
);
|
||||||
|
return completed.data;
|
||||||
|
} catch (error) {
|
||||||
|
await apiFetch(`/mindspace/v1/uploads/${created.data.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}).catch(() => {});
|
||||||
|
throw normalizeMindSpaceUploadError(error, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function claimMindSpaceConversationUploads(
|
||||||
|
sessionId: string,
|
||||||
|
messageId: string,
|
||||||
|
): Promise<{ claimedCount: number }> {
|
||||||
|
const result = await apiFetch<{ data: { claimedCount: number } }>(
|
||||||
|
`/mindspace/v1/conversation-packages/${encodeURIComponent(sessionId)}/claim-uploads`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ message_id: messageId }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadFileContent(
|
||||||
|
url: string,
|
||||||
|
file: File,
|
||||||
|
onProgress?: (progress: number) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('PUT', url);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (!event.lengthComputable || !onProgress) return;
|
||||||
|
onProgress(Math.min(0.99, Math.max(0, event.loaded / event.total)));
|
||||||
|
};
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
onProgress?.(1);
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let message = '文件内容上传失败';
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(xhr.responseText || '{}') as { error?: { message?: string } };
|
||||||
|
message = body?.error?.message ?? message;
|
||||||
|
} catch {
|
||||||
|
// Keep the generic upload error when the response is not JSON.
|
||||||
|
}
|
||||||
|
reject(new ApiError(xhr.status, message));
|
||||||
|
};
|
||||||
|
xhr.onerror = () => reject(new ApiError(0, '文件内容上传失败'));
|
||||||
|
xhr.onabort = () => reject(new ApiError(0, '文件上传已取消'));
|
||||||
|
xhr.send(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMindSpaceAsset(assetId: string): Promise<void> {
|
||||||
|
await apiFetch(`/mindspace/v1/assets/${assetId}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import type {
|
||||||
|
ChatSaveResult,
|
||||||
|
MindSpacePage,
|
||||||
|
MindSpacePageDeletePreview,
|
||||||
|
MindSpacePageDeleteResult,
|
||||||
|
MindSpaceSaveCategory,
|
||||||
|
} from '../types';
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
apiFetch,
|
||||||
|
formatNetworkError,
|
||||||
|
notifyUnauthorized,
|
||||||
|
parseErrorResponse,
|
||||||
|
} from './core';
|
||||||
|
|
||||||
|
export type MindSpaceListPage = {
|
||||||
|
total?: number;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
has_more?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getMindSpacePageDeletePreview(
|
||||||
|
pageId: string,
|
||||||
|
): Promise<MindSpacePageDeletePreview> {
|
||||||
|
const result = await apiFetch<{ data: MindSpacePageDeletePreview }>(
|
||||||
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/delete-preview`,
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMindSpacePage(
|
||||||
|
pageId: string,
|
||||||
|
options?: { removeFromPlaza?: boolean },
|
||||||
|
): Promise<MindSpacePageDeleteResult> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.removeFromPlaza) params.set('remove_from_plaza', 'true');
|
||||||
|
const query = params.toString() ? `?${params.toString()}` : '';
|
||||||
|
const result = await apiFetch<{ data: MindSpacePageDeleteResult }>(
|
||||||
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}${query}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMindSpacePages(options?: {
|
||||||
|
status?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
categoryCode?: string;
|
||||||
|
}): Promise<{ items: MindSpacePage[]; page: MindSpaceListPage }> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.status) params.set('status', options.status);
|
||||||
|
if (options?.limit != null) params.set('limit', String(options.limit));
|
||||||
|
if (options?.offset != null) params.set('offset', String(options.offset));
|
||||||
|
if (options?.categoryCode) params.set('category_code', options.categoryCode);
|
||||||
|
const query = params.toString() ? `?${params.toString()}` : '';
|
||||||
|
const result = await apiFetch<{ data: MindSpacePage[]; page?: MindSpaceListPage }>(
|
||||||
|
`/mindspace/v1/pages${query}`,
|
||||||
|
);
|
||||||
|
return { items: result.data, page: result.page ?? {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMindSpacePage(pageId: string): Promise<MindSpacePage> {
|
||||||
|
const result = await apiFetch<{ data: MindSpacePage }>(
|
||||||
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}`,
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveChatMessageAsPage(input: {
|
||||||
|
sessionId: string;
|
||||||
|
messageId: string;
|
||||||
|
title: string;
|
||||||
|
summary?: string;
|
||||||
|
templateId?: string;
|
||||||
|
categoryCode?: MindSpaceSaveCategory;
|
||||||
|
selectedLinkIndex?: number;
|
||||||
|
acknowledgedFindingIds?: string[];
|
||||||
|
replacePageId?: string;
|
||||||
|
saveAsNew?: boolean;
|
||||||
|
}): Promise<ChatSaveResult> {
|
||||||
|
const result = await apiFetch<{ data: ChatSaveResult }>(
|
||||||
|
'/mindspace/v1/pages/save-from-chat',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: input.sessionId,
|
||||||
|
message_id: input.messageId,
|
||||||
|
title: input.title,
|
||||||
|
summary: input.summary,
|
||||||
|
template_id: input.templateId ?? 'editorial',
|
||||||
|
category_code: input.categoryCode ?? 'draft',
|
||||||
|
selected_link_index: input.selectedLinkIndex ?? 0,
|
||||||
|
acknowledged_finding_ids: input.acknowledgedFindingIds,
|
||||||
|
page_type: input.templateId === 'report' ? 'report' : 'article',
|
||||||
|
replace_page_id: input.replacePageId,
|
||||||
|
save_as_new: input.saveAsNew ?? false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMindSpacePageFromAsset(input: {
|
||||||
|
assetId: string;
|
||||||
|
title?: string;
|
||||||
|
summary?: string;
|
||||||
|
}): Promise<MindSpacePage> {
|
||||||
|
const result = await apiFetch<{ data: { page: MindSpacePage } }>(
|
||||||
|
'/mindspace/v1/pages/from-asset',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
asset_id: input.assetId,
|
||||||
|
title: input.title,
|
||||||
|
summary: input.summary,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result.data.page;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMindSpacePage(input: {
|
||||||
|
title: string;
|
||||||
|
summary?: string;
|
||||||
|
content: string;
|
||||||
|
templateId: string;
|
||||||
|
}): Promise<MindSpacePage> {
|
||||||
|
const result = await apiFetch<{ data: MindSpacePage }>('/mindspace/v1/pages', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: input.title,
|
||||||
|
summary: input.summary,
|
||||||
|
content: input.content,
|
||||||
|
template_id: input.templateId,
|
||||||
|
page_type: input.templateId === 'report' ? 'report' : 'article',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMindSpacePage(
|
||||||
|
pageId: string,
|
||||||
|
input: {
|
||||||
|
expectedVersion: number;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
content: string;
|
||||||
|
templateId: string;
|
||||||
|
changeNote?: string;
|
||||||
|
},
|
||||||
|
): Promise<MindSpacePage> {
|
||||||
|
const result = await apiFetch<{ data: MindSpacePage }>(
|
||||||
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
expected_version: input.expectedVersion,
|
||||||
|
title: input.title,
|
||||||
|
summary: input.summary,
|
||||||
|
content: input.content,
|
||||||
|
template_id: input.templateId,
|
||||||
|
page_type: input.templateId === 'report' ? 'report' : 'article',
|
||||||
|
change_note: input.changeNote,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rewriteMindSpacePageDownloadLinks(
|
||||||
|
pageId: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const result = await apiFetch<{ data: { html: string } }>(
|
||||||
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/rewrite-download-links`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result.data.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMindSpacePageDraftPreview(
|
||||||
|
pageId: string,
|
||||||
|
input: {
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
content: string;
|
||||||
|
templateId: string;
|
||||||
|
},
|
||||||
|
): Promise<string> {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`/api/mindspace/v1/pages/${encodeURIComponent(pageId)}/preview-draft`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: input.title,
|
||||||
|
summary: input.summary,
|
||||||
|
content: input.content,
|
||||||
|
template_id: input.templateId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new ApiError(0, formatNetworkError(err));
|
||||||
|
}
|
||||||
|
if (res.status === 401) {
|
||||||
|
notifyUnauthorized();
|
||||||
|
throw new ApiError(401, '未授权,请重新登录');
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const parsed = await parseErrorResponse(res);
|
||||||
|
throw new ApiError(res.status, parsed.message || `${res.status} ${res.statusText}`, parsed.code);
|
||||||
|
}
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openMindSpaceDraftPreviewWindow(html: string) {
|
||||||
|
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const opened = window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
if (!opened) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
throw new ApiError(0, '无法打开新窗口,请检查浏览器是否拦截弹窗');
|
||||||
|
}
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 120_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadMindSpacePageThumbnail(
|
||||||
|
pageId: string,
|
||||||
|
input: {
|
||||||
|
imageBase64: string;
|
||||||
|
mimeType?: string;
|
||||||
|
title?: string;
|
||||||
|
summary?: string;
|
||||||
|
content?: string;
|
||||||
|
},
|
||||||
|
): Promise<{ updatedAt: number }> {
|
||||||
|
const result = await apiFetch<{ data: { updatedAt: number } }>(
|
||||||
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/thumbnail/upload`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
image_base64: input.imageBase64,
|
||||||
|
mime_type: input.mimeType,
|
||||||
|
title: input.title,
|
||||||
|
summary: input.summary,
|
||||||
|
html: input.content,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function regenerateMindSpacePageThumbnail(
|
||||||
|
pageId: string,
|
||||||
|
input: {
|
||||||
|
title?: string;
|
||||||
|
summary?: string;
|
||||||
|
content?: string;
|
||||||
|
useAi?: boolean;
|
||||||
|
instruction?: string;
|
||||||
|
} = {},
|
||||||
|
): Promise<{ updatedAt: number; content?: string | null }> {
|
||||||
|
const result = await apiFetch<{ data: { updatedAt: number; content?: string | null } }>(
|
||||||
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/thumbnail/regenerate`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: input.title,
|
||||||
|
summary: input.summary,
|
||||||
|
html: input.content,
|
||||||
|
use_ai: input.useAi ?? false,
|
||||||
|
instruction: input.instruction,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bindMindSpacePageLiveEdit(
|
||||||
|
pageId: string,
|
||||||
|
sessionId: string,
|
||||||
|
options?: { parentSessionId?: string },
|
||||||
|
): Promise<{ sessionId: string; pageId: string; parentSessionId?: string }> {
|
||||||
|
const result = await apiFetch<{ data: { sessionId: string; pageId: string; parentSessionId?: string } }>(
|
||||||
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/live-edit/bind`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: sessionId,
|
||||||
|
...(options?.parentSessionId ? { parent_session_id: options.parentSessionId } : {}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forkMindSpacePageEditSession(
|
||||||
|
pageId: string,
|
||||||
|
parentSessionId: string,
|
||||||
|
h5ApiBase?: string | null,
|
||||||
|
): Promise<{ sessionId: string; pageId: string; parentSessionId: string }> {
|
||||||
|
const result = await apiFetch<{
|
||||||
|
data: { sessionId: string; pageId: string; parentSessionId: string };
|
||||||
|
}>(`/mindspace/v1/pages/${encodeURIComponent(pageId)}/live-edit/fork-session`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
parent_session_id: parentSessionId,
|
||||||
|
...(h5ApiBase ? { h5_api_base: h5ApiBase } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeMindSpacePageEditSession(
|
||||||
|
pageId: string,
|
||||||
|
input: {
|
||||||
|
sessionId: string;
|
||||||
|
parentSessionId?: string | null;
|
||||||
|
summary?: string;
|
||||||
|
},
|
||||||
|
): Promise<{ sessionId: string; pageId: string; merged: boolean }> {
|
||||||
|
const result = await apiFetch<{
|
||||||
|
data: { sessionId: string; pageId: string; merged: boolean };
|
||||||
|
}>(`/mindspace/v1/pages/${encodeURIComponent(pageId)}/live-edit/close-session`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: input.sessionId,
|
||||||
|
parent_session_id: input.parentSessionId ?? undefined,
|
||||||
|
summary: input.summary ?? '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMindSpacePageLiveRevision(pageId: string): Promise<{
|
||||||
|
pageId: string;
|
||||||
|
versionNo: number;
|
||||||
|
updatedAt: number;
|
||||||
|
liveRevision: number;
|
||||||
|
}> {
|
||||||
|
const result = await apiFetch<{
|
||||||
|
data: { pageId: string; versionNo: number; updatedAt: number; liveRevision: number };
|
||||||
|
}>(`/mindspace/v1/pages/${encodeURIComponent(pageId)}/live-edit/revision`);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import type {
|
||||||
|
MindSpacePublication,
|
||||||
|
MindSpacePublicationStats,
|
||||||
|
MindSpacePublishCheck,
|
||||||
|
MindSpaceRedactedCopyResult,
|
||||||
|
} from '../types';
|
||||||
|
import { apiFetch } from './core';
|
||||||
|
import { getMindSpacePage } from './mindspace-pages';
|
||||||
|
|
||||||
|
export async function checkMindSpacePagePublication(
|
||||||
|
pageId: string,
|
||||||
|
input: {
|
||||||
|
pageVersionId: string;
|
||||||
|
accessMode: MindSpacePublishCheck['accessMode'];
|
||||||
|
urlSlug: string;
|
||||||
|
password?: string;
|
||||||
|
expiresAt?: number | null;
|
||||||
|
},
|
||||||
|
): Promise<MindSpacePublishCheck> {
|
||||||
|
const result = await apiFetch<{ data: MindSpacePublishCheck }>(
|
||||||
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/publish-check`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
page_version_id: input.pageVersionId,
|
||||||
|
access_mode: input.accessMode,
|
||||||
|
url_slug: input.urlSlug,
|
||||||
|
password: input.password,
|
||||||
|
expires_at: input.expiresAt,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishMindSpacePage(
|
||||||
|
pageId: string,
|
||||||
|
input: {
|
||||||
|
pageVersionId: string;
|
||||||
|
accessMode: MindSpacePublishCheck['accessMode'];
|
||||||
|
urlSlug: string;
|
||||||
|
acknowledgedFindingIds: string[];
|
||||||
|
password?: string;
|
||||||
|
expiresAt?: number | null;
|
||||||
|
},
|
||||||
|
): Promise<MindSpacePublication> {
|
||||||
|
const result = await apiFetch<{ data: MindSpacePublication }>(
|
||||||
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/publish`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
page_version_id: input.pageVersionId,
|
||||||
|
access_mode: input.accessMode,
|
||||||
|
url_slug: input.urlSlug,
|
||||||
|
password: input.password,
|
||||||
|
expires_at: input.expiresAt,
|
||||||
|
acknowledged_finding_ids: input.acknowledgedFindingIds,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePublicationStatus(
|
||||||
|
publicationId: string,
|
||||||
|
input: {
|
||||||
|
accessMode: MindSpacePublishCheck['accessMode'];
|
||||||
|
expiresAt?: number | null;
|
||||||
|
},
|
||||||
|
): Promise<MindSpacePublication> {
|
||||||
|
const result = await apiFetch<{ data: MindSpacePublication }>(
|
||||||
|
`/mindspace/v1/publications/${encodeURIComponent(publicationId)}/update-status`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
access_mode: input.accessMode,
|
||||||
|
expires_at: input.expiresAt,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function redactMindSpacePage(
|
||||||
|
pageId: string,
|
||||||
|
input: {
|
||||||
|
pageVersionId: string;
|
||||||
|
expectedVersion: number;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
content: string;
|
||||||
|
},
|
||||||
|
): Promise<MindSpaceRedactedCopyResult> {
|
||||||
|
const result = await apiFetch<{ data: MindSpaceRedactedCopyResult }>(
|
||||||
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/redact`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
page_version_id: input.pageVersionId,
|
||||||
|
expected_version: input.expectedVersion,
|
||||||
|
title: input.title,
|
||||||
|
summary: input.summary,
|
||||||
|
content: input.content,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fixMindSpacePagePublication(
|
||||||
|
pageId: string,
|
||||||
|
input: {
|
||||||
|
pageVersionId: string;
|
||||||
|
expectedVersion: number;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
content: string;
|
||||||
|
},
|
||||||
|
): Promise<MindSpaceRedactedCopyResult> {
|
||||||
|
const result = await apiFetch<{ data: MindSpaceRedactedCopyResult }>(
|
||||||
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/publish-fix`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
page_version_id: input.pageVersionId,
|
||||||
|
expected_version: input.expectedVersion,
|
||||||
|
title: input.title,
|
||||||
|
summary: input.summary,
|
||||||
|
content: input.content,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated use redactMindSpacePage */
|
||||||
|
export async function createMindSpaceRedactedCopy(
|
||||||
|
pageId: string,
|
||||||
|
pageVersionId: string,
|
||||||
|
): Promise<MindSpaceRedactedCopyResult> {
|
||||||
|
const page = await getMindSpacePage(pageId);
|
||||||
|
return redactMindSpacePage(pageId, {
|
||||||
|
pageVersionId,
|
||||||
|
expectedVersion: page.versionNo,
|
||||||
|
title: page.title,
|
||||||
|
summary: page.summary,
|
||||||
|
content: page.content ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function offlineMindSpacePublication(publicationId: string): Promise<void> {
|
||||||
|
await apiFetch(
|
||||||
|
`/mindspace/v1/publications/${encodeURIComponent(publicationId)}/offline`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({}) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMindSpacePublicationStats(
|
||||||
|
publicationId: string,
|
||||||
|
): Promise<MindSpacePublicationStats> {
|
||||||
|
const result = await apiFetch<{ data: MindSpacePublicationStats }>(
|
||||||
|
`/mindspace/v1/publications/${encodeURIComponent(publicationId)}/stats`,
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import type {
|
||||||
|
PageDataAccessPolicy,
|
||||||
|
PageDataDatasetSummary,
|
||||||
|
PageDataLogEntry,
|
||||||
|
PageDataOpsOverview,
|
||||||
|
} from '../types';
|
||||||
|
import { ApiError, apiFetch } from './core';
|
||||||
|
|
||||||
|
export async function listOwnerPageDataPolicies(): Promise<
|
||||||
|
Array<{
|
||||||
|
pageId: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
accessMode: string;
|
||||||
|
datasetCount: number;
|
||||||
|
scopeHash: string;
|
||||||
|
updatedAt: number;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const result = await apiFetch<{
|
||||||
|
data: {
|
||||||
|
policies: Array<{
|
||||||
|
pageId: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
accessMode: string;
|
||||||
|
datasetCount: number;
|
||||||
|
scopeHash: string;
|
||||||
|
updatedAt: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}>('/page-data/policies');
|
||||||
|
return result.data.policies;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPageDataDatasets(): Promise<PageDataDatasetSummary[]> {
|
||||||
|
const result = await apiFetch<{ data: { datasets: PageDataDatasetSummary[] } }>('/page-data');
|
||||||
|
return result.data.datasets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPageDataPolicy(pageId: string): Promise<PageDataAccessPolicy | null> {
|
||||||
|
try {
|
||||||
|
const result = await apiFetch<{ data: { policy: PageDataAccessPolicy } }>(
|
||||||
|
`/page-data/policies/${encodeURIComponent(pageId)}`,
|
||||||
|
);
|
||||||
|
return result.data.policy;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError && error.status === 404) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyPageDataPublishPolicy(
|
||||||
|
pageId: string,
|
||||||
|
input: {
|
||||||
|
datasetName: string;
|
||||||
|
capabilities?: {
|
||||||
|
read?: boolean;
|
||||||
|
insert?: boolean;
|
||||||
|
update?: boolean;
|
||||||
|
softDelete?: boolean;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
): Promise<PageDataAccessPolicy> {
|
||||||
|
const result = await apiFetch<{ data: { policy: PageDataAccessPolicy } }>(
|
||||||
|
`/page-data/policies/${encodeURIComponent(pageId)}/apply-publish`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
datasetName: input.datasetName,
|
||||||
|
capabilities: input.capabilities ?? {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result.data.policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePageDataPolicy(
|
||||||
|
pageId: string,
|
||||||
|
policy: Partial<PageDataAccessPolicy>,
|
||||||
|
): Promise<PageDataAccessPolicy> {
|
||||||
|
const result = await apiFetch<{ data: { policy: PageDataAccessPolicy } }>(
|
||||||
|
`/page-data/policies/${encodeURIComponent(pageId)}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(policy),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result.data.policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPageDataExportUrl(dataset: string, format: 'json' | 'csv' = 'json') {
|
||||||
|
const params = new URLSearchParams({ format });
|
||||||
|
return `/api/page-data/${encodeURIComponent(dataset)}/export?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPageDataOpsOverview(pageId: string): Promise<PageDataOpsOverview> {
|
||||||
|
const result = await apiFetch<{ data: PageDataOpsOverview }>(
|
||||||
|
`/page-data/policies/${encodeURIComponent(pageId)}/ops`,
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPageDataLogs(
|
||||||
|
pageId: string,
|
||||||
|
input: { limit?: number; offset?: number } = {},
|
||||||
|
): Promise<{ pageId: string; logs: PageDataLogEntry[]; count: number }> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (input.limit != null) params.set('limit', String(input.limit));
|
||||||
|
if (input.offset != null) params.set('offset', String(input.offset));
|
||||||
|
const query = params.toString();
|
||||||
|
const result = await apiFetch<{ data: { pageId: string; logs: PageDataLogEntry[]; count: number } }>(
|
||||||
|
`/page-data/policies/${encodeURIComponent(pageId)}/logs${query ? `?${query}` : ''}`,
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokePageDataTokens(
|
||||||
|
pageId: string,
|
||||||
|
input: { revokeAll?: boolean; token?: string },
|
||||||
|
): Promise<{ pageId: string; revokedCount: number; revokeAll: boolean }> {
|
||||||
|
const result = await apiFetch<{ data: { pageId: string; revokedCount: number; revokeAll: boolean } }>(
|
||||||
|
`/page-data/policies/${encodeURIComponent(pageId)}/tokens/revoke`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
revokeAll: Boolean(input.revokeAll),
|
||||||
|
token: input.token,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetPageDataPassword(
|
||||||
|
pageId: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ pageId: string; passwordReset: boolean; revokedSessions: number }> {
|
||||||
|
const result = await apiFetch<{
|
||||||
|
data: { pageId: string; passwordReset: boolean; revokedSessions: number };
|
||||||
|
}>(`/page-data/policies/${encodeURIComponent(pageId)}/password/reset`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closePageDataDataset(
|
||||||
|
pageId: string,
|
||||||
|
dataset: string,
|
||||||
|
): Promise<{ pageId: string; dataset: string; closed: boolean }> {
|
||||||
|
const result = await apiFetch<{ data: { pageId: string; dataset: string; closed: boolean } }>(
|
||||||
|
`/page-data/policies/${encodeURIComponent(pageId)}/datasets/${encodeURIComponent(dataset)}/close`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restorePageDataRow(
|
||||||
|
dataset: string,
|
||||||
|
rowId: number | string,
|
||||||
|
): Promise<{ restored: boolean; row: Record<string, unknown> }> {
|
||||||
|
const result = await apiFetch<{ data: { restored: boolean; row: Record<string, unknown> } }>(
|
||||||
|
`/page-data/${encodeURIComponent(dataset)}/rows/${encodeURIComponent(String(rowId))}/restore`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
@@ -1586,7 +1586,7 @@ export function MindSpacePageDetail({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={previewRefreshPending ? 'is-refresh-pending' : undefined}
|
className={previewRefreshPending ? 'is-refresh-pending' : undefined}
|
||||||
onClick={handleManualPreviewRefresh}
|
onClick={() => handleManualPreviewRefresh()}
|
||||||
title={previewRefreshPending ? '草稿已有新内容,点击刷新预览' : '将当前草稿同步到预览'}
|
title={previewRefreshPending ? '草稿已有新内容,点击刷新预览' : '将当前草稿同步到预览'}
|
||||||
>
|
>
|
||||||
刷新预览{previewRefreshPending ? ' · 有新修改' : ''}
|
刷新预览{previewRefreshPending ? ' · 有新修改' : ''}
|
||||||
@@ -1684,15 +1684,21 @@ export function MindSpacePageDetail({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{confirmPublicationStatusOpen && page?.publication ? (
|
{confirmPublicationStatusOpen && page?.publication ? (
|
||||||
<MindSpaceModal className="mindspace-confirm-publication-status-modal">
|
<MindSpaceModal
|
||||||
|
open={confirmPublicationStatusOpen}
|
||||||
|
onClose={() => setConfirmPublicationStatusOpen(false)}
|
||||||
|
title="页面预览期确认"
|
||||||
|
eyebrow="PUBLICATION STATUS"
|
||||||
|
className="mindspace-confirm-publication-status-modal"
|
||||||
|
disableClose={statusConfirming}
|
||||||
|
>
|
||||||
<div className="mindspace-modal-content">
|
<div className="mindspace-modal-content">
|
||||||
<h3>页面预览期确认</h3>
|
|
||||||
<p>你的页面当前处于 30 分钟预览期。请选择后续状态:</p>
|
<p>你的页面当前处于 30 分钟预览期。请选择后续状态:</p>
|
||||||
<div className="mindspace-modal-actions">
|
<div className="mindspace-modal-actions">
|
||||||
<button
|
<button
|
||||||
className="mindspace-secondary"
|
className="mindspace-secondary"
|
||||||
disabled={statusConfirming}
|
disabled={statusConfirming}
|
||||||
onClick={() => confirmPublicationStatus('private')}
|
onClick={() => confirmPublicationStatus('owner_only')}
|
||||||
>
|
>
|
||||||
{statusConfirming ? '处理中...' : '改为私有'}
|
{statusConfirming ? '处理中...' : '改为私有'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -477,6 +477,10 @@ function formatBytes(bytes: number) {
|
|||||||
|
|
||||||
type AssetFilter = 'all' | 'images' | 'files' | 'pages';
|
type AssetFilter = 'all' | 'images' | 'files' | 'pages';
|
||||||
|
|
||||||
|
function previewBlocked() {
|
||||||
|
return new Error('预览模式仅供查看,不能修改内容');
|
||||||
|
}
|
||||||
|
|
||||||
function canGenerateWithAgent(asset: MindSpaceAsset) {
|
function canGenerateWithAgent(asset: MindSpaceAsset) {
|
||||||
const textLikeMimeTypes = new Set([
|
const textLikeMimeTypes = new Set([
|
||||||
'text/plain',
|
'text/plain',
|
||||||
|
|||||||
@@ -17,7 +17,16 @@ export function VoiceInputDialog({
|
|||||||
onSend: (text: string) => void;
|
onSend: (text: string) => void;
|
||||||
onError?: (message: string) => void;
|
onError?: (message: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { phase, text, analyser, liveRecognition, stopListening, finishFallbackRecording, resetSession } =
|
const {
|
||||||
|
phase,
|
||||||
|
text,
|
||||||
|
analyser,
|
||||||
|
liveRecognition,
|
||||||
|
updateText,
|
||||||
|
stopListening,
|
||||||
|
finishFallbackRecording,
|
||||||
|
resetSession,
|
||||||
|
} =
|
||||||
useVoiceSession({
|
useVoiceSession({
|
||||||
active: open && !disabled,
|
active: open && !disabled,
|
||||||
onError,
|
onError,
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export function getDisplayText(message: Message): string {
|
|||||||
// REGRESSION GUARD: never show agent-only routing/skill prefixes in the chat UI.
|
// REGRESSION GUARD: never show agent-only routing/skill prefixes in the chat UI.
|
||||||
if (message.role === 'user') {
|
if (message.role === 'user') {
|
||||||
if ('displayText' in message.metadata && message.metadata.displayText != null) {
|
if ('displayText' in message.metadata && message.metadata.displayText != null) {
|
||||||
return stripImageUrlLines(message.metadata.displayText);
|
return stripImageUrlLines(deriveUserFacingText(message.metadata.displayText));
|
||||||
}
|
}
|
||||||
const raw = message.content
|
const raw = message.content
|
||||||
.filter((c): c is Extract<MessageContent, { type: 'text' }> => c.type === 'text')
|
.filter((c): c is Extract<MessageContent, { type: 'text' }> => c.type === 'text')
|
||||||
|
|||||||
Reference in New Issue
Block a user