feat(chat): add one-click Plaza publish for HTML page messages.
Speed up the quick-plaza path with session snapshot reads, publish timeouts, and a modal fix so parent re-renders no longer abort in-flight requests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -64,6 +64,7 @@ import type { AgentRunCreateOptions, AgentRunValidation } from '../utils/agentRu
|
||||
const API = '/api';
|
||||
const DEFAULT_API_TIMEOUT_MS = 20_000;
|
||||
const AGENT_CONNECT_TIMEOUT_MS = 60_000;
|
||||
const QUICK_PLAZA_TIMEOUT_MS = 120_000;
|
||||
const AGENT_RUNS_PATH = '/agent/runs';
|
||||
|
||||
export type AgentRun = {
|
||||
@@ -1086,6 +1087,35 @@ export async function quickShareFromChat(input: {
|
||||
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: {
|
||||
sessionId: string;
|
||||
messageId: string;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AvatarPicker } from './AvatarPicker';
|
||||
import { ChatSkillPicker } from './ChatSkillPicker';
|
||||
import { ChatLoadingSpinner } from './ChatLoadingSpinner';
|
||||
import { ChatSharePreviewModal } from './ChatSharePreviewModal';
|
||||
import { ChatPlazaPublishModal } from './ChatPlazaPublishModal';
|
||||
import { MessageList } from './MessageList';
|
||||
import { PageSaveDialog } from './PageSaveDialog';
|
||||
import { VoiceInputButton } from './VoiceInputButton';
|
||||
@@ -166,6 +167,7 @@ export function ChatPanel({
|
||||
const [voiceRecording, setVoiceRecording] = useState(false);
|
||||
const [pageSource, setPageSource] = 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 [forceDeepReasoning, setForceDeepReasoning] = useState(false);
|
||||
const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]);
|
||||
@@ -629,6 +631,14 @@ export function ChatPanel({
|
||||
setPageSource(message);
|
||||
};
|
||||
|
||||
const openPlazaPublish = (message: Message) => {
|
||||
setPlazaPublishSource(message);
|
||||
};
|
||||
|
||||
const closePlazaPublish = useCallback(() => {
|
||||
setPlazaPublishSource(null);
|
||||
}, []);
|
||||
|
||||
const downloadLongImage = (_message: Message, publicUrl: string) => {
|
||||
triggerUrlDownload(appendLongImageDownloadParam(publicUrl));
|
||||
};
|
||||
@@ -665,6 +675,7 @@ export function ChatPanel({
|
||||
streaming={chatState === 'streaming'}
|
||||
onAvatarClick={compact ? undefined : openAvatarPicker}
|
||||
onSaveAsPage={openSaveActions}
|
||||
onShareToPlaza={openPlazaPublish}
|
||||
onDownloadLongImage={downloadLongImage}
|
||||
onDownloadDocx={(message) => void downloadDocx(message)}
|
||||
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' : ''}`}>
|
||||
{connectStatusText && (
|
||||
<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() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
@@ -240,6 +256,7 @@ function MessageRow({
|
||||
avatarUrl,
|
||||
onAvatarClick,
|
||||
onSaveAsPage,
|
||||
onShareToPlaza,
|
||||
onDownloadLongImage,
|
||||
onDownloadDocx,
|
||||
saveDisabled,
|
||||
@@ -253,6 +270,7 @@ function MessageRow({
|
||||
avatarUrl: string | null;
|
||||
onAvatarClick?: () => void;
|
||||
onSaveAsPage?: (message: Message) => void;
|
||||
onShareToPlaza?: (message: Message) => void;
|
||||
onDownloadLongImage?: (message: Message, publicUrl: string) => void;
|
||||
onDownloadDocx?: (message: Message) => void;
|
||||
saveDisabled?: boolean;
|
||||
@@ -286,8 +304,59 @@ function MessageRow({
|
||||
const hasAssistantActions =
|
||||
!isUser && Boolean(message.id && onSaveAsPage && copyText);
|
||||
const hasPageDownloadActions = hasAssistantActions && saveActions.kind === 'page' && Boolean(saveActions.previewUrl);
|
||||
const hasPlazaAction = hasPageDownloadActions && Boolean(onShareToPlaza);
|
||||
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 (
|
||||
<div className={`msg-row ${isUser ? 'msg-row-user' : 'msg-row-assistant'}`}>
|
||||
{!isUser && <TKMindAvatar />}
|
||||
@@ -344,89 +413,13 @@ function MessageRow({
|
||||
{copyText && !compact && (
|
||||
<div className="msg-actions">
|
||||
<CopyButton text={copyText} />
|
||||
{hasAssistantActions && (
|
||||
<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>
|
||||
)}
|
||||
{hasAssistantActions && renderPageActions()}
|
||||
</div>
|
||||
)}
|
||||
{copyText && compact && actionsOpen && (
|
||||
<div className="msg-actions msg-actions-compact">
|
||||
<CopyButton text={copyText} />
|
||||
{hasAssistantActions && (
|
||||
<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>
|
||||
)}
|
||||
{hasAssistantActions && renderPageActions()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -448,6 +441,7 @@ export function MessageList({
|
||||
streaming,
|
||||
onAvatarClick,
|
||||
onSaveAsPage,
|
||||
onShareToPlaza,
|
||||
onDownloadLongImage,
|
||||
onDownloadDocx,
|
||||
publishUserId,
|
||||
@@ -458,6 +452,7 @@ export function MessageList({
|
||||
streaming: boolean;
|
||||
onAvatarClick?: () => void;
|
||||
onSaveAsPage?: (message: Message) => void;
|
||||
onShareToPlaza?: (message: Message) => void;
|
||||
onDownloadLongImage?: (message: Message, publicUrl: string) => void;
|
||||
onDownloadDocx?: (message: Message) => void;
|
||||
publishUserId?: string;
|
||||
@@ -496,6 +491,7 @@ export function MessageList({
|
||||
avatarUrl={avatarUrl}
|
||||
onAvatarClick={onAvatarClick}
|
||||
onSaveAsPage={onSaveAsPage}
|
||||
onShareToPlaza={onShareToPlaza}
|
||||
onDownloadLongImage={onDownloadLongImage}
|
||||
onDownloadDocx={onDownloadDocx}
|
||||
saveDisabled={streaming}
|
||||
|
||||
+70
-52
@@ -1014,45 +1014,57 @@ export function useTKMindChat(
|
||||
setPendingTool(null);
|
||||
activeRequestId.current = null;
|
||||
|
||||
const knownSession = sessionsRef.current.find((s) => s.id === sessionId);
|
||||
const hints = knownSession
|
||||
? { messageCount: knownSession.message_count, updatedAt: knownSession.updated_at }
|
||||
: undefined;
|
||||
const detailPromise = withTransientConnectRetry(() =>
|
||||
loadSessionDetail(sessionId, hints, {
|
||||
before: 0,
|
||||
limit: appConfig.sessionMessagePageSize,
|
||||
}),
|
||||
);
|
||||
const resumedPromise =
|
||||
options?.skipResume || isDirectChatSessionId(sessionId)
|
||||
? Promise.resolve(options.seedSession ?? null)
|
||||
: withTransientConnectRetry(() =>
|
||||
resumeSession(sessionId, {
|
||||
skipReconcile: options?.skipReconcile ?? false,
|
||||
}),
|
||||
);
|
||||
const { session: detail, messages: history, page } = await detailPromise;
|
||||
if (token !== connectTokenRef.current) return;
|
||||
let completed = false;
|
||||
try {
|
||||
const knownSession = sessionsRef.current.find((s) => s.id === sessionId);
|
||||
const hints = knownSession
|
||||
? { messageCount: knownSession.message_count, updatedAt: knownSession.updated_at }
|
||||
: undefined;
|
||||
const detailPromise = withTransientConnectRetry(() =>
|
||||
loadSessionDetail(sessionId, hints, {
|
||||
before: 0,
|
||||
limit: appConfig.sessionMessagePageSize,
|
||||
}),
|
||||
);
|
||||
const resumedPromise =
|
||||
options?.skipResume || isDirectChatSessionId(sessionId)
|
||||
? Promise.resolve(options.seedSession ?? null)
|
||||
: withTransientConnectRetry(() =>
|
||||
resumeSession(sessionId, {
|
||||
skipReconcile: options?.skipReconcile ?? false,
|
||||
}),
|
||||
);
|
||||
const { session: detail, messages: history, page } = await detailPromise;
|
||||
if (token !== connectTokenRef.current) return;
|
||||
|
||||
writeStoredSessionId(userRef.current?.id, sessionId);
|
||||
setSession((current) => (current?.id === sessionId ? { ...detail, ...current, id: sessionId } : { ...detail, id: sessionId }));
|
||||
messagesRef.current = history;
|
||||
messageHistoryLoadedCountRef.current = history.length;
|
||||
messageHistoryTotalRef.current = Math.max(Number(page.total ?? history.length), history.length);
|
||||
messageHistoryHasMoreRef.current = history.length < messageHistoryTotalRef.current;
|
||||
setMessageHistoryHasMore(messageHistoryHasMoreRef.current);
|
||||
setMessageHistoryTotal(messageHistoryTotalRef.current);
|
||||
setMessages(history);
|
||||
const resumed = await resumedPromise;
|
||||
if (token !== connectTokenRef.current) return;
|
||||
setSession({ ...detail, ...(resumed ?? {}), id: sessionId });
|
||||
setChatState('idle');
|
||||
setSessions((prev) =>
|
||||
prependUnique(prev, toSessionSummary({ ...detail, ...(resumed ?? {}), id: sessionId })),
|
||||
);
|
||||
writeStoredSessionId(userRef.current?.id, sessionId);
|
||||
setSession((current) =>
|
||||
current?.id === sessionId ? { ...detail, ...current, id: sessionId } : { ...detail, id: sessionId },
|
||||
);
|
||||
messagesRef.current = history;
|
||||
messageHistoryLoadedCountRef.current = history.length;
|
||||
messageHistoryTotalRef.current = Math.max(Number(page.total ?? history.length), history.length);
|
||||
messageHistoryHasMoreRef.current = history.length < messageHistoryTotalRef.current;
|
||||
setMessageHistoryHasMore(messageHistoryHasMoreRef.current);
|
||||
setMessageHistoryTotal(messageHistoryTotalRef.current);
|
||||
setMessages(history);
|
||||
const resumed = await resumedPromise;
|
||||
if (token !== connectTokenRef.current) return;
|
||||
setSession({ ...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],
|
||||
);
|
||||
@@ -1528,22 +1540,23 @@ export function useTKMindChat(
|
||||
setPendingTool(null);
|
||||
setChatState('connecting');
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
let completed = false;
|
||||
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();
|
||||
if (token !== connectTokenRef.current) return;
|
||||
|
||||
@@ -1560,12 +1573,17 @@ export function useTKMindChat(
|
||||
if (token !== connectTokenRef.current) return;
|
||||
subscribeToSession(nextSession.id);
|
||||
setChatState('idle');
|
||||
completed = true;
|
||||
void refreshSessions();
|
||||
} catch (err) {
|
||||
if (token !== connectTokenRef.current) return;
|
||||
setSession(null);
|
||||
setChatState('idle');
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (!completed && token === connectTokenRef.current) {
|
||||
setChatState((current) => (current === 'connecting' ? 'idle' : current));
|
||||
}
|
||||
}
|
||||
}, [
|
||||
clearActiveRequestMissingTimer,
|
||||
|
||||
+113
@@ -1377,6 +1377,12 @@ body,
|
||||
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-public-share-link {
|
||||
display: inline-flex;
|
||||
@@ -1442,6 +1448,107 @@ body,
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -9880,6 +9987,12 @@ body,
|
||||
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 {
|
||||
border-color: rgba(24, 33, 29, 0.12);
|
||||
background: rgba(255, 252, 244, 0.98);
|
||||
|
||||
Reference in New Issue
Block a user