feat(mindspace): gate published page delivery

This commit is contained in:
john
2026-07-14 19:13:01 +08:00
parent ae9090948a
commit 27b11894f2
16 changed files with 448 additions and 39 deletions
+4
View File
@@ -2131,6 +2131,10 @@ export async function listNotifications(status = 'unread', limit = 20): Promise<
if (status && status !== 'all') params.set('status', status);
params.set('limit', String(limit));
const response = await fetch(`/auth/notifications?${params}`);
if (response.status === 401) {
notifyUnauthorized();
throw new ApiError(401, '未授权,请重新登录');
}
if (!response.ok) {
throw new ApiError(response.status, '读取通知失败');
}
+49 -17
View File
@@ -1,4 +1,5 @@
import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent } from 'react';
import { BrainCircuit, Database } from 'lucide-react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { openAvatarPicker } from '../utils/userAvatar';
import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills';
@@ -184,6 +185,7 @@ export function ChatPanel({
options?: {
messageId?: string;
forceDeepReasoning?: boolean;
pgRequired?: boolean;
selectedChatSkill?: string;
fileAttachments?: ChatFileAttachment[];
},
@@ -216,6 +218,8 @@ export function ChatPanel({
const [plazaPublishSource, setPlazaPublishSource] = useState<Message | null>(null);
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
const [forceDeepReasoning, setForceDeepReasoning] = useState(false);
const [pgRequired, setPgRequired] = useState(false);
const [chatControlOnboardingStep, setChatControlOnboardingStep] = useState<0 | 1 | 2 | null>(null);
const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]);
const [pendingFiles, setPendingFiles] = useState<PendingChatFile[]>([]);
const [uploadingImage, setUploadingImage] = useState(false);
@@ -293,6 +297,16 @@ export function ChatPanel({
nearBottomRef.current = true;
}, [session?.id]);
useEffect(() => {
setChatControlOnboardingStep(0);
const timers = [
window.setTimeout(() => setChatControlOnboardingStep(1), 2_200),
window.setTimeout(() => setChatControlOnboardingStep(2), 4_400),
window.setTimeout(() => setChatControlOnboardingStep(null), 6_600),
];
return () => timers.forEach((timer) => window.clearTimeout(timer));
}, [session?.id]);
useLayoutEffect(() => {
const container = mainRef.current;
if (!container) return;
@@ -730,6 +744,7 @@ export function ChatPanel({
await onSubmit(trimmed, imagesToSend, previewImagesToSend, {
messageId: outgoingMessageId,
forceDeepReasoning,
pgRequired,
selectedChatSkill,
fileAttachments: fileAttachmentsToSend,
});
@@ -758,6 +773,7 @@ export function ChatPanel({
suppressVoiceUpdateRef.current = false;
}, [
forceDeepReasoning,
pgRequired,
input,
onSubmit,
onUploadFile,
@@ -1189,6 +1205,7 @@ export function ChatPanel({
<ChatSkillPicker
skills={chatSkills}
disabled={voiceDisabled}
onboardingActive={chatControlOnboardingStep === 0}
onSelect={submitText}
onPrefill={(prompt, skillId) => {
pendingSkillRef.current = skillId ?? null;
@@ -1196,23 +1213,38 @@ export function ChatPanel({
}}
/>
)}
{!showHomeWelcome && (
<label
className={`chat-deep-reasoning-toggle${forceDeepReasoning ? ' is-active' : ''}`}
title="勾选后,本轮消息使用深度推理完成"
>
<input
type="checkbox"
checked={forceDeepReasoning}
disabled={voiceDisabled}
onChange={(event) => setForceDeepReasoning(event.target.checked)}
/>
<span className="chat-deep-reasoning-toggle-label" aria-hidden="true">
<span></span>
<span></span>
</span>
</label>
)}
<label
className={`chat-deep-reasoning-toggle${pgRequired ? ' is-active' : ''}${chatControlOnboardingStep === 1 ? ' chat-control-onboarding-active' : ''}`}
title="勾选后,本轮生成的可保存数据必须使用你的专属 PostgreSQL 数据空间"
>
<input
type="checkbox"
checked={pgRequired}
disabled={voiceDisabled}
aria-label="使用 PostgreSQL 数据空间"
onChange={(event) => setPgRequired(event.target.checked)}
/>
<Database className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
{chatControlOnboardingStep === 1 && (
<span className="chat-control-onboarding-tip" role="status">使</span>
)}
</label>
<label
className={`chat-deep-reasoning-toggle${forceDeepReasoning ? ' is-active' : ''}${chatControlOnboardingStep === 2 ? ' chat-control-onboarding-active' : ''}`}
title="勾选后,本轮消息使用深度推理完成"
>
<input
type="checkbox"
checked={forceDeepReasoning}
disabled={voiceDisabled}
aria-label="使用深度推理"
onChange={(event) => setForceDeepReasoning(event.target.checked)}
/>
<BrainCircuit className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
{chatControlOnboardingStep === 2 && (
<span className="chat-control-onboarding-tip" role="status"></span>
)}
</label>
{chatState === 'streaming' ? (
<button type="button" className="danger-btn chat-input-action" onClick={() => void onStop()}>
+6 -1
View File
@@ -5,11 +5,13 @@ import { ChatSkillIcon } from './ChatSkillIcons';
export function ChatSkillPicker({
skills,
disabled,
onboardingActive = false,
onSelect,
onPrefill,
}: {
skills: ChatSkillOption[];
disabled?: boolean;
onboardingActive?: boolean;
onSelect: (prompt: string, skillId?: string) => void | Promise<void>;
onPrefill?: (prompt: string, skillId?: string) => void;
}) {
@@ -50,7 +52,7 @@ export function ChatSkillPicker({
<div className={`chat-skill-picker${open ? ' open' : ''}`} ref={wrapRef}>
<button
type="button"
className="chat-skill-trigger"
className={`chat-skill-trigger${onboardingActive ? ' chat-control-onboarding-active' : ''}`}
disabled={disabled}
aria-expanded={open}
aria-haspopup="menu"
@@ -59,6 +61,9 @@ export function ChatSkillPicker({
>
<ChatSkillIcon id="spark" className="chat-skill-trigger-icon" />
<span className="chat-skill-trigger-label">skill</span>
{onboardingActive && (
<span className="chat-control-onboarding-tip" role="status"> Skill</span>
)}
</button>
{open && (
+1
View File
@@ -547,6 +547,7 @@ export function ChatView({
{
messageId: options?.messageId,
forceDeepReasoning: options?.forceDeepReasoning,
pgRequired: options?.pgRequired,
selectedChatSkill: options?.selectedChatSkill,
fileAttachments: options?.fileAttachments,
},
+2
View File
@@ -147,6 +147,7 @@ export function SpaceChatPanel({
...context,
messageId: options?.messageId,
forceDeepReasoning: options?.forceDeepReasoning,
pgRequired: options?.pgRequired,
fileAttachments: options?.fileAttachments,
},
imageUrls,
@@ -158,6 +159,7 @@ export function SpaceChatPanel({
mindspaceContext: context,
messageId: options?.messageId,
forceDeepReasoning: options?.forceDeepReasoning,
pgRequired: options?.pgRequired,
fileAttachments: options?.fileAttachments,
},
imageUrls,
+29 -3
View File
@@ -279,6 +279,10 @@ function isTransientConnectError(err: unknown) {
return /超时|timeout/i.test(err.message);
}
function isMissingAgentSessionError(err: unknown) {
return err instanceof ApiError && /session not found/i.test(err.message);
}
async function withTransientConnectRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
let lastErr: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
@@ -329,6 +333,7 @@ export function useTKMindChat(
const subscribedSessionIdRef = useRef<string | null>(null);
const unsubscribeRef = useRef<(() => void) | null>(null);
const connectTokenRef = useRef(0);
const unavailableAgentSessionIdsRef = useRef(new Set<string>());
const messagesRef = useRef<Message[]>([]);
const sessionRef = useRef<Session | null>(null);
const sessionsRef = useRef<SessionSummary[]>([]);
@@ -1139,7 +1144,9 @@ export function useTKMindChat(
}),
);
const resumedPromise =
options?.skipResume || isDirectChatSessionId(sessionId)
options?.skipResume ||
isDirectChatSessionId(sessionId) ||
unavailableAgentSessionIdsRef.current.has(sessionId)
? Promise.resolve(options.seedSession ?? null)
: withTransientConnectRetry(() =>
resumeSession(sessionId, {
@@ -1160,7 +1167,16 @@ export function useTKMindChat(
setMessageHistoryHasMore(messageHistoryHasMoreRef.current);
setMessageHistoryTotal(messageHistoryTotalRef.current);
setMessages(history);
const resumed = await resumedPromise;
let resumed: Session | null = null;
try {
resumed = await resumedPromise;
} catch (err) {
if (!isMissingAgentSessionError(err)) throw err;
// Persisted conversation is still readable even when its transient
// Agent runtime has been reclaimed. Do not leave a completed chat
// in the connecting state or retry resume on future selections.
unavailableAgentSessionIdsRef.current.add(sessionId);
}
if (token !== connectTokenRef.current) return;
setSession({ ...detail, ...(resumed ?? {}), id: sessionId });
setChatState('idle');
@@ -1383,6 +1399,7 @@ export function useTKMindChat(
mindspaceContext?: MindSpaceChatContext;
messageId?: string;
forceDeepReasoning?: boolean;
pgRequired?: boolean;
selectedChatSkill?: string;
fileAttachments?: ChatFileAttachment[];
},
@@ -1412,7 +1429,10 @@ export function useTKMindChat(
: '';
const userPrefix = buildUserAddressPrefix(userRef.current);
const skillPrefix = buildAutoChatSkillPrefix(trimmed, grantedSkills ?? []);
const agentPrefix = `${userPrefix}${mindspacePrefix}${skillPrefix}`;
const pgContractPrefix = options?.pgRequired
? '[交付约束:用户已明确要求使用专属 PostgreSQL 数据空间。若生成页面,必须按 page-data-collect 完成建表、dataset、policy 和 workspace page 绑定;禁止 localStorage、SQLite、静态 JSON 或内存持久化。所有验证通过前不得回复已发布或给出页面链接。]\n'
: '';
const agentPrefix = `${userPrefix}${mindspacePrefix}${skillPrefix}${pgContractPrefix}`;
const priorMessageCount = messagesRef.current.length;
const userMessage = buildUserMessage(trimmed, {
id: options?.messageId,
@@ -1429,6 +1449,7 @@ export function useTKMindChat(
? userMessage.metadata.memindRun
: {}),
sessionMessageCount: priorMessageCount,
...(options?.pgRequired ? { pgRequired: true } : {}),
...(options?.selectedChatSkill ? { selectedChatSkill: options.selectedChatSkill } : {}),
},
};
@@ -1582,6 +1603,11 @@ export function useTKMindChat(
const nextChatState = resolvePostAgentRunChatState({
chatState: chatStateRef.current,
finishedViaPortalDirectChat,
// The agent-run result is authoritative even when the immediate
// session snapshot has not yet carried portal-direct metadata.
// Without this, a completed Page Data task can re-enter streaming
// and leave the Stop button attached to no active request.
agentRunSucceeded: finishedRun.status === 'succeeded',
});
if (nextChatState === 'idle') {
clearActiveRequestMissingTimer();
+86 -11
View File
@@ -717,20 +717,25 @@ body,
justify-content: center;
width: auto;
min-width: 0;
height: 30px;
padding: 2px 4px;
height: 24px;
padding: 2px 3px;
border-radius: 6px;
font-size: 10px;
flex-direction: row;
gap: 3px;
gap: 2px;
font-weight: 600;
line-height: 1;
white-space: normal;
}
.app-shell-h5 .chat-deep-reasoning-toggle input {
width: 11px;
height: 11px;
width: 9px;
height: 9px;
}
.app-shell-h5 .chat-deep-reasoning-toggle-icon {
width: 13px;
height: 13px;
}
.app-shell-h5 .chat-deep-reasoning-toggle-label {
@@ -1843,7 +1848,7 @@ body,
.page-save-preview-hint {
margin: 0;
color: #7a8680;
font-size: 11px;
font-size: 10px;
line-height: 1.4;
}
@@ -3061,6 +3066,7 @@ body,
}
.chat-deep-reasoning-toggle {
position: relative;
display: inline-flex;
flex: 0 0 auto;
align-items: center;
@@ -3101,6 +3107,70 @@ body,
line-height: 1.1;
}
.chat-deep-reasoning-toggle-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.chat-control-onboarding-active {
position: relative;
z-index: 31;
animation: chat-control-onboarding-pulse 1s ease-in-out infinite;
}
.chat-control-onboarding-tip {
position: absolute;
bottom: calc(100% + 7px);
left: 50%;
z-index: 2;
width: max-content;
max-width: min(165px, calc(100vw - 20px));
padding: 4px 6px;
border: 1px solid rgba(218, 229, 255, 0.6);
border-radius: 9px;
background: linear-gradient(135deg, rgba(73, 117, 239, 0.98), rgba(152, 91, 230, 0.98));
box-shadow: 0 6px 14px rgba(103, 89, 221, 0.28), 0 1px 3px rgba(28, 38, 100, 0.2);
color: #fff;
font-size: 8px;
font-weight: 600;
line-height: 1.35;
letter-spacing: 0.01em;
pointer-events: none;
transform: translateX(-50%);
animation: chat-control-onboarding-tip-in 220ms ease-out both;
}
.chat-control-onboarding-tip::after {
position: absolute;
bottom: -4px;
left: 50%;
width: 6px;
height: 6px;
border-right: 1px solid rgba(218, 229, 255, 0.6);
border-bottom: 1px solid rgba(218, 229, 255, 0.6);
background: #835fdb;
content: '';
transform: translateX(-50%) rotate(45deg);
}
@keyframes chat-control-onboarding-pulse {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-2px) scale(1.07); }
}
@keyframes chat-control-onboarding-tip-in {
from { opacity: 0; transform: translate(-50%, 4px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
@media (prefers-reduced-motion: reduce) {
.chat-control-onboarding-active,
.chat-control-onboarding-tip {
animation: none;
}
}
.chat-deep-reasoning-toggle.is-active {
color: #eff6ff;
border-color: rgba(121, 183, 255, 0.6);
@@ -9938,12 +10008,12 @@ body,
justify-content: center;
width: auto;
min-width: 0;
height: 30px;
padding: 2px 4px;
height: 24px;
padding: 2px 3px;
border-radius: 6px;
font-size: 10px;
flex-direction: row;
gap: 3px;
gap: 2px;
font-weight: 600;
white-space: normal;
}
@@ -9953,8 +10023,13 @@ body,
}
.chat-deep-reasoning-toggle input {
width: 11px;
height: 11px;
width: 9px;
height: 9px;
}
.chat-deep-reasoning-toggle-icon {
width: 13px;
height: 13px;
}
.chat-deep-reasoning-toggle-label {