bug修改
This commit is contained in:
1 parent
910cee9e67
commit
704438b3d2
6 files changed
+278
-36
No files matched your search
@@ -76,6 +76,7 @@ import {
|
|||||||
useArtifactContent,
|
useArtifactContent,
|
||||||
useUpdateArtifactContent,
|
useUpdateArtifactContent,
|
||||||
} from "@/core/artifacts/hooks";
|
} from "@/core/artifacts/hooks";
|
||||||
|
import { isWriteFileInFlight } from "@/core/artifacts/write-file-in-flight";
|
||||||
import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
|
import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
|
||||||
import {
|
import {
|
||||||
isSandboxStreamPerfEnabled,
|
isSandboxStreamPerfEnabled,
|
||||||
@@ -308,19 +309,33 @@ export function ArtifactFileDetail({
|
|||||||
const isSpreadsheetFile = useMemo(() => {
|
const isSpreadsheetFile = useMemo(() => {
|
||||||
return /\.xlsx$/i.test(filepath);
|
return /\.xlsx$/i.test(filepath);
|
||||||
}, [filepath]);
|
}, [filepath]);
|
||||||
// 圆桌沙箱:write_file 虚拟预览的 md 在流式结束(thread 非 loading)后,切回真实
|
// 仅当「当前这个文件」的 write_file 还没返回 tool result 时才算在写。
|
||||||
// artifacts API 加载并允许编辑/保存。流式进行中仍走内存 stub 只读预览;仅
|
// 不能用 thread.isLoading:用户协助后的下一轮问答也会 loading,否则会把已写完的
|
||||||
// allowVirtualMarkdownEdit 开启的调用方(Step2/Step3 沙箱)生效,主聊天页不受影响。
|
// md 从 CodeMirror 拽回巨大 textarea,主线程卡住、聊天变成「发送中然后一口气出来」。
|
||||||
|
const writeFileInFlight = useMemo(() => {
|
||||||
|
if (!isWriteFileVirtual || isImageFile(filepath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isWriteFileInFlight(thread, filepathFromProps);
|
||||||
|
}, [filepath, filepathFromProps, isWriteFileVirtual, thread.messages]);
|
||||||
|
// 圆桌/主聊天沙箱:write_file 虚拟预览的 md 在该文件写完后切回真实 artifacts API
|
||||||
|
// (可加载 + PATCH 保存)。后续问答轮次的 loading 不再把预览拽回去。
|
||||||
const editVirtualMarkdown = useMemo(() => {
|
const editVirtualMarkdown = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
allowVirtualMarkdownEdit &&
|
allowVirtualMarkdownEdit &&
|
||||||
isWriteFileVirtual &&
|
isWriteFileVirtual &&
|
||||||
!isImageFile(filepath) &&
|
!isImageFile(filepath) &&
|
||||||
!isMock &&
|
!isMock &&
|
||||||
!thread.isLoading &&
|
!writeFileInFlight &&
|
||||||
checkCodeFile(filepath).language === "markdown"
|
checkCodeFile(filepath).language === "markdown"
|
||||||
);
|
);
|
||||||
}, [allowVirtualMarkdownEdit, isWriteFileVirtual, filepath, isMock, thread.isLoading]);
|
}, [
|
||||||
|
allowVirtualMarkdownEdit,
|
||||||
|
isWriteFileVirtual,
|
||||||
|
filepath,
|
||||||
|
isMock,
|
||||||
|
writeFileInFlight,
|
||||||
|
]);
|
||||||
/** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API;
|
/** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API;
|
||||||
* 圆桌结束后可编辑的 md(editVirtualMarkdown)亦回到真实 API(可加载 + PATCH 保存)。 */
|
* 圆桌结束后可编辑的 md(editVirtualMarkdown)亦回到真实 API(可加载 + PATCH 保存)。 */
|
||||||
const useWriteFileStreaming = useMemo(() => {
|
const useWriteFileStreaming = useMemo(() => {
|
||||||
@@ -377,7 +392,7 @@ export function ArtifactFileDetail({
|
|||||||
language === "markdown" &&
|
language === "markdown" &&
|
||||||
!isSkillFile &&
|
!isSkillFile &&
|
||||||
!isMock &&
|
!isMock &&
|
||||||
!thread.isLoading &&
|
!writeFileInFlight &&
|
||||||
(canEditArtifact || useWriteFileStreaming);
|
(canEditArtifact || useWriteFileStreaming);
|
||||||
const [draftContent, setDraftContent] = useState("");
|
const [draftContent, setDraftContent] = useState("");
|
||||||
const [hasLocalEdits, setHasLocalEdits] = useState(false);
|
const [hasLocalEdits, setHasLocalEdits] = useState(false);
|
||||||
@@ -406,7 +421,7 @@ export function ArtifactFileDetail({
|
|||||||
? draftContent
|
? draftContent
|
||||||
: (content ?? ""));
|
: (content ?? ""));
|
||||||
const streamPerfEnabled = isSandboxStreamPerfEnabled();
|
const streamPerfEnabled = isSandboxStreamPerfEnabled();
|
||||||
const isLiveStreaming = thread.isLoading || documentRewriteActive;
|
const isLiveStreaming = writeFileInFlight || documentRewriteActive;
|
||||||
const sandboxDisplayText = useMemo(() => {
|
const sandboxDisplayText = useMemo(() => {
|
||||||
const raw = displayContent ?? "";
|
const raw = displayContent ?? "";
|
||||||
if (disableDesensitize) return raw;
|
if (disableDesensitize) return raw;
|
||||||
@@ -426,7 +441,7 @@ export function ArtifactFileDetail({
|
|||||||
const isAutoPreviewLanguage =
|
const isAutoPreviewLanguage =
|
||||||
language === "html" || language === "markdown" || language === "mermaid";
|
language === "html" || language === "markdown" || language === "mermaid";
|
||||||
const isPreviewStreaming =
|
const isPreviewStreaming =
|
||||||
isAutoPreviewLanguage && (thread.isLoading || documentRewriteActive);
|
isAutoPreviewLanguage && (writeFileInFlight || documentRewriteActive);
|
||||||
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
||||||
const fileTypeLabel = useMemo(() => {
|
const fileTypeLabel = useMemo(() => {
|
||||||
if (isImageFile(filepath)) return "IMG";
|
if (isImageFile(filepath)) return "IMG";
|
||||||
@@ -889,7 +904,7 @@ export function ArtifactFileDetail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (language === "html") {
|
if (language === "html") {
|
||||||
setViewMode(thread.isLoading ? "code" : "preview");
|
setViewMode(writeFileInFlight || documentRewriteActive ? "code" : "preview");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -899,14 +914,20 @@ export function ArtifactFileDetail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setViewMode("code");
|
setViewMode("code");
|
||||||
}, [isPreviewStreaming, isSupportPreview, language, thread.isLoading]);
|
}, [
|
||||||
|
documentRewriteActive,
|
||||||
|
isPreviewStreaming,
|
||||||
|
isSupportPreview,
|
||||||
|
language,
|
||||||
|
writeFileInFlight,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!allowAutoViewSwitch || !isAutoPreviewLanguage) {
|
if (!allowAutoViewSwitch || !isAutoPreviewLanguage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (thread.isLoading) {
|
if (writeFileInFlight || documentRewriteActive) {
|
||||||
setViewMode("code");
|
setViewMode("code");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -918,7 +939,13 @@ export function ArtifactFileDetail({
|
|||||||
}, 1400);
|
}, 1400);
|
||||||
|
|
||||||
return () => window.clearTimeout(timeoutId);
|
return () => window.clearTimeout(timeoutId);
|
||||||
}, [allowAutoViewSwitch, isAutoPreviewLanguage, isSupportPreview, thread.isLoading]);
|
}, [
|
||||||
|
allowAutoViewSwitch,
|
||||||
|
documentRewriteActive,
|
||||||
|
isAutoPreviewLanguage,
|
||||||
|
isSupportPreview,
|
||||||
|
writeFileInFlight,
|
||||||
|
]);
|
||||||
|
|
||||||
const scrollArtifactToBottom = useCallback(
|
const scrollArtifactToBottom = useCallback(
|
||||||
(behavior: ScrollBehavior = "auto") => {
|
(behavior: ScrollBehavior = "auto") => {
|
||||||
@@ -1015,13 +1042,13 @@ export function ArtifactFileDetail({
|
|||||||
}, [filepath, threadId, isMock, isCodeFile, isSpreadsheetFile, isWriteFileVirtual, publicShareCode]);
|
}, [filepath, threadId, isMock, isCodeFile, isSpreadsheetFile, isWriteFileVirtual, publicShareCode]);
|
||||||
|
|
||||||
const handlePauseAutoScroll = useCallback(() => {
|
const handlePauseAutoScroll = useCallback(() => {
|
||||||
if (!thread.isLoading || !autoScrollEnabled) {
|
if (!isLiveStreaming || !autoScrollEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setAutoScrollEnabled(false);
|
setAutoScrollEnabled(false);
|
||||||
setShowResumeAutoScroll(true);
|
setShowResumeAutoScroll(true);
|
||||||
}, [autoScrollEnabled, thread.isLoading]);
|
}, [autoScrollEnabled, isLiveStreaming]);
|
||||||
|
|
||||||
const handleResumeAutoScroll = useCallback(() => {
|
const handleResumeAutoScroll = useCallback(() => {
|
||||||
setAutoScrollEnabled(true);
|
setAutoScrollEnabled(true);
|
||||||
@@ -1774,7 +1801,7 @@ export function ArtifactFileDetail({
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
isStreaming={thread.isLoading}
|
isStreaming={writeFileInFlight || documentRewriteActive}
|
||||||
streamingReadonlyFallback={!canEditArtifact}
|
streamingReadonlyFallback={!canEditArtifact}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
|
|
||||||
import { useThread } from "@/components/workspace/messages/context";
|
import { useThread } from "@/components/workspace/messages/context";
|
||||||
|
|
||||||
import { isImageFile } from "../utils/files";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
loadArtifactContent,
|
loadArtifactContent,
|
||||||
loadArtifactContentFromLatestWriteFile,
|
loadArtifactContentFromLatestWriteFile,
|
||||||
@@ -15,22 +13,13 @@ import {
|
|||||||
isSandboxStreamPerfEnabled,
|
isSandboxStreamPerfEnabled,
|
||||||
} from "./sandbox-stream-perf";
|
} from "./sandbox-stream-perf";
|
||||||
import { isArtifactApiPath } from "./streaming-write-preview";
|
import { isArtifactApiPath } from "./streaming-write-preview";
|
||||||
|
import { isWriteFileInFlight, isWriteFileStreamingUrl } from "./write-file-in-flight";
|
||||||
import { updateArtifactContent } from "./api";
|
import { updateArtifactContent } from "./api";
|
||||||
|
|
||||||
function isWriteFileStreamingUrl(filepath: string): boolean {
|
function hasToolResult(
|
||||||
if (!filepath.startsWith("write-file:")) {
|
thread: ReturnType<typeof useThread>["thread"],
|
||||||
return false;
|
toolCallId?: string,
|
||||||
}
|
) {
|
||||||
try {
|
|
||||||
const url = new URL(filepath);
|
|
||||||
const path = decodeURIComponent(url.pathname);
|
|
||||||
return !isImageFile(path);
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasToolResult(thread: ReturnType<typeof useThread>["thread"], toolCallId?: string) {
|
|
||||||
if (!toolCallId) {
|
if (!toolCallId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -144,11 +133,13 @@ export function useArtifactContent({
|
|||||||
const liveWriteFileContent = useWriteFileStreaming
|
const liveWriteFileContent = useWriteFileStreaming
|
||||||
? loadArtifactContentFromToolCall({ url: filepath, thread })
|
? loadArtifactContentFromToolCall({ url: filepath, thread })
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const writeFileInFlight =
|
||||||
|
useWriteFileStreaming && isWriteFileInFlight(thread, filepath);
|
||||||
const displayedWriteFileContent = useThrottledWriteFileDisplay({
|
const displayedWriteFileContent = useThrottledWriteFileDisplay({
|
||||||
filepath,
|
filepath,
|
||||||
liveContent: liveWriteFileContent,
|
liveContent: liveWriteFileContent,
|
||||||
enabled: streamPerfEnabled && useWriteFileStreaming,
|
enabled: streamPerfEnabled && useWriteFileStreaming,
|
||||||
streaming: thread.isLoading,
|
streaming: writeFileInFlight,
|
||||||
});
|
});
|
||||||
const content = useWriteFileStreaming ? displayedWriteFileContent : null;
|
const content = useWriteFileStreaming ? displayedWriteFileContent : null;
|
||||||
const toolCallFallbackContent = useMemo(() => {
|
const toolCallFallbackContent = useMemo(() => {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { isWriteFileInFlight, resolveWriteFilePath } from "./write-file-in-flight";
|
||||||
|
|
||||||
|
test("resolveWriteFilePath reads the virtual URL pathname", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWriteFilePath("write-file:report.md?message_id=m1&tool_call_id=t1"),
|
||||||
|
"report.md",
|
||||||
|
);
|
||||||
|
assert.equal(resolveWriteFilePath("mnt/report.md"), "mnt/report.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isWriteFileInFlight is true only until that file's tool result arrives", () => {
|
||||||
|
const inFlight = {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: "ai",
|
||||||
|
tool_calls: [
|
||||||
|
{ id: "w1", name: "write_file", args: { path: "report.md" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.equal(isWriteFileInFlight(inFlight, "write-file:report.md?tool_call_id=w1"), true);
|
||||||
|
|
||||||
|
const completed = {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: "ai",
|
||||||
|
tool_calls: [
|
||||||
|
{ id: "w1", name: "write_file", args: { path: "report.md" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: "tool", tool_call_id: "w1", name: "write_file" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
isWriteFileInFlight(completed, "write-file:report.md?tool_call_id=w1"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a later thread turn does not treat a finished write as in-flight", () => {
|
||||||
|
const laterTurn = {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: "ai",
|
||||||
|
tool_calls: [
|
||||||
|
{ id: "w1", name: "write_file", args: { path: "report.md" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: "tool", tool_call_id: "w1", name: "write_file" },
|
||||||
|
{ type: "human", content: "按方案 A" },
|
||||||
|
{
|
||||||
|
type: "ai",
|
||||||
|
tool_calls: [{ id: "c2", name: "web_search", args: { query: "x" } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
isWriteFileInFlight(laterTurn, "write-file:report.md?tool_call_id=w1"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { isImageFile } from "../utils/files";
|
||||||
|
|
||||||
|
export function isWriteFileStreamingUrl(filepath: string): boolean {
|
||||||
|
if (!filepath.startsWith("write-file:")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = new URL(filepath);
|
||||||
|
const path = decodeURIComponent(url.pathname);
|
||||||
|
return !isImageFile(path);
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWriteFilePath(filepath: string): string {
|
||||||
|
if (filepath.startsWith("write-file:")) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(new URL(filepath).pathname).trim();
|
||||||
|
} catch {
|
||||||
|
const rest = filepath.slice("write-file:".length);
|
||||||
|
return rest.split(/[?#]/, 1)[0]?.trim() ?? filepath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
type WriteFileThreadLike = {
|
||||||
|
messages: Array<{
|
||||||
|
type?: string;
|
||||||
|
tool_call_id?: string;
|
||||||
|
tool_calls?: Array<{
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
args?: unknown;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function hasToolResult(thread: WriteFileThreadLike, toolCallId?: string) {
|
||||||
|
if (!toolCallId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return thread.messages.some(
|
||||||
|
(message) => message.type === "tool" && message.tool_call_id === toolCallId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True only while THIS file's write_file call has not yet returned a tool result. */
|
||||||
|
export function isWriteFileInFlight(
|
||||||
|
thread: WriteFileThreadLike,
|
||||||
|
filepath: string,
|
||||||
|
): boolean {
|
||||||
|
const path = resolveWriteFilePath(filepath);
|
||||||
|
if (!path) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const message of thread.messages) {
|
||||||
|
if (message.type !== "ai" || !message.tool_calls) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const toolCall of message.tool_calls) {
|
||||||
|
if (toolCall.name !== "write_file") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const args = toolCall.args as { path?: unknown } | undefined;
|
||||||
|
const callPath = typeof args?.path === "string" ? args.path.trim() : "";
|
||||||
|
if (callPath !== path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!hasToolResult(thread, toolCall.id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
|
|
||||||
|
import { groupMessages } from "./utils";
|
||||||
|
|
||||||
|
function groupTypes(messages: Message[]) {
|
||||||
|
const types: string[] = [];
|
||||||
|
groupMessages(messages, (group) => {
|
||||||
|
types.push(group.type);
|
||||||
|
return group.type;
|
||||||
|
});
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("write_file tool after 用户协助 opens a processing group instead of erroring", () => {
|
||||||
|
const types = groupTypes([
|
||||||
|
{ type: "human", id: "h1", content: "写一份报告" } as Message,
|
||||||
|
{
|
||||||
|
type: "ai",
|
||||||
|
id: "a1",
|
||||||
|
content: "",
|
||||||
|
tool_calls: [{ id: "c1", name: "ask_clarification", args: {} }],
|
||||||
|
} as Message,
|
||||||
|
{
|
||||||
|
type: "tool",
|
||||||
|
id: "t1",
|
||||||
|
name: "ask_clarification",
|
||||||
|
content: "请选择范围",
|
||||||
|
tool_call_id: "c1",
|
||||||
|
} as Message,
|
||||||
|
{ type: "human", id: "h2", content: "按方案 A" } as Message,
|
||||||
|
{
|
||||||
|
type: "tool",
|
||||||
|
id: "t2",
|
||||||
|
name: "write_file",
|
||||||
|
content: "OK",
|
||||||
|
tool_call_id: "w1",
|
||||||
|
} as Message,
|
||||||
|
{
|
||||||
|
type: "tool",
|
||||||
|
id: "t3",
|
||||||
|
name: "write_file",
|
||||||
|
content: "OK",
|
||||||
|
tool_call_id: "w2",
|
||||||
|
} as Message,
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(types, [
|
||||||
|
"human",
|
||||||
|
"assistant:processing",
|
||||||
|
"assistant:clarification",
|
||||||
|
"human",
|
||||||
|
"assistant:processing",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tool messages still attach to an open processing group", () => {
|
||||||
|
const types = groupTypes([
|
||||||
|
{
|
||||||
|
type: "ai",
|
||||||
|
id: "a1",
|
||||||
|
content: "",
|
||||||
|
tool_calls: [{ id: "w1", name: "write_file", args: { path: "a.md" } }],
|
||||||
|
} as Message,
|
||||||
|
{
|
||||||
|
type: "tool",
|
||||||
|
id: "t1",
|
||||||
|
name: "write_file",
|
||||||
|
content: "OK",
|
||||||
|
tool_call_id: "w1",
|
||||||
|
} as Message,
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(types, ["assistant:processing"]);
|
||||||
|
});
|
||||||
@@ -119,10 +119,15 @@ export function groupMessages<T>(
|
|||||||
if (open) {
|
if (open) {
|
||||||
open.messages.push(message);
|
open.messages.push(message);
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
// After 用户协助 the last group is human/clarification, so later
|
||||||
"Unexpected tool message outside a processing group",
|
// write_file results have nowhere to attach. Opening a processing
|
||||||
message,
|
// group keeps them renderable and avoids console.error(full message)
|
||||||
);
|
// on every MessageList render (that froze the SSE thread).
|
||||||
|
groups.push({
|
||||||
|
id: message.id,
|
||||||
|
type: "assistant:processing",
|
||||||
|
messages: [message],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
Reference in new issue
Block a user