Merge pull request 'feat(chat): Plaza 一键发布 + publication RPC 错误映射' (#5) from feature/rpc-publication-errors into main
Merge pull request #5: Plaza 一键发布 + publication RPC 错误映射
This commit was merged in pull request #5.
This commit is contained in:
@@ -0,0 +1,206 @@
|
|||||||
|
export function slugFromPageTitle(title, pageId) {
|
||||||
|
const ascii = String(title ?? '')
|
||||||
|
.normalize('NFKC')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, pageId ? 54 : 64);
|
||||||
|
const base = ascii || 'page';
|
||||||
|
if (pageId) {
|
||||||
|
return `${base}-${String(pageId).replace(/-/g, '').slice(0, 8)}`.slice(0, 64);
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveExistingPage({
|
||||||
|
userId,
|
||||||
|
sessionId,
|
||||||
|
messageId,
|
||||||
|
analysis,
|
||||||
|
mindSpacePages,
|
||||||
|
}) {
|
||||||
|
const byMessage = await mindSpacePages
|
||||||
|
.findPageBySourceMessage(userId, sessionId, messageId)
|
||||||
|
.catch(() => null);
|
||||||
|
if (byMessage) return byMessage;
|
||||||
|
|
||||||
|
if (analysis.contentMode === 'static_html' && analysis.relativePath) {
|
||||||
|
const byPath = await mindSpacePages
|
||||||
|
.findPageByRelativePath(userId, analysis.relativePath)
|
||||||
|
.catch(() => null);
|
||||||
|
if (byPath) return byPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildPageInput({ bundle, body = {} }) {
|
||||||
|
const { source, analysis, resolvedHtml } = bundle;
|
||||||
|
let pageInput = {
|
||||||
|
title: body.title,
|
||||||
|
summary: body.summary,
|
||||||
|
templateId: body.template_id ?? 'editorial',
|
||||||
|
pageType: body.page_type,
|
||||||
|
categoryCode: 'draft',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (analysis.contentMode === 'static_html') {
|
||||||
|
if (!resolvedHtml) {
|
||||||
|
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
|
||||||
|
}
|
||||||
|
pageInput = {
|
||||||
|
...pageInput,
|
||||||
|
title: body.title || resolvedHtml.suggestedTitle,
|
||||||
|
summary: body.summary || resolvedHtml.suggestedSummary,
|
||||||
|
content: resolvedHtml.content,
|
||||||
|
contentFormat: 'html',
|
||||||
|
pageType: 'html',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
pageInput = {
|
||||||
|
...pageInput,
|
||||||
|
title: body.title || 'AI 创作',
|
||||||
|
content: source.content,
|
||||||
|
contentFormat: 'markdown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureChatPageForPlaza({
|
||||||
|
userId,
|
||||||
|
bundle,
|
||||||
|
mindSpacePages,
|
||||||
|
ensureWorkspaceHtmlThumbnail,
|
||||||
|
publishDir,
|
||||||
|
body = {},
|
||||||
|
skipThumbnail = false,
|
||||||
|
}) {
|
||||||
|
const { source, analysis, resolvedHtml } = bundle;
|
||||||
|
const sessionId = body.session_id ?? body.sessionId;
|
||||||
|
const messageId = body.message_id ?? body.messageId;
|
||||||
|
const snapshot = {
|
||||||
|
session_name: source.session.name,
|
||||||
|
message_created: source.message.created,
|
||||||
|
role: source.message.role,
|
||||||
|
content_mode: analysis.contentMode,
|
||||||
|
public_url: analysis.previewUrl,
|
||||||
|
relative_path: analysis.relativePath ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageInput = await buildPageInput({ bundle, body });
|
||||||
|
const existingPage = await resolveExistingPage({
|
||||||
|
userId,
|
||||||
|
sessionId,
|
||||||
|
messageId,
|
||||||
|
analysis,
|
||||||
|
mindSpacePages,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
!skipThumbnail &&
|
||||||
|
analysis.contentMode === 'static_html' &&
|
||||||
|
resolvedHtml?.content &&
|
||||||
|
analysis.relativePath &&
|
||||||
|
ensureWorkspaceHtmlThumbnail &&
|
||||||
|
publishDir
|
||||||
|
) {
|
||||||
|
await ensureWorkspaceHtmlThumbnail(publishDir, analysis.relativePath, resolvedHtml.content, {
|
||||||
|
title: pageInput.title,
|
||||||
|
subtitle: pageInput.summary,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingPage) {
|
||||||
|
return mindSpacePages.updatePage(userId, existingPage.id, {
|
||||||
|
...pageInput,
|
||||||
|
expectedVersion: existingPage.versionNo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return mindSpacePages.createFromChat(userId, pageInput, {
|
||||||
|
sessionId,
|
||||||
|
messageId,
|
||||||
|
snapshot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensurePagePublicationForPlaza({ userId, page, mindSpacePublications }) {
|
||||||
|
const existing = await mindSpacePublications.getCurrent(userId, page.id);
|
||||||
|
if (existing?.id) return existing;
|
||||||
|
|
||||||
|
const preferredSlug = slugFromPageTitle(page.title, page.id);
|
||||||
|
return mindSpacePublications.publish(userId, page.id, {
|
||||||
|
pageVersionId: page.currentVersionId,
|
||||||
|
accessMode: 'public',
|
||||||
|
urlSlug: preferredSlug,
|
||||||
|
autoAcknowledgeFindings: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensurePlazaPostForPublication({
|
||||||
|
userId,
|
||||||
|
publication,
|
||||||
|
plazaPosts,
|
||||||
|
categorySlug = 'other',
|
||||||
|
}) {
|
||||||
|
const post = await plazaPosts.publishPostForPublication(
|
||||||
|
userId,
|
||||||
|
{
|
||||||
|
publication_id: publication.id,
|
||||||
|
category_slug: categorySlug,
|
||||||
|
cover_url: '',
|
||||||
|
allow_comment: true,
|
||||||
|
},
|
||||||
|
{ forcePublished: true, deferPostPublishedHooks: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
return { post };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function quickPlazaFromChat({
|
||||||
|
user,
|
||||||
|
h5Root,
|
||||||
|
bundle,
|
||||||
|
body,
|
||||||
|
mindSpacePages,
|
||||||
|
mindSpacePublications,
|
||||||
|
plazaPosts,
|
||||||
|
publishDir,
|
||||||
|
}) {
|
||||||
|
if (!mindSpacePages || !mindSpacePublications || !plazaPosts) {
|
||||||
|
throw Object.assign(new Error('Plaza 或 MindSpace 未启用'), { code: 'plaza_unavailable' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await ensureChatPageForPlaza({
|
||||||
|
userId: user.id,
|
||||||
|
bundle,
|
||||||
|
mindSpacePages,
|
||||||
|
publishDir,
|
||||||
|
body,
|
||||||
|
skipThumbnail: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const publication = await ensurePagePublicationForPlaza({
|
||||||
|
userId: user.id,
|
||||||
|
page,
|
||||||
|
mindSpacePublications,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { post } = await ensurePlazaPostForPublication({
|
||||||
|
userId: user.id,
|
||||||
|
publication,
|
||||||
|
plazaPosts,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageId: page.id,
|
||||||
|
publicationId: publication.id,
|
||||||
|
publicUrl: publication.publicUrl ?? publication.public_url ?? null,
|
||||||
|
post: {
|
||||||
|
id: post.id,
|
||||||
|
status: post.status,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { slugFromPageTitle } from './mindspace-chat-plaza.mjs';
|
||||||
|
import { MINDSPACE_SERVER_ADAPTER_BINDINGS } from './mindspace-server-adapter-contract.mjs';
|
||||||
|
|
||||||
|
test('adapter contract exposes findPageBySourceMessage for quick plaza', () => {
|
||||||
|
assert.ok(MINDSPACE_SERVER_ADAPTER_BINDINGS.pageService.includes('findPageBySourceMessage'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slugFromPageTitle falls back to page when title has no ascii', () => {
|
||||||
|
assert.equal(slugFromPageTitle('春江花月夜', '1c99b83b-0454-474f-a5d2-129d34506a32'), 'page-1c99b83b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slugFromPageTitle keeps ascii slug and page suffix', () => {
|
||||||
|
assert.equal(slugFromPageTitle('Travel Guide 2026', 'abcd-1234-5678-9012'), 'travel-guide-2026-abcd1234');
|
||||||
|
});
|
||||||
@@ -581,7 +581,9 @@ export function createPublicationService(pool, options = {}) {
|
|||||||
findings: result.findings,
|
findings: result.findings,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const acknowledged = new Set(input.acknowledgedFindingIds ?? []);
|
const acknowledged = input.autoAcknowledgeFindings
|
||||||
|
? new Set(result.findings.map((finding) => finding.id))
|
||||||
|
: new Set(input.acknowledgedFindingIds ?? []);
|
||||||
const missingAcknowledgements = result.findings.filter(
|
const missingAcknowledgements = result.findings.filter(
|
||||||
(finding) => !finding.blocking && !acknowledged.has(finding.id),
|
(finding) => !finding.blocking && !acknowledged.has(finding.id),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,17 +27,32 @@ function buildRemoteOperationUrl(endpoint, bindingKey, method, operationBasePath
|
|||||||
return `${base}${normalizedBasePath}/${encodeURIComponent(bindingKey)}/${encodeURIComponent(method)}`;
|
return `${base}${normalizedBasePath}/${encodeURIComponent(bindingKey)}/${encodeURIComponent(method)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parseRemoteOperationResponse(response, bindingKey, method) {
|
function throwRemoteOperationError(response, bindingKey, method, bodyText, payload) {
|
||||||
if (!response.ok) {
|
const error = new Error(
|
||||||
const bodyText = await response.text().catch(() => '');
|
payload?.message ??
|
||||||
throw new Error(
|
|
||||||
`MindSpace remote adapter ${bindingKey}.${method}() failed with ${response.status}${bodyText ? `: ${bodyText}` : ''}`,
|
`MindSpace remote adapter ${bindingKey}.${method}() failed with ${response.status}${bodyText ? `: ${bodyText}` : ''}`,
|
||||||
);
|
);
|
||||||
|
if (payload?.code) error.code = payload.code;
|
||||||
|
if (payload?.details !== undefined) error.details = payload.details;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseRemoteOperationResponse(response, bindingKey, method) {
|
||||||
|
const bodyText = await response.text().catch(() => '');
|
||||||
|
let payload = null;
|
||||||
|
if (bodyText) {
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(bodyText);
|
||||||
|
} catch {
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throwRemoteOperationError(response, bindingKey, method, bodyText, payload);
|
||||||
}
|
}
|
||||||
if (response.status === 204) return null;
|
if (response.status === 204) return null;
|
||||||
const text = await response.text();
|
if (!bodyText) return null;
|
||||||
if (!text) return null;
|
return payload ?? JSON.parse(bodyText);
|
||||||
return JSON.parse(text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function invokeRemoteOperation({
|
async function invokeRemoteOperation({
|
||||||
|
|||||||
@@ -133,3 +133,26 @@ test('createMindSpaceRemoteServerAdapter surfaces remote conversation package er
|
|||||||
/conversationPackageRegistry\.readManifestForSession\(\) failed with 502: upstream manifest missing/,
|
/conversationPackageRegistry\.readManifestForSession\(\) failed with 502: upstream manifest missing/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createMindSpaceRemoteServerAdapter preserves publication error codes from rpc payload', async () => {
|
||||||
|
const adapter = createMindSpaceRemoteServerAdapter({
|
||||||
|
endpoint: 'https://mindspace.example.com/',
|
||||||
|
authToken: 'secret-token',
|
||||||
|
fetchFn: async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
async text() {
|
||||||
|
return JSON.stringify({
|
||||||
|
message: '公开页面不存在',
|
||||||
|
code: 'publication_not_found',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
logger: { log() {}, warn() {}, error() {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => adapter.publicationService.resolvePublic('john', 'missing-page', null, null, {}),
|
||||||
|
(error) => error instanceof Error && error.code === 'publication_not_found',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const MINDSPACE_SERVER_ADAPTER_BINDINGS = Object.freeze({
|
|||||||
'deletePage',
|
'deletePage',
|
||||||
'findPageByRelativePath',
|
'findPageByRelativePath',
|
||||||
'findPageBySourceAsset',
|
'findPageBySourceAsset',
|
||||||
|
'findPageBySourceMessage',
|
||||||
'getDeletePreview',
|
'getDeletePreview',
|
||||||
'getPage',
|
'getPage',
|
||||||
'listPages',
|
'listPages',
|
||||||
|
|||||||
@@ -53,6 +53,36 @@ function json(res, statusCode, payload) {
|
|||||||
res.end(body);
|
res.end(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeRpcError(error) {
|
||||||
|
return {
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
code: error?.code ?? 'internal_error',
|
||||||
|
...(error?.details !== undefined ? { details: error.details } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRpcErrorStatus(error) {
|
||||||
|
switch (error?.code) {
|
||||||
|
case 'publication_not_found':
|
||||||
|
case 'publication_owner_not_found':
|
||||||
|
case 'page_not_found':
|
||||||
|
case 'category_not_found':
|
||||||
|
return 404;
|
||||||
|
case 'publication_login_required':
|
||||||
|
return 401;
|
||||||
|
case 'publication_password_required':
|
||||||
|
return 403;
|
||||||
|
case 'invalid_input':
|
||||||
|
case 'invalid_publish_input':
|
||||||
|
case 'invalid_state_transition':
|
||||||
|
case 'slug_conflict':
|
||||||
|
case 'security_ack_required':
|
||||||
|
return 400;
|
||||||
|
default:
|
||||||
|
return 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function createMindSpaceRpcRequestHandler({
|
export async function createMindSpaceRpcRequestHandler({
|
||||||
adapter,
|
adapter,
|
||||||
env = process.env,
|
env = process.env,
|
||||||
@@ -113,10 +143,11 @@ export async function createMindSpaceRpcRequestHandler({
|
|||||||
const result = await service[method](...args);
|
const result = await service[method](...args);
|
||||||
return json(res, 200, result);
|
return json(res, 200, result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error?.('[MindSpace RPC Error]', error);
|
const statusCode = resolveRpcErrorStatus(error);
|
||||||
return json(res, 500, {
|
if (statusCode >= 500) {
|
||||||
message: error instanceof Error ? error.message : String(error),
|
logger.error?.('[MindSpace RPC Error]', error);
|
||||||
});
|
}
|
||||||
|
return json(res, statusCode, serializeRpcError(error));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,3 +196,52 @@ test('rpc invocation revives JSON-serialized buffers before dispatch', async ()
|
|||||||
assert.deepEqual(response.body, { isBuffer: true, size: payload.length });
|
assert.deepEqual(response.body, { isBuffer: true, size: payload.length });
|
||||||
assert.equal(Buffer.isBuffer(adapter.calls.writeUploadContent[0][2]), true);
|
assert.equal(Buffer.isBuffer(adapter.calls.writeUploadContent[0][2]), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rpc maps publication_not_found to 404 with error code', async () => {
|
||||||
|
const adapter = createStubAdapter();
|
||||||
|
adapter.publicationService.resolvePublic = async () => {
|
||||||
|
const error = new Error('公开页面不存在');
|
||||||
|
error.code = 'publication_not_found';
|
||||||
|
throw error;
|
||||||
|
};
|
||||||
|
const handler = await createMindSpaceRpcRequestHandler({
|
||||||
|
adapter,
|
||||||
|
env: {
|
||||||
|
MINDSPACE_MEMIND_ROOT: '..',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await runRequest(handler, {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/mindspace/v1/adapter/publicationService/resolvePublic',
|
||||||
|
body: JSON.stringify({ args: ['john', 'missing-page', null, null, {}] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 404);
|
||||||
|
assert.equal(response.body.code, 'publication_not_found');
|
||||||
|
assert.match(response.body.message, /公开页面不存在/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rpc maps publication_login_required to 401 with error code', async () => {
|
||||||
|
const adapter = createStubAdapter();
|
||||||
|
adapter.publicationService.resolvePublic = async () => {
|
||||||
|
const error = new Error('登录后才能访问此页面');
|
||||||
|
error.code = 'publication_login_required';
|
||||||
|
throw error;
|
||||||
|
};
|
||||||
|
const handler = await createMindSpaceRpcRequestHandler({
|
||||||
|
adapter,
|
||||||
|
env: {
|
||||||
|
MINDSPACE_MEMIND_ROOT: '..',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await runRequest(handler, {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/mindspace/v1/adapter/publicationService/resolvePublic',
|
||||||
|
body: JSON.stringify({ args: ['john', 'login-page', null, null, {}] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 401);
|
||||||
|
assert.equal(response.body.code, 'publication_login_required');
|
||||||
|
});
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export function createPlazaSeoService(
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'text/plain' },
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
body: list.join('\n'),
|
body: list.join('\n'),
|
||||||
|
signal: AbortSignal.timeout(5_000),
|
||||||
});
|
});
|
||||||
const payload = await response.json().catch(() => ({}));
|
const payload = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
+84
-9
@@ -122,6 +122,7 @@ import {
|
|||||||
normalizePublicHtmlRelativePath,
|
normalizePublicHtmlRelativePath,
|
||||||
syncPublicHtmlAfterFinish,
|
syncPublicHtmlAfterFinish,
|
||||||
} from './mindspace-public-finish-sync.mjs';
|
} from './mindspace-public-finish-sync.mjs';
|
||||||
|
import { quickPlazaFromChat } from './mindspace-chat-plaza.mjs';
|
||||||
import { extractCoverSignals, generateHtmlThumbnail } from './mindspace-thumbnails.mjs';
|
import { extractCoverSignals, generateHtmlThumbnail } from './mindspace-thumbnails.mjs';
|
||||||
import {
|
import {
|
||||||
extractSharePreviewMeta,
|
extractSharePreviewMeta,
|
||||||
@@ -2908,7 +2909,8 @@ function messageText(message) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveOwnedAssistantMessage(userId, sessionId, messageId) {
|
async function resolveOwnedAssistantMessage(user, sessionId, messageId) {
|
||||||
|
const userId = user?.id;
|
||||||
if (!sessionId || !messageId) {
|
if (!sessionId || !messageId) {
|
||||||
throw Object.assign(new Error('缺少来源会话或消息'), {
|
throw Object.assign(new Error('缺少来源会话或消息'), {
|
||||||
code: 'invalid_page_input',
|
code: 'invalid_page_input',
|
||||||
@@ -2917,13 +2919,52 @@ async function resolveOwnedAssistantMessage(userId, sessionId, messageId) {
|
|||||||
if (!(await userAuth.ownsSession(userId, sessionId))) {
|
if (!(await userAuth.ownsSession(userId, sessionId))) {
|
||||||
throw Object.assign(new Error('来源会话不存在'), { code: 'source_message_not_found' });
|
throw Object.assign(new Error('来源会话不存在'), { code: 'source_message_not_found' });
|
||||||
}
|
}
|
||||||
const upstream = await tkmindProxy.apiFetch(`/sessions/${encodeURIComponent(sessionId)}`, {
|
|
||||||
method: 'GET',
|
let session = null;
|
||||||
});
|
if (sessionSnapshotService?.isEnabled()) {
|
||||||
if (!upstream.ok) {
|
const snapshot = await sessionSnapshotService.get(sessionId).catch(() => null);
|
||||||
throw Object.assign(new Error('无法读取来源会话'), { code: 'source_message_not_found' });
|
if (snapshot?.messages?.length) {
|
||||||
|
session = {
|
||||||
|
...snapshot.session,
|
||||||
|
conversation: sanitizeSessionConversationPublicHtmlLinks(snapshot.messages, user),
|
||||||
|
};
|
||||||
|
if (authPool) {
|
||||||
|
session = await repairSessionConversationFromDb(authPool, session, sessionId, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const session = await upstream.json();
|
|
||||||
|
if (!session) {
|
||||||
|
const target = await tkmindProxy.resolveTarget(sessionId);
|
||||||
|
let upstream;
|
||||||
|
try {
|
||||||
|
upstream = await tkmindProxy.apiFetchTo(
|
||||||
|
target,
|
||||||
|
`/sessions/${encodeURIComponent(sessionId)}`,
|
||||||
|
{ method: 'GET', signal: AbortSignal.timeout(15_000) },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(
|
||||||
|
error?.name === 'TimeoutError' || error?.name === 'AbortError'
|
||||||
|
? '读取来源会话超时,请稍后重试'
|
||||||
|
: '无法读取来源会话',
|
||||||
|
),
|
||||||
|
{ code: 'source_message_not_found' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!upstream.ok) {
|
||||||
|
throw Object.assign(new Error('无法读取来源会话'), { code: 'source_message_not_found' });
|
||||||
|
}
|
||||||
|
session = await upstream.json();
|
||||||
|
if (Array.isArray(session.conversation)) {
|
||||||
|
session.conversation = sanitizeSessionConversationPublicHtmlLinks(session.conversation, user);
|
||||||
|
}
|
||||||
|
if (authPool) {
|
||||||
|
session = await repairSessionConversationFromDb(authPool, session, sessionId, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const message = (session.conversation ?? []).find((item) => item.id === messageId);
|
const message = (session.conversation ?? []).find((item) => item.id === messageId);
|
||||||
if (!message) {
|
if (!message) {
|
||||||
throw Object.assign(new Error('来源消息不存在'), { code: 'source_message_not_found' });
|
throw Object.assign(new Error('来源消息不存在'), { code: 'source_message_not_found' });
|
||||||
@@ -2963,7 +3004,7 @@ async function resolveChatSaveBundle(user, h5Root, input = {}) {
|
|||||||
const previewTitle = String(input.previewTitle ?? input.preview_title ?? '').trim();
|
const previewTitle = String(input.previewTitle ?? input.preview_title ?? '').trim();
|
||||||
const previewSummary = String(input.previewSummary ?? input.preview_summary ?? '').trim();
|
const previewSummary = String(input.previewSummary ?? input.preview_summary ?? '').trim();
|
||||||
|
|
||||||
const source = await resolveOwnedAssistantMessage(user.id, sessionId, messageId);
|
const source = await resolveOwnedAssistantMessage(user, sessionId, messageId);
|
||||||
let { analysis, resolvedHtml } = await resolveChatSaveAnalysis({
|
let { analysis, resolvedHtml } = await resolveChatSaveAnalysis({
|
||||||
content: source.content,
|
content: source.content,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -3294,6 +3335,40 @@ api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
api.post('/mindspace/v1/pages/quick-plaza-from-chat', async (req, res) => {
|
||||||
|
if (!mindSpacePages || !mindSpacePublications || !plazaPosts) {
|
||||||
|
return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 或 MindSpace 未启用');
|
||||||
|
}
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body);
|
||||||
|
const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser);
|
||||||
|
const result = await quickPlazaFromChat({
|
||||||
|
user: req.currentUser,
|
||||||
|
h5Root: __dirname,
|
||||||
|
bundle,
|
||||||
|
body: req.body ?? {},
|
||||||
|
mindSpacePages,
|
||||||
|
mindSpacePublications,
|
||||||
|
plazaPosts,
|
||||||
|
publishDir,
|
||||||
|
});
|
||||||
|
console.info('[quick-plaza] ok', {
|
||||||
|
ms: Date.now() - startedAt,
|
||||||
|
pageId: result.pageId,
|
||||||
|
publicationId: result.publicationId,
|
||||||
|
postId: result.post?.id,
|
||||||
|
});
|
||||||
|
return sendData(res, req, result, 201);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[quick-plaza] error:', { ms: Date.now() - startedAt, error });
|
||||||
|
if (error?.code && mapPlazaError(error) !== 500) {
|
||||||
|
return plazaRouteError(res, req, error);
|
||||||
|
}
|
||||||
|
return mindSpaceError(res, req, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function handleChatSaveDocx(req, res) {
|
async function handleChatSaveDocx(req, res) {
|
||||||
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||||||
try {
|
try {
|
||||||
@@ -3348,7 +3423,7 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => {
|
|||||||
throw Object.assign(new Error('无效的保存目标'), { code: 'invalid_category_code' });
|
throw Object.assign(new Error('无效的保存目标'), { code: 'invalid_category_code' });
|
||||||
}
|
}
|
||||||
const source = await resolveOwnedAssistantMessage(
|
const source = await resolveOwnedAssistantMessage(
|
||||||
req.currentUser.id,
|
req.currentUser,
|
||||||
req.body?.session_id,
|
req.body?.session_id,
|
||||||
req.body?.message_id,
|
req.body?.message_id,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import type { AgentRunCreateOptions, AgentRunValidation } from '../utils/agentRu
|
|||||||
const API = '/api';
|
const API = '/api';
|
||||||
const DEFAULT_API_TIMEOUT_MS = 20_000;
|
const DEFAULT_API_TIMEOUT_MS = 20_000;
|
||||||
const AGENT_CONNECT_TIMEOUT_MS = 60_000;
|
const AGENT_CONNECT_TIMEOUT_MS = 60_000;
|
||||||
|
const QUICK_PLAZA_TIMEOUT_MS = 120_000;
|
||||||
const AGENT_RUNS_PATH = '/agent/runs';
|
const AGENT_RUNS_PATH = '/agent/runs';
|
||||||
|
|
||||||
export type AgentRun = {
|
export type AgentRun = {
|
||||||
@@ -1086,6 +1087,35 @@ export async function quickShareFromChat(input: {
|
|||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function quickPlazaFromChat(input: {
|
||||||
|
sessionId: string;
|
||||||
|
messageId: string;
|
||||||
|
selectedLinkIndex?: number;
|
||||||
|
}, signal?: AbortSignal): Promise<{
|
||||||
|
pageId: string;
|
||||||
|
publicationId: string;
|
||||||
|
publicUrl: string | null;
|
||||||
|
post: PlazaPostBrief;
|
||||||
|
}> {
|
||||||
|
const result = await apiFetch<{
|
||||||
|
data: {
|
||||||
|
pageId: string;
|
||||||
|
publicationId: string;
|
||||||
|
publicUrl: string | null;
|
||||||
|
post: PlazaPostBrief;
|
||||||
|
};
|
||||||
|
}>('/mindspace/v1/pages/quick-plaza-from-chat', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: input.sessionId,
|
||||||
|
message_id: input.messageId,
|
||||||
|
selected_link_index: input.selectedLinkIndex ?? 0,
|
||||||
|
}),
|
||||||
|
signal,
|
||||||
|
}, { timeoutMs: QUICK_PLAZA_TIMEOUT_MS });
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function downloadChatMessageDocx(input: {
|
export async function downloadChatMessageDocx(input: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { AvatarPicker } from './AvatarPicker';
|
|||||||
import { ChatSkillPicker } from './ChatSkillPicker';
|
import { ChatSkillPicker } from './ChatSkillPicker';
|
||||||
import { ChatLoadingSpinner } from './ChatLoadingSpinner';
|
import { ChatLoadingSpinner } from './ChatLoadingSpinner';
|
||||||
import { ChatSharePreviewModal } from './ChatSharePreviewModal';
|
import { ChatSharePreviewModal } from './ChatSharePreviewModal';
|
||||||
|
import { ChatPlazaPublishModal } from './ChatPlazaPublishModal';
|
||||||
import { MessageList } from './MessageList';
|
import { MessageList } from './MessageList';
|
||||||
import { PageSaveDialog } from './PageSaveDialog';
|
import { PageSaveDialog } from './PageSaveDialog';
|
||||||
import { VoiceInputButton } from './VoiceInputButton';
|
import { VoiceInputButton } from './VoiceInputButton';
|
||||||
@@ -166,6 +167,7 @@ export function ChatPanel({
|
|||||||
const [voiceRecording, setVoiceRecording] = useState(false);
|
const [voiceRecording, setVoiceRecording] = useState(false);
|
||||||
const [pageSource, setPageSource] = useState<Message | null>(null);
|
const [pageSource, setPageSource] = useState<Message | null>(null);
|
||||||
const [sharePreviewSource, setSharePreviewSource] = useState<Message | null>(null);
|
const [sharePreviewSource, setSharePreviewSource] = useState<Message | null>(null);
|
||||||
|
const [plazaPublishSource, setPlazaPublishSource] = useState<Message | null>(null);
|
||||||
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
|
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
|
||||||
const [forceDeepReasoning, setForceDeepReasoning] = useState(false);
|
const [forceDeepReasoning, setForceDeepReasoning] = useState(false);
|
||||||
const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]);
|
const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]);
|
||||||
@@ -629,6 +631,14 @@ export function ChatPanel({
|
|||||||
setPageSource(message);
|
setPageSource(message);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openPlazaPublish = (message: Message) => {
|
||||||
|
setPlazaPublishSource(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePlazaPublish = useCallback(() => {
|
||||||
|
setPlazaPublishSource(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const downloadLongImage = (_message: Message, publicUrl: string) => {
|
const downloadLongImage = (_message: Message, publicUrl: string) => {
|
||||||
triggerUrlDownload(appendLongImageDownloadParam(publicUrl));
|
triggerUrlDownload(appendLongImageDownloadParam(publicUrl));
|
||||||
};
|
};
|
||||||
@@ -665,6 +675,7 @@ export function ChatPanel({
|
|||||||
streaming={chatState === 'streaming'}
|
streaming={chatState === 'streaming'}
|
||||||
onAvatarClick={compact ? undefined : openAvatarPicker}
|
onAvatarClick={compact ? undefined : openAvatarPicker}
|
||||||
onSaveAsPage={openSaveActions}
|
onSaveAsPage={openSaveActions}
|
||||||
|
onShareToPlaza={openPlazaPublish}
|
||||||
onDownloadLongImage={downloadLongImage}
|
onDownloadLongImage={downloadLongImage}
|
||||||
onDownloadDocx={(message) => void downloadDocx(message)}
|
onDownloadDocx={(message) => void downloadDocx(message)}
|
||||||
publishUserId={user?.id}
|
publishUserId={user?.id}
|
||||||
@@ -721,6 +732,14 @@ export function ChatPanel({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{plazaPublishSource?.id && session?.id && (
|
||||||
|
<ChatPlazaPublishModal
|
||||||
|
sessionId={session.id}
|
||||||
|
messageId={plazaPublishSource.id}
|
||||||
|
onClose={closePlazaPublish}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<footer className={compact ? 'space-chat-panel-footer' : `footer${showHomeWelcome ? ' footer-home' : ''}`}>
|
<footer className={compact ? 'space-chat-panel-footer' : `footer${showHomeWelcome ? ' footer-home' : ''}`}>
|
||||||
{connectStatusText && (
|
{connectStatusText && (
|
||||||
<div className="chat-connect-status" role="status" aria-live="polite">
|
<div className="chat-connect-status" role="status" aria-live="polite">
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { ApiError, quickPlazaFromChat } from '../api/client';
|
||||||
|
import { resolvePlazaPostUrl } from '../utils/publicUrl';
|
||||||
|
import { ChatLoadingSpinner } from './ChatLoadingSpinner';
|
||||||
|
|
||||||
|
type PlazaPublishPhase =
|
||||||
|
| { kind: 'loading' }
|
||||||
|
| { kind: 'success'; plazaUrl: string }
|
||||||
|
| { kind: 'already'; plazaUrl: string; message: string }
|
||||||
|
| { kind: 'error'; message: string };
|
||||||
|
|
||||||
|
function resolveAlreadyPublishedMessage(err: ApiError) {
|
||||||
|
const postId = String(err.details?.post_id ?? err.details?.postId ?? '').trim();
|
||||||
|
const plazaUrl = postId ? resolvePlazaPostUrl(postId) : '';
|
||||||
|
return {
|
||||||
|
message: '该内容已发布到 Plaza,请先在广场删除后再试',
|
||||||
|
plazaUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatPlazaPublishModal({
|
||||||
|
sessionId,
|
||||||
|
messageId,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
sessionId: string;
|
||||||
|
messageId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [phase, setPhase] = useState<PlazaPublishPhase>({ kind: 'loading' });
|
||||||
|
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
if (closeTimer.current) window.clearTimeout(closeTimer.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== 'Escape' || phase.kind === 'loading') return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose, phase.kind]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
void quickPlazaFromChat({ sessionId, messageId }, controller.signal)
|
||||||
|
.then((result) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
const plazaUrl = resolvePlazaPostUrl(result.post.id);
|
||||||
|
setPhase({ kind: 'success', plazaUrl });
|
||||||
|
closeTimer.current = window.setTimeout(() => onCloseRef.current(), 2200);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
if (err instanceof ApiError && err.code === 'ALREADY_PUBLISHED') {
|
||||||
|
const resolved = resolveAlreadyPublishedMessage(err);
|
||||||
|
setPhase({
|
||||||
|
kind: 'already',
|
||||||
|
message: resolved.message,
|
||||||
|
plazaUrl: resolved.plazaUrl,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPhase({
|
||||||
|
kind: 'error',
|
||||||
|
message: err instanceof Error ? err.message : '发布到 Plaza 失败,请重试',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [sessionId, messageId]);
|
||||||
|
|
||||||
|
const canClose = phase.kind !== 'loading';
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="chat-plaza-publish-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onClick={canClose ? onClose : undefined}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="chat-plaza-publish-panel"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="chat-plaza-publish-title"
|
||||||
|
aria-busy={phase.kind === 'loading'}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
{phase.kind === 'loading' && (
|
||||||
|
<div className="chat-plaza-publish-body">
|
||||||
|
<ChatLoadingSpinner className="chat-plaza-publish-spinner" />
|
||||||
|
<h3 id="chat-plaza-publish-title">发布中</h3>
|
||||||
|
<p>正在保存页面并推送到 Plaza 广场…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase.kind === 'success' && (
|
||||||
|
<div className="chat-plaza-publish-body chat-plaza-publish-body-success">
|
||||||
|
<div className="chat-plaza-publish-icon chat-plaza-publish-icon-success" aria-hidden="true">
|
||||||
|
✓
|
||||||
|
</div>
|
||||||
|
<h3 id="chat-plaza-publish-title">发布成功</h3>
|
||||||
|
<p>内容已推送到 Plaza 广场,窗口即将关闭。</p>
|
||||||
|
<a href={phase.plazaUrl} target="_blank" rel="noreferrer" className="chat-plaza-publish-link">
|
||||||
|
查看 Plaza 帖子
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase.kind === 'already' && (
|
||||||
|
<div className="chat-plaza-publish-body chat-plaza-publish-body-warning">
|
||||||
|
<div className="chat-plaza-publish-icon chat-plaza-publish-icon-warning" aria-hidden="true">
|
||||||
|
!
|
||||||
|
</div>
|
||||||
|
<h3 id="chat-plaza-publish-title">无法重复发布</h3>
|
||||||
|
<p>{phase.message}</p>
|
||||||
|
{phase.plazaUrl ? (
|
||||||
|
<a href={phase.plazaUrl} target="_blank" rel="noreferrer" className="chat-plaza-publish-link">
|
||||||
|
查看已有帖子
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
<button type="button" className="chat-plaza-publish-close-btn" onClick={onClose}>
|
||||||
|
知道了
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase.kind === 'error' && (
|
||||||
|
<div className="chat-plaza-publish-body chat-plaza-publish-body-error">
|
||||||
|
<div className="chat-plaza-publish-icon chat-plaza-publish-icon-error" aria-hidden="true">
|
||||||
|
×
|
||||||
|
</div>
|
||||||
|
<h3 id="chat-plaza-publish-title">发布失败</h3>
|
||||||
|
<p>{phase.message}</p>
|
||||||
|
<button type="button" className="chat-plaza-publish-close-btn" onClick={onClose}>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -83,6 +83,22 @@ function DocumentIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlazaIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" className="icon-plaza">
|
||||||
|
<rect x="4" y="4" width="7" height="7" rx="1.6" stroke="currentColor" strokeWidth="1.7" />
|
||||||
|
<rect x="13" y="4" width="7" height="7" rx="1.6" stroke="currentColor" strokeWidth="1.7" />
|
||||||
|
<rect x="4" y="13" width="7" height="7" rx="1.6" stroke="currentColor" strokeWidth="1.7" />
|
||||||
|
<path
|
||||||
|
d="M13 16.5h7M16.5 13v7"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function LinkIcon() {
|
function LinkIcon() {
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
@@ -240,6 +256,7 @@ function MessageRow({
|
|||||||
avatarUrl,
|
avatarUrl,
|
||||||
onAvatarClick,
|
onAvatarClick,
|
||||||
onSaveAsPage,
|
onSaveAsPage,
|
||||||
|
onShareToPlaza,
|
||||||
onDownloadLongImage,
|
onDownloadLongImage,
|
||||||
onDownloadDocx,
|
onDownloadDocx,
|
||||||
saveDisabled,
|
saveDisabled,
|
||||||
@@ -253,6 +270,7 @@ function MessageRow({
|
|||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
onAvatarClick?: () => void;
|
onAvatarClick?: () => void;
|
||||||
onSaveAsPage?: (message: Message) => void;
|
onSaveAsPage?: (message: Message) => void;
|
||||||
|
onShareToPlaza?: (message: Message) => void;
|
||||||
onDownloadLongImage?: (message: Message, publicUrl: string) => void;
|
onDownloadLongImage?: (message: Message, publicUrl: string) => void;
|
||||||
onDownloadDocx?: (message: Message) => void;
|
onDownloadDocx?: (message: Message) => void;
|
||||||
saveDisabled?: boolean;
|
saveDisabled?: boolean;
|
||||||
@@ -286,8 +304,59 @@ function MessageRow({
|
|||||||
const hasAssistantActions =
|
const hasAssistantActions =
|
||||||
!isUser && Boolean(message.id && onSaveAsPage && copyText);
|
!isUser && Boolean(message.id && onSaveAsPage && copyText);
|
||||||
const hasPageDownloadActions = hasAssistantActions && saveActions.kind === 'page' && Boolean(saveActions.previewUrl);
|
const hasPageDownloadActions = hasAssistantActions && saveActions.kind === 'page' && Boolean(saveActions.previewUrl);
|
||||||
|
const hasPlazaAction = hasPageDownloadActions && Boolean(onShareToPlaza);
|
||||||
const showActionsToggle = compact && Boolean(copyText);
|
const showActionsToggle = compact && Boolean(copyText);
|
||||||
|
|
||||||
|
const renderPageActions = () => (
|
||||||
|
<div className="msg-page-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="msg-save-page"
|
||||||
|
disabled={saveDisabled}
|
||||||
|
onClick={() => onSaveAsPage!(message)}
|
||||||
|
aria-label={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
|
||||||
|
title={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
|
||||||
|
>
|
||||||
|
<PageIcon />
|
||||||
|
</button>
|
||||||
|
{saveActions.previewUrl && <PublicLinkCopyButton url={saveActions.previewUrl} />}
|
||||||
|
{hasPageDownloadActions && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="msg-save-page"
|
||||||
|
onClick={() => onDownloadLongImage?.(message, saveActions.previewUrl!)}
|
||||||
|
aria-label="下载图片"
|
||||||
|
title="下载图片"
|
||||||
|
>
|
||||||
|
<ImageIcon />
|
||||||
|
</button>
|
||||||
|
{hasPlazaAction && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="msg-save-page icon-plaza"
|
||||||
|
disabled={saveDisabled}
|
||||||
|
onClick={() => onShareToPlaza!(message)}
|
||||||
|
aria-label="发布到 Plaza"
|
||||||
|
title="发布到 Plaza"
|
||||||
|
>
|
||||||
|
<PlazaIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="msg-save-page"
|
||||||
|
onClick={() => onDownloadDocx?.(message)}
|
||||||
|
aria-label="保存文档"
|
||||||
|
title="保存文档"
|
||||||
|
>
|
||||||
|
<DocumentIcon />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`msg-row ${isUser ? 'msg-row-user' : 'msg-row-assistant'}`}>
|
<div className={`msg-row ${isUser ? 'msg-row-user' : 'msg-row-assistant'}`}>
|
||||||
{!isUser && <TKMindAvatar />}
|
{!isUser && <TKMindAvatar />}
|
||||||
@@ -344,89 +413,13 @@ function MessageRow({
|
|||||||
{copyText && !compact && (
|
{copyText && !compact && (
|
||||||
<div className="msg-actions">
|
<div className="msg-actions">
|
||||||
<CopyButton text={copyText} />
|
<CopyButton text={copyText} />
|
||||||
{hasAssistantActions && (
|
{hasAssistantActions && renderPageActions()}
|
||||||
<div className="msg-page-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="msg-save-page"
|
|
||||||
disabled={saveDisabled}
|
|
||||||
onClick={() => onSaveAsPage!(message)}
|
|
||||||
aria-label={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
|
|
||||||
title={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
|
|
||||||
>
|
|
||||||
<PageIcon />
|
|
||||||
</button>
|
|
||||||
{saveActions.previewUrl && (
|
|
||||||
<PublicLinkCopyButton url={saveActions.previewUrl} />
|
|
||||||
)}
|
|
||||||
{hasPageDownloadActions && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="msg-save-page"
|
|
||||||
onClick={() => onDownloadLongImage?.(message, saveActions.previewUrl!)}
|
|
||||||
aria-label="下载图片"
|
|
||||||
title="下载图片"
|
|
||||||
>
|
|
||||||
<ImageIcon />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="msg-save-page"
|
|
||||||
onClick={() => onDownloadDocx?.(message)}
|
|
||||||
aria-label="保存文档"
|
|
||||||
title="保存文档"
|
|
||||||
>
|
|
||||||
<DocumentIcon />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{copyText && compact && actionsOpen && (
|
{copyText && compact && actionsOpen && (
|
||||||
<div className="msg-actions msg-actions-compact">
|
<div className="msg-actions msg-actions-compact">
|
||||||
<CopyButton text={copyText} />
|
<CopyButton text={copyText} />
|
||||||
{hasAssistantActions && (
|
{hasAssistantActions && renderPageActions()}
|
||||||
<div className="msg-page-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="msg-save-page"
|
|
||||||
disabled={saveDisabled}
|
|
||||||
onClick={() => onSaveAsPage!(message)}
|
|
||||||
aria-label={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
|
|
||||||
title={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
|
|
||||||
>
|
|
||||||
<PageIcon />
|
|
||||||
</button>
|
|
||||||
{saveActions.previewUrl && (
|
|
||||||
<PublicLinkCopyButton url={saveActions.previewUrl} />
|
|
||||||
)}
|
|
||||||
{hasPageDownloadActions && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="msg-save-page"
|
|
||||||
onClick={() => onDownloadLongImage?.(message, saveActions.previewUrl!)}
|
|
||||||
aria-label="下载图片"
|
|
||||||
title="下载图片"
|
|
||||||
>
|
|
||||||
<ImageIcon />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="msg-save-page"
|
|
||||||
onClick={() => onDownloadDocx?.(message)}
|
|
||||||
aria-label="保存文档"
|
|
||||||
title="保存文档"
|
|
||||||
>
|
|
||||||
<DocumentIcon />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -448,6 +441,7 @@ export function MessageList({
|
|||||||
streaming,
|
streaming,
|
||||||
onAvatarClick,
|
onAvatarClick,
|
||||||
onSaveAsPage,
|
onSaveAsPage,
|
||||||
|
onShareToPlaza,
|
||||||
onDownloadLongImage,
|
onDownloadLongImage,
|
||||||
onDownloadDocx,
|
onDownloadDocx,
|
||||||
publishUserId,
|
publishUserId,
|
||||||
@@ -458,6 +452,7 @@ export function MessageList({
|
|||||||
streaming: boolean;
|
streaming: boolean;
|
||||||
onAvatarClick?: () => void;
|
onAvatarClick?: () => void;
|
||||||
onSaveAsPage?: (message: Message) => void;
|
onSaveAsPage?: (message: Message) => void;
|
||||||
|
onShareToPlaza?: (message: Message) => void;
|
||||||
onDownloadLongImage?: (message: Message, publicUrl: string) => void;
|
onDownloadLongImage?: (message: Message, publicUrl: string) => void;
|
||||||
onDownloadDocx?: (message: Message) => void;
|
onDownloadDocx?: (message: Message) => void;
|
||||||
publishUserId?: string;
|
publishUserId?: string;
|
||||||
@@ -496,6 +491,7 @@ export function MessageList({
|
|||||||
avatarUrl={avatarUrl}
|
avatarUrl={avatarUrl}
|
||||||
onAvatarClick={onAvatarClick}
|
onAvatarClick={onAvatarClick}
|
||||||
onSaveAsPage={onSaveAsPage}
|
onSaveAsPage={onSaveAsPage}
|
||||||
|
onShareToPlaza={onShareToPlaza}
|
||||||
onDownloadLongImage={onDownloadLongImage}
|
onDownloadLongImage={onDownloadLongImage}
|
||||||
onDownloadDocx={onDownloadDocx}
|
onDownloadDocx={onDownloadDocx}
|
||||||
saveDisabled={streaming}
|
saveDisabled={streaming}
|
||||||
|
|||||||
+70
-52
@@ -1014,45 +1014,57 @@ export function useTKMindChat(
|
|||||||
setPendingTool(null);
|
setPendingTool(null);
|
||||||
activeRequestId.current = null;
|
activeRequestId.current = null;
|
||||||
|
|
||||||
const knownSession = sessionsRef.current.find((s) => s.id === sessionId);
|
let completed = false;
|
||||||
const hints = knownSession
|
try {
|
||||||
? { messageCount: knownSession.message_count, updatedAt: knownSession.updated_at }
|
const knownSession = sessionsRef.current.find((s) => s.id === sessionId);
|
||||||
: undefined;
|
const hints = knownSession
|
||||||
const detailPromise = withTransientConnectRetry(() =>
|
? { messageCount: knownSession.message_count, updatedAt: knownSession.updated_at }
|
||||||
loadSessionDetail(sessionId, hints, {
|
: undefined;
|
||||||
before: 0,
|
const detailPromise = withTransientConnectRetry(() =>
|
||||||
limit: appConfig.sessionMessagePageSize,
|
loadSessionDetail(sessionId, hints, {
|
||||||
}),
|
before: 0,
|
||||||
);
|
limit: appConfig.sessionMessagePageSize,
|
||||||
const resumedPromise =
|
}),
|
||||||
options?.skipResume || isDirectChatSessionId(sessionId)
|
);
|
||||||
? Promise.resolve(options.seedSession ?? null)
|
const resumedPromise =
|
||||||
: withTransientConnectRetry(() =>
|
options?.skipResume || isDirectChatSessionId(sessionId)
|
||||||
resumeSession(sessionId, {
|
? Promise.resolve(options.seedSession ?? null)
|
||||||
skipReconcile: options?.skipReconcile ?? false,
|
: withTransientConnectRetry(() =>
|
||||||
}),
|
resumeSession(sessionId, {
|
||||||
);
|
skipReconcile: options?.skipReconcile ?? false,
|
||||||
const { session: detail, messages: history, page } = await detailPromise;
|
}),
|
||||||
if (token !== connectTokenRef.current) return;
|
);
|
||||||
|
const { session: detail, messages: history, page } = await detailPromise;
|
||||||
|
if (token !== connectTokenRef.current) return;
|
||||||
|
|
||||||
writeStoredSessionId(userRef.current?.id, sessionId);
|
writeStoredSessionId(userRef.current?.id, sessionId);
|
||||||
setSession((current) => (current?.id === sessionId ? { ...detail, ...current, id: sessionId } : { ...detail, id: sessionId }));
|
setSession((current) =>
|
||||||
messagesRef.current = history;
|
current?.id === sessionId ? { ...detail, ...current, id: sessionId } : { ...detail, id: sessionId },
|
||||||
messageHistoryLoadedCountRef.current = history.length;
|
);
|
||||||
messageHistoryTotalRef.current = Math.max(Number(page.total ?? history.length), history.length);
|
messagesRef.current = history;
|
||||||
messageHistoryHasMoreRef.current = history.length < messageHistoryTotalRef.current;
|
messageHistoryLoadedCountRef.current = history.length;
|
||||||
setMessageHistoryHasMore(messageHistoryHasMoreRef.current);
|
messageHistoryTotalRef.current = Math.max(Number(page.total ?? history.length), history.length);
|
||||||
setMessageHistoryTotal(messageHistoryTotalRef.current);
|
messageHistoryHasMoreRef.current = history.length < messageHistoryTotalRef.current;
|
||||||
setMessages(history);
|
setMessageHistoryHasMore(messageHistoryHasMoreRef.current);
|
||||||
const resumed = await resumedPromise;
|
setMessageHistoryTotal(messageHistoryTotalRef.current);
|
||||||
if (token !== connectTokenRef.current) return;
|
setMessages(history);
|
||||||
setSession({ ...detail, ...(resumed ?? {}), id: sessionId });
|
const resumed = await resumedPromise;
|
||||||
setChatState('idle');
|
if (token !== connectTokenRef.current) return;
|
||||||
setSessions((prev) =>
|
setSession({ ...detail, ...(resumed ?? {}), id: sessionId });
|
||||||
prependUnique(prev, toSessionSummary({ ...detail, ...(resumed ?? {}), id: sessionId })),
|
setChatState('idle');
|
||||||
);
|
completed = true;
|
||||||
|
setSessions((prev) =>
|
||||||
|
prependUnique(prev, toSessionSummary({ ...detail, ...(resumed ?? {}), id: sessionId })),
|
||||||
|
);
|
||||||
|
|
||||||
subscribeToSession(sessionId);
|
subscribeToSession(sessionId);
|
||||||
|
} finally {
|
||||||
|
if (!completed && token === connectTokenRef.current) {
|
||||||
|
setChatState((current) =>
|
||||||
|
current === 'connecting' || current === 'loading' ? 'idle' : current,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[clearActiveRequestMissingTimer, subscribeToSession],
|
[clearActiveRequestMissingTimer, subscribeToSession],
|
||||||
);
|
);
|
||||||
@@ -1528,22 +1540,23 @@ export function useTKMindChat(
|
|||||||
setPendingTool(null);
|
setPendingTool(null);
|
||||||
setChatState('connecting');
|
setChatState('connecting');
|
||||||
|
|
||||||
if (
|
let completed = false;
|
||||||
previousSessionId &&
|
|
||||||
previousSession &&
|
|
||||||
shouldShowNewChatTitle(toSessionSummary(previousSession))
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
await deleteChatSession(previousSessionId);
|
|
||||||
if (token === connectTokenRef.current) {
|
|
||||||
setSessions((prev) => prev.filter((item) => item.id !== previousSessionId));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Keep the abandoned empty session in history if cleanup fails.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (
|
||||||
|
previousSessionId &&
|
||||||
|
previousSession &&
|
||||||
|
shouldShowNewChatTitle(toSessionSummary(previousSession))
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await deleteChatSession(previousSessionId);
|
||||||
|
if (token === connectTokenRef.current) {
|
||||||
|
setSessions((prev) => prev.filter((item) => item.id !== previousSessionId));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep the abandoned empty session in history if cleanup fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const started = await startSession();
|
const started = await startSession();
|
||||||
if (token !== connectTokenRef.current) return;
|
if (token !== connectTokenRef.current) return;
|
||||||
|
|
||||||
@@ -1560,12 +1573,17 @@ export function useTKMindChat(
|
|||||||
if (token !== connectTokenRef.current) return;
|
if (token !== connectTokenRef.current) return;
|
||||||
subscribeToSession(nextSession.id);
|
subscribeToSession(nextSession.id);
|
||||||
setChatState('idle');
|
setChatState('idle');
|
||||||
|
completed = true;
|
||||||
void refreshSessions();
|
void refreshSessions();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (token !== connectTokenRef.current) return;
|
if (token !== connectTokenRef.current) return;
|
||||||
setSession(null);
|
setSession(null);
|
||||||
setChatState('idle');
|
setChatState('idle');
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
if (!completed && token === connectTokenRef.current) {
|
||||||
|
setChatState((current) => (current === 'connecting' ? 'idle' : current));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
clearActiveRequestMissingTimer,
|
clearActiveRequestMissingTimer,
|
||||||
|
|||||||
+113
@@ -1377,6 +1377,12 @@ body,
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.msg-save-page.icon-plaza:hover,
|
||||||
|
.msg-save-page.icon-plaza.is-done {
|
||||||
|
color: #2f6f57;
|
||||||
|
border-color: rgba(47, 111, 87, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
.msg-save-page,
|
.msg-save-page,
|
||||||
.msg-public-share-link {
|
.msg-public-share-link {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -1442,6 +1448,107 @@ body,
|
|||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-plaza-publish-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1250;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(7, 12, 10, 0.72);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-plaza-publish-panel {
|
||||||
|
width: min(360px, 100%);
|
||||||
|
padding: 28px 24px;
|
||||||
|
border: 1px solid rgba(238, 176, 78, 0.28);
|
||||||
|
border-radius: 22px;
|
||||||
|
color: #18211d;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 100% 0, rgba(47, 111, 87, 0.14), transparent 16rem),
|
||||||
|
#f8f3e8;
|
||||||
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-plaza-publish-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-plaza-publish-body h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-plaza-publish-body p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: rgba(24, 33, 29, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-plaza-publish-spinner {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-plaza-publish-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-plaza-publish-icon-success {
|
||||||
|
color: #2f6f57;
|
||||||
|
background: rgba(47, 111, 87, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-plaza-publish-icon-warning {
|
||||||
|
color: #b7791f;
|
||||||
|
background: rgba(238, 176, 78, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-plaza-publish-icon-error {
|
||||||
|
color: #b42318;
|
||||||
|
background: rgba(220, 38, 38, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-plaza-publish-link {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #2f6f57;
|
||||||
|
font-size: 14px;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-plaza-publish-close-btn {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
border: 1px solid rgba(24, 33, 29, 0.14);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
color: #18211d;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-plaza-publish-close-btn:hover {
|
||||||
|
border-color: rgba(47, 111, 87, 0.35);
|
||||||
|
color: #2f6f57;
|
||||||
|
}
|
||||||
|
|
||||||
.page-save-panel {
|
.page-save-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -9880,6 +9987,12 @@ body,
|
|||||||
color: var(--ms-soft);
|
color: var(--ms-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.space-chat-panel .msg-save-page.icon-plaza:hover,
|
||||||
|
.space-chat-panel .msg-save-page.icon-plaza.is-done {
|
||||||
|
color: var(--ms-green-deep);
|
||||||
|
border-color: rgba(47, 111, 87, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
.space-chat-panel .chat-skill-menu {
|
.space-chat-panel .chat-skill-menu {
|
||||||
border-color: rgba(24, 33, 29, 0.12);
|
border-color: rgba(24, 33, 29, 0.12);
|
||||||
background: rgba(255, 252, 244, 0.98);
|
background: rgba(255, 252, 244, 0.98);
|
||||||
|
|||||||
Reference in New Issue
Block a user