Compare commits

...

2 Commits

Author SHA1 Message Date
肖应宇 08e8de5e3e fix(workspace): 恢复先删后建并修复新会话初始化时序
- 新会话初始化改为 delete -> create

- 通过初始化就绪门控,确保 history 在创建完成后再加载

- 发送消息前等待初始化完成,避免与初始化并发
2026-04-28 18:34:18 +08:00
肖应宇 d1cdb7eef7 fix(workspace): 调整输入区与消息展示逻辑
- 输入框新增返回欢迎页按钮

- 人类消息展示保留原始换行

- 调整引用刷新策略与中英文文案
2026-04-28 18:29:39 +08:00
8 changed files with 109 additions and 40 deletions

View File

@ -81,16 +81,23 @@ export default function ChatPage() {
() => isNewThread && !safeThreadId,
[isNewThread, safeThreadId],
);
const [isThreadInitReady, setIsThreadInitReady] = useState(false);
const streamThreadId = useMemo(() => {
if (isNewThread && createNewSession) {
if (!safeThreadId) {
return undefined;
}
// In /new flow, defer history loading until thread init is finished:
// delete -> create -> history.
if (isNewThread && !isThreadInitReady) {
return undefined;
}
return safeThreadId;
}, [createNewSession, isNewThread, safeThreadId]);
}, [isNewThread, isThreadInitReady, safeThreadId]);
const apiClient = useMemo(() => getAPIClient(isMock), [isMock]);
const warnedMissingThreadIdRef = useRef(false);
const initializedThreadRef = useRef<string | null>(null);
const threadInitPromiseRef = useRef<Promise<void> | null>(null);
const { showNotification } = useNotification();
const currentSlogan = motivationSlogans[
@ -130,6 +137,7 @@ export default function ChatPage() {
useEffect(() => {
if (!isNewThread) {
warnedMissingThreadIdRef.current = false;
setIsThreadInitReady(true);
return;
}
if (!safeThreadId) {
@ -137,29 +145,38 @@ export default function ChatPage() {
warnedMissingThreadIdRef.current = true;
toast.error(t.chatPage.missingThreadIdForCreate);
}
setIsThreadInitReady(false);
return;
}
warnedMissingThreadIdRef.current = false;
if (initializedThreadRef.current === safeThreadId) return;
initializedThreadRef.current = safeThreadId;
void apiClient.threads
// TODO: 先注释先删除再创建的逻辑
// .delete(safeThreadId)
// .catch(() => undefined)
// .then(() =>
// apiClient.threads.create({
// threadId: safeThreadId,
// ifExists: "raise",
// }),
// )
.create({
threadId: safeThreadId,
ifExists: "do_nothing",
setIsThreadInitReady(false);
const initPromise = apiClient.threads
.delete(safeThreadId)
.catch(() => undefined)
.then(() =>
apiClient.threads.create({
threadId: safeThreadId,
ifExists: "do_nothing",
}),
)
.then(() => {
setIsThreadInitReady(true);
})
.catch(() => {
initializedThreadRef.current = null;
setIsThreadInitReady(false);
toast.error(t.chatPage.createSessionFailed);
});
threadInitPromiseRef.current = initPromise;
void initPromise.finally(() => {
if (threadInitPromiseRef.current === initPromise) {
threadInitPromiseRef.current = null;
}
});
}, [
apiClient,
isNewThread,
@ -291,7 +308,7 @@ export default function ChatPage() {
const [showExitDialog, setShowExitDialog] = useState(false);
const isStreaming = isUploading || thread.isLoading;
const handleSubmit = useCallback(
(message: Parameters<typeof sendMessage>[1]) => {
async (message: Parameters<typeof sendMessage>[1]) => {
if (isSelectedSkillBootstrapping) {
return;
}
@ -299,6 +316,12 @@ export default function ChatPage() {
toast.error(t.chatPage.missingThreadIdForSend);
return;
}
if (isNewThread && safeThreadId) {
await threadInitPromiseRef.current;
}
if (isNewThread && safeThreadId && !isThreadInitReady) {
return;
}
setHasSubmitted(true);
if (safeThreadId && (isNewThread || showWelcomeStyle)) {
router.replace(`/workspace/chats/${safeThreadId}?is_chatting=true`);
@ -307,6 +330,7 @@ export default function ChatPage() {
},
[
isNewThread,
isThreadInitReady,
isSelectedSkillBootstrapping,
router,
safeThreadId,
@ -633,14 +657,14 @@ export default function ChatPage() {
type: POST_MESSAGE_TYPES.IS_CHATTING,
isChatting: false,
});
resetNewSessionState();
// 始终复用 query 中的 thread_id。
const nextQuery = new URLSearchParams();
if (threadId && threadId !== "new") {
nextQuery.set("thread_id", threadId);
}
// /workspace/chats/${threadId}?is_chatting=false
router.replace(
`/workspace/chats/${threadId}?is_chatting=false`,
`/workspace/chats/new?thread_id=${threadId}`,
);
}}
>

View File

@ -970,6 +970,14 @@ export function InputBox({
/>
</div>
)}
{!showWelcomeStyle && (
<div className="shrink-0 h-full">
<ExitChattingButton
router={router}
threadId={threadIdFromProps}
/>
</div>
)}
<div ref={attachmentsButtonTourRef} className="shrink-0 h-full">
<AddAttachmentsButton />
</div>
@ -1292,6 +1300,53 @@ function HistoryButton({
</Tooltip>
);
}
function ExitChattingButton({
className,
router,
threadId,
}: {
className?: string;
router: AppRouterInstance;
threadId: string;
}) {
const { t } = useI18n();
return (
<Tooltip content={t.inputBox.welcome}>
<WorkspaceToolButton
className={cn(
"text-ws-base-1 hover:text-ws-interactive-primary",
className,
)}
onClick={() =>
router.replace(`/workspace/chats/${threadId}?is_chatting=false`)
}
>
<svg
className="transition-[color] duration-200"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
className="stroke-current transition-[stroke] duration-200"
cx="9"
cy="9"
r="8.5"
/>
<path
className="stroke-current transition-[stroke] duration-200"
d="M6 9H12"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</WorkspaceToolButton>
</Tooltip>
);
}
// 启动iframeSkillDialog
function IframeSkillDialogButton({
className,

View File

@ -39,7 +39,6 @@ import {
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { materializeSkillYaml } from "@/core/skills";
import { humanMessagePlugins } from "@/core/streamdown";
import { dispatchMentionReference } from "@/core/threads/reference-events";
import { cn } from "@/lib/utils";
@ -225,13 +224,9 @@ function MessageContent_({
if (isHuman) {
const shouldRenderSummaryCollapse = isSummaryMessage && summaryBody;
const messageResponse = contentToDisplay ? (
<AIElementMessageResponse
remarkPlugins={humanMessagePlugins.remarkPlugins}
rehypePlugins={humanMessagePlugins.rehypePlugins}
components={components}
>
<div className="whitespace-break-spaces break-words">
{contentToDisplay}
</AIElementMessageResponse>
</div>
) : null;
return (
<div className={cn("ml-auto flex flex-col gap-2", className)}>
@ -250,13 +245,9 @@ function MessageContent_({
: t.toolCalls.expandContent}
</summary>
<AIElementMessageContent className="w-fit border-t">
<AIElementMessageResponse
remarkPlugins={humanMessagePlugins.remarkPlugins}
rehypePlugins={humanMessagePlugins.rehypePlugins}
components={components}
>
<div className="whitespace-break-spaces break-words">
{summaryBody}
</AIElementMessageResponse>
</div>
</AIElementMessageContent>
</details>
)}

View File

@ -32,7 +32,6 @@ export function useReferenceFiles(threadId: string | undefined) {
queryKey: ["references", "list", threadId],
queryFn: () => listReferenceFiles(threadId ?? ""),
enabled: Boolean(threadId),
refetchInterval: 5000,
refetchOnWindowFocus: true,
refetchOnWindowFocus: false,
});
}

View File

@ -86,6 +86,7 @@ export const enUS: Translations = {
"Please note, this feature will consume tokens. Ensure your account balance is greater than 200 credits.",
addAttachments: "Add attachments",
history: "History",
welcome:"Welcome",
selectSkill: "Select Skill",
mode: "Mode",
flashMode: "Flash",

View File

@ -75,6 +75,7 @@ export interface Translations {
createSkillPrompt: string;
addAttachments: string;
history: string;
welcome:string;
selectSkill: string;
mode: string;
flashMode: string;

View File

@ -87,6 +87,7 @@ export const zhCN: Translations = {
"请注意此功能将消耗token请保证账户余额大于200可学豆。",
addAttachments: "添加附件",
history: "历史记录",
welcome:"欢迎页",
selectSkill: "选择Skill",
mode: "模式",
flashMode: "闪速",
@ -262,7 +263,7 @@ export const zhCN: Translations = {
noArtifactSelectedDescription: "请选择一个生成文件以查看详情",
exitDialogTitle: "提示",
exitDialogDescription:
"历史记录每七天自动删除,现在将返回欢迎页,是否继续?",
"每七天自动删除。现在将返回欢迎页且清空聊天消息,是否继续?",
exitDialogConfirm: "确定",
selectedSkillLoadFailed: "技能加载失败",
unknownErrorRetry: "发生了未知错误,请稍后重试。",

View File

@ -414,12 +414,9 @@ export function stripPriorityHintSuffix(content: string): string {
* - Split Chinese-numbered items (e.g. "1...") into separate paragraphs.
*/
export function normalizeHumanMessageDisplayText(content: string): string {
return content
.replace(/\\n/g, "\n")
.replace(/\r\n?/g, "\n")
.replace(/\n(?=\d+[)]\s*)/g, "\n\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
// Preserve human input as-is for display; only decode escaped newlines
// and normalize CRLF/CR to LF so line breaks render consistently.
return content.replace(/\\n/g, "\n").replace(/\r\n?/g, "\n");
}
export function parseUploadedFiles(content: string): FileInMessage[] {