feat: add canvas Clawd mode

This commit is contained in:
134 2026-06-23 20:23:19 +08:00
parent a752737327
commit d9bb014742
6 changed files with 747 additions and 18 deletions

View File

@ -98,6 +98,11 @@ import {
getCanvasSkillIconComponent,
} from "@/components/canvas/canvas-skill-icons";
import { HomeAnimationEmbed } from "@/components/home/home-animation-embed";
import {
DEFAULT_OPENCLAW_CHAT_BASE_URL,
OPENCLAW_CHAT_CONFIG_KEY,
type OpenClawChatConfig,
} from "@/lib/openclaw-chat/config";
import { Button } from "@/components/ui/button";
import {
Card,
@ -1839,6 +1844,11 @@ export function AdminSettingsClient() {
`/api/admin/settings?key=${HOME_CHAT_ANIMATION_CONFIG_KEY}`,
fetcher
);
const { data: openClawChatSettingData, mutate: mutateOpenClawChatSetting } =
useSWR<{ setting?: { value?: unknown } }>(
`/api/admin/settings?key=${OPENCLAW_CHAT_CONFIG_KEY}`,
fetcher
);
// 🆕 生图记录数据
const imageQueryParams = new URLSearchParams({
@ -2145,6 +2155,15 @@ export function AdminSettingsClient() {
htmlContent: "",
updatedAt: null,
});
const [openClawChatConfig, setOpenClawChatConfig] =
useState<OpenClawChatConfig>({
enabled: false,
baseUrl: DEFAULT_OPENCLAW_CHAT_BASE_URL,
token: "",
timeoutMs: 180_000,
});
const [openClawChatTokenConfigured, setOpenClawChatTokenConfigured] =
useState(false);
const lastLoadedSiteSettingsRef = useRef<SiteSettings | null>(null);
const lastLoadedHomeAnimationConfigRef = useRef<HomeAnimationConfig | null>(
null
@ -2416,6 +2435,29 @@ export function AdminSettingsClient() {
lastLoadedHomeAnimationConfigRef.current = nextHomeAnimationConfig;
}, [homeAnimationSettingData]);
useEffect(() => {
const raw =
openClawChatSettingData?.setting?.value &&
typeof openClawChatSettingData.setting.value === "object" &&
!Array.isArray(openClawChatSettingData.setting.value)
? (openClawChatSettingData.setting.value as Record<string, unknown>)
: {};
setOpenClawChatConfig({
enabled: raw.enabled === true,
baseUrl:
String(raw.baseUrl || "").trim() || DEFAULT_OPENCLAW_CHAT_BASE_URL,
token: "",
timeoutMs: Math.min(
300_000,
Math.max(5_000, Math.round(Number(raw.timeoutMs) || 180_000))
),
});
setOpenClawChatTokenConfigured(
raw.hasToken === true || Boolean(String(raw.token || "").trim())
);
}, [openClawChatSettingData]);
useEffect(() => {
const normalized = normalizeDetailAgentLocalMoonshotConfig(
moonshotDetailAgentSettingData?.setting?.value
@ -4242,6 +4284,68 @@ export function AdminSettingsClient() {
}
};
const handleSaveOpenClawChatConfig = async () => {
try {
const normalizedConfig: OpenClawChatConfig = {
enabled: openClawChatConfig.enabled,
baseUrl:
openClawChatConfig.baseUrl.trim() || DEFAULT_OPENCLAW_CHAT_BASE_URL,
token: openClawChatConfig.token.trim(),
timeoutMs: Math.min(
300_000,
Math.max(5_000, Math.round(Number(openClawChatConfig.timeoutMs) || 180_000))
),
};
if (normalizedConfig.enabled) {
if (!normalizedConfig.baseUrl) {
toast.error("请填写大龙虾聊天接口地址");
return;
}
if (!normalizedConfig.token && !openClawChatTokenConfigured) {
toast.error("请填写大龙虾聊天接口 Token");
return;
}
}
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: OPENCLAW_CHAT_CONFIG_KEY,
value: normalizedConfig,
}),
});
const responseData = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(responseData?.message || responseData?.error || "保存失败");
}
const savedValue =
responseData?.setting?.value &&
typeof responseData.setting.value === "object"
? (responseData.setting.value as OpenClawChatConfig)
: normalizedConfig;
setOpenClawChatConfig({
enabled: savedValue.enabled,
baseUrl: savedValue.baseUrl,
token: "",
timeoutMs: savedValue.timeoutMs,
});
setOpenClawChatTokenConfigured(
Boolean((responseData?.setting?.value as any)?.hasToken) ||
Boolean(normalizedConfig.token)
);
toast.success("大龙虾聊天接口配置已保存");
mutateOpenClawChatSetting();
mutateSiteSettings();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "保存大龙虾聊天接口配置失败"
);
}
};
// --- 网站设置逻辑 ---
const handleSaveSiteSettings = async () => {
try {
@ -12068,6 +12172,96 @@ export function AdminSettingsClient() {
/>
</div>
<div className="space-y-4 rounded-lg border p-4">
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label htmlFor="openClawChatEnabled">
无限画布 Clawd模式
</Label>
<div className="text-muted-foreground text-xs">
开启并配置接口后,右侧对话框模式菜单显示
Clawd模式;Token 仅保存在服务端,不会下发到前端。
</div>
</div>
<Switch
checked={openClawChatConfig.enabled}
id="openClawChatEnabled"
onCheckedChange={(checked) =>
setOpenClawChatConfig((prev) => ({
...prev,
enabled: checked,
}))
}
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="openClawChatBaseUrl">
接口 Base URL
</Label>
<Input
id="openClawChatBaseUrl"
onChange={(event) =>
setOpenClawChatConfig((prev) => ({
...prev,
baseUrl: event.target.value,
}))
}
placeholder={DEFAULT_OPENCLAW_CHAT_BASE_URL}
value={openClawChatConfig.baseUrl}
/>
</div>
<div className="space-y-2">
<Label htmlFor="openClawChatTimeoutMs">
请求超时(毫秒)
</Label>
<Input
id="openClawChatTimeoutMs"
min={5000}
max={300000}
onChange={(event) =>
setOpenClawChatConfig((prev) => ({
...prev,
timeoutMs: Number(event.target.value) || 180000,
}))
}
type="number"
value={openClawChatConfig.timeoutMs}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="openClawChatToken">
服务端 Bearer Token
</Label>
<Input
id="openClawChatToken"
onChange={(event) =>
setOpenClawChatConfig((prev) => ({
...prev,
token: event.target.value,
}))
}
placeholder={
openClawChatTokenConfigured
? "已配置,留空则保持不变"
: "只保存在服务端,禁止下发到浏览器"
}
type="password"
value={openClawChatConfig.token}
/>
</div>
<div className="flex justify-end">
<Button
onClick={handleSaveOpenClawChatConfig}
type="button"
variant="outline"
>
保存大龙虾聊天接口
</Button>
</div>
</div>
{siteSettings.agentEnabled && (
<div className="space-y-4 rounded-lg border p-4">
<div>

View File

@ -71,6 +71,11 @@ import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
normalizeAgentWorkflowMaterialApiConfig,
} from "@/lib/agents/workflow-material-config";
import {
normalizeOpenClawChatConfig,
OPENCLAW_CHAT_CONFIG_KEY,
toAdminOpenClawChatConfig,
} from "@/lib/openclaw-chat/config";
import { clearScopedSiteSettingsCache } from "@/lib/site-settings/public-cache";
const settingSchema = z.object({
@ -149,6 +154,12 @@ export async function GET(request: Request) {
setting: setting ? { ...setting, value } : { key, value },
});
}
if (key === OPENCLAW_CHAT_CONFIG_KEY) {
const value = toAdminOpenClawChatConfig(setting?.value);
return Response.json({
setting: setting ? { ...setting, value } : { key, value },
});
}
return Response.json({ setting });
}
@ -301,6 +312,45 @@ export async function POST(request: Request) {
});
}
if (key === OPENCLAW_CHAT_CONFIG_KEY) {
const existingSetting = await getSystemSetting({
key: OPENCLAW_CHAT_CONFIG_KEY,
}).catch(() => null);
const existingValue =
existingSetting?.value && typeof existingSetting.value === "object"
? (existingSetting.value as Record<string, unknown>)
: {};
const normalizedValue = normalizeOpenClawChatConfig(
value,
String(existingValue.token || "").trim()
);
if (normalizedValue.enabled) {
if (!normalizedValue.baseUrl) {
return new ChatSDKError("bad_request:api", "请填写大龙虾聊天接口地址").toResponse();
}
if (!normalizedValue.token) {
return new ChatSDKError("bad_request:api", "请填写大龙虾聊天接口 Token").toResponse();
}
}
const saved = await upsertSystemSetting({
key: OPENCLAW_CHAT_CONFIG_KEY,
value: normalizedValue,
updatedBy: session.user.id,
});
clearScopedSiteSettingsCache();
return Response.json({
setting: saved[0]
? {
...saved[0],
value: toAdminOpenClawChatConfig(saved[0].value),
}
: undefined,
});
}
if (key === VIDEO_CANVAS_FUNCTION_CONFIG_KEY) {
nextValue = normalizeVideoCanvasFunctionConfig(value);
}

View File

@ -0,0 +1,212 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import {
getChatById,
getSystemSetting,
saveChat,
saveMessages,
} from "@/lib/db/queries";
import {
normalizeOpenClawChatConfig,
OPENCLAW_CHAT_CONFIG_KEY,
} from "@/lib/openclaw-chat/config";
import { generateUUID } from "@/lib/utils";
const requestSchema = z.object({
id: z.string().uuid(),
message: z.string().trim().min(1).max(8000),
userMessageId: z.string().uuid().optional(),
metadata: z.record(z.unknown()).optional(),
});
function buildChatTitle(message: string) {
const normalized = message.replace(/\s+/g, " ").trim();
if (!normalized) return "Clawd 对话";
return normalized.length > 80 ? `${normalized.slice(0, 80)}...` : normalized;
}
function createConversationId(userId: string, chatId: string) {
return `site-user-${userId}-canvas-${chatId}`;
}
async function readOpenClawConfig() {
const setting = await getSystemSetting({ key: OPENCLAW_CHAT_CONFIG_KEY }).catch(
() => null
);
return normalizeOpenClawChatConfig(setting?.value);
}
function getOpenClawErrorMessage(payload: unknown, fallback: string) {
if (payload && typeof payload === "object") {
const raw = payload as Record<string, unknown>;
const nestedError =
raw.error && typeof raw.error === "object"
? (raw.error as Record<string, unknown>)
: null;
const message = nestedError?.message || raw.message || raw.error;
if (typeof message === "string" && message.trim()) {
return message.trim();
}
}
return fallback;
}
export async function POST(request: Request) {
const session = await auth().catch(() => null);
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const parsed = requestSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
}
const config = await readOpenClawConfig();
if (!config.enabled) {
return NextResponse.json({ error: "Clawd 模式未启用" }, { status: 403 });
}
if (!config.baseUrl || !config.token) {
return NextResponse.json(
{ error: "Clawd 接口地址或 Token 未配置" },
{ status: 500 }
);
}
const existingChat = await getChatById({ id: parsed.data.id }).catch(() => null);
if (existingChat && existingChat.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (!existingChat) {
await saveChat({
id: parsed.data.id,
userId: session.user.id,
title: buildChatTitle(parsed.data.message),
visibility: "private",
});
}
const userMessageId = parsed.data.userMessageId || generateUUID();
const assistantMessageId = generateUUID();
const now = new Date();
const conversationId = createConversationId(session.user.id, parsed.data.id);
const endpoint = `${config.baseUrl}/api/chat`;
let upstreamPayload: unknown = null;
let upstreamStatus = 502;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
conversation_id: conversationId,
user_id: session.user.id,
user_name: session.user.name || session.user.email || "",
message: parsed.data.message,
metadata: {
source: "ai_chatbot_canvas",
page: "/canvas",
tenant: "default",
chat_id: parsed.data.id,
...parsed.data.metadata,
},
}),
signal: controller.signal,
}).finally(() => clearTimeout(timeout));
upstreamStatus = response.status;
upstreamPayload = await response.json().catch(() => null);
if (!response.ok) {
return NextResponse.json(
{
error: getOpenClawErrorMessage(upstreamPayload, "大龙虾聊天接口调用失败"),
},
{ status: response.status }
);
}
} catch (error) {
const isAbortError =
error instanceof Error && error.name === "AbortError";
return NextResponse.json(
{
error: isAbortError
? "大龙虾聊天接口请求超时"
: "大龙虾聊天接口暂时不可用",
},
{ status: isAbortError ? 504 : upstreamStatus }
);
}
const answer =
upstreamPayload && typeof upstreamPayload === "object"
? String((upstreamPayload as Record<string, unknown>).answer || "").trim()
: "";
if (!answer) {
return NextResponse.json(
{ error: "大龙虾聊天接口未返回有效回答" },
{ status: 502 }
);
}
const userMessage = {
id: userMessageId,
chatId: parsed.data.id,
role: "user",
parts: [{ type: "text", text: parsed.data.message }],
attachments: [],
createdAt: now,
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
};
const assistantMessage = {
id: assistantMessageId,
chatId: parsed.data.id,
role: "assistant",
parts: [{ type: "text", text: answer }],
attachments: [],
createdAt: new Date(now.getTime() + 1),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
};
await saveMessages({ messages: [userMessage, assistantMessage] });
return NextResponse.json({
conversationId,
requestId:
upstreamPayload && typeof upstreamPayload === "object"
? (upstreamPayload as Record<string, unknown>).request_id || null
: null,
messages: [
{
id: userMessage.id,
role: "user",
parts: userMessage.parts,
metadata: { createdAt: userMessage.createdAt.toISOString() },
},
{
id: assistantMessage.id,
role: "assistant",
parts: assistantMessage.parts,
metadata: { createdAt: assistantMessage.createdAt.toISOString() },
},
],
});
}

View File

@ -125,6 +125,10 @@ import {
HOME_CHAT_ANIMATION_MAX_BYTES,
normalizeHomeChatAnimationConfig,
} from "@/lib/home/home-animation-config";
import {
normalizePublicOpenClawChatConfig,
OPENCLAW_CHAT_CONFIG_KEY,
} from "@/lib/openclaw-chat/config";
import {
ENHANCE_PRO_KREA_SETTING_KEY,
normalizeEnhanceProKreaConfig,
@ -837,6 +841,7 @@ async function getScopedPublicSiteSettings(
LOVART_COMPOSER_PREVIEW_TEXTS_KEY,
LOVART_COMPOSER_PREFIX_KEY,
CANVAS_TOOLBAR_CONFIG_SETTING_KEY,
OPENCLAW_CHAT_CONFIG_KEY,
]);
const typedSettings = settings as Record<string, unknown> | undefined;
@ -864,6 +869,9 @@ async function getScopedPublicSiteSettings(
canvasToolbarConfig: normalizeCanvasToolbarConfig(
valueByKey.get(CANVAS_TOOLBAR_CONFIG_SETTING_KEY)
),
openClawChatConfig: normalizePublicOpenClawChatConfig(
valueByKey.get(OPENCLAW_CHAT_CONFIG_KEY)
),
});
}

View File

@ -299,6 +299,8 @@ interface CanvasChatPanelProps {
canvasToolbarConfig?: CanvasToolbarConfig;
}
type CanvasComposerMode = "agent" | "image" | "video" | "detail" | "clawd";
type VideoStoryboardStreamData =
| {
stage: "detecting";
@ -1206,6 +1208,7 @@ export function CanvasChatPanel({
const [isDetailGenerationMode, setIsDetailGenerationMode] = useState(false);
const [detailGenerationMode, setDetailGenerationMode] = useState<DetailGenerationMode>("classic");
const [isDetailGenerating, setIsDetailGenerating] = useState(false);
const [isClawdSubmitting, setIsClawdSubmitting] = useState(false);
const [detailSections, setDetailSections] = useState<Record<DetailSectionKey, boolean>>({
required: true,
strategy: false,
@ -2577,6 +2580,10 @@ export function CanvasChatPanel({
canvasAutoModeDefaultModelId?: string | null;
canvasDetailDefaultModelId?: string | null;
canvasToolbarConfig?: CanvasToolbarConfig;
openClawChatConfig?: {
enabled?: boolean;
baseUrl?: string;
};
}>("/api/site-settings?scope=canvas-chat", fetcher);
const { data: canvasSkillTagsData } = useSWR<{
enabled?: boolean;
@ -2612,6 +2619,7 @@ export function CanvasChatPanel({
: normalizeCanvasToolbarConfig(siteSettings?.canvasToolbarConfig),
[propCanvasToolbarConfig, rawCanvasToolbarConfig, siteSettings?.canvasToolbarConfig]
);
const isClawdModeEnabled = siteSettings?.openClawChatConfig?.enabled === true;
const getToolbarItem = useCallback(
(key: CanvasToolbarItemKey) => getCanvasToolbarItem(canvasToolbarConfig, key),
[canvasToolbarConfig]
@ -3000,16 +3008,16 @@ export function CanvasChatPanel({
hasSelectedAgentPreset &&
!isDetailGenerationMode &&
(!selectedModelId || selectedModelId === "auto" || isConversationModel);
const computedComposerMode = useMemo<"agent" | "image" | "video" | "detail">(() => {
const computedComposerMode = useMemo<CanvasComposerMode>(() => {
if (isDetailGenerationMode) return "detail";
if (isVideoModel) return "video";
if (isAgentModel) return "agent";
return "image";
}, [isAgentModel, isDetailGenerationMode, isVideoModel]);
const [composerModeOverride, setComposerModeOverride] = useState<
"agent" | "image" | "video" | "detail" | null
>(null);
const [composerModeOverride, setComposerModeOverride] =
useState<CanvasComposerMode | null>(null);
const activeComposerMode = composerModeOverride || computedComposerMode;
const isClawdMode = activeComposerMode === "clawd" && isClawdModeEnabled;
const agentSelectableModels = useMemo(
() =>
effectiveAgentEnabled ? [SMART_AUTO_MODEL, ...conversationOnlyModels] : conversationOnlyModels,
@ -3348,7 +3356,9 @@ export function CanvasChatPanel({
[videoOnlyModels]
);
const activeModeLabel =
activeComposerMode === "video"
activeComposerMode === "clawd"
? "Clawd模式"
: activeComposerMode === "video"
? "视频"
: activeComposerMode === "detail"
? getToolbarItem("chatModeDetailActive").label
@ -3655,12 +3665,21 @@ export function CanvasChatPanel({
]);
const handleSelectGenerationMode = useCallback(
(mode: "agent" | "image" | "video" | "detail") => {
(mode: CanvasComposerMode) => {
hiddenPromptRef.current = null;
autoContextRef.current = null;
if (mode === "clawd" && !isClawdModeEnabled) {
toast.error("Clawd模式未开启");
return;
}
activeComposerModeRef.current = mode;
setComposerModeOverride(mode);
if (mode === "clawd") {
setIsDetailGenerationMode(false);
return;
}
if (mode === "agent") {
if (!effectiveAgentEnabled) {
return;
@ -3725,6 +3744,7 @@ export function CanvasChatPanel({
[
canUseDetailGeneration,
effectiveAgentEnabled,
isClawdModeEnabled,
allModels,
computedComposerMode,
firstImageModelId,
@ -3739,6 +3759,17 @@ export function CanvasChatPanel({
]
);
useEffect(() => {
if (activeComposerMode === "clawd" && !isClawdModeEnabled) {
handleSelectGenerationMode(effectiveAgentEnabled ? "agent" : "image");
}
}, [
activeComposerMode,
effectiveAgentEnabled,
handleSelectGenerationMode,
isClawdModeEnabled,
]);
useEffect(() => {
if (!effectiveAgentEnabled || typeof window === "undefined") return;
const targetWindow = window as CanvasAgentPendingRequestWindow;
@ -8459,6 +8490,108 @@ export function CanvasChatPanel({
return;
}
if (isClawdMode) {
const textContent = input
.replace(/\u200b/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!textContent) {
return;
}
if (attachments.length > 0) {
toast.error("Clawd模式当前仅支持文本对话");
return;
}
const sendSignature = JSON.stringify({
chatId: activeChatId,
mode: "clawd",
text: textContent,
});
if (shouldBlockDuplicateSend(activeChatId, sendSignature)) {
toast.message("检测到重复发送,已自动忽略");
return;
}
const userMessageId = generateUUID();
const optimisticUserMessage: ChatMessage = {
id: userMessageId,
role: "user",
parts: [{ type: "text", text: textContent }],
metadata: { createdAt: new Date().toISOString() },
} as ChatMessage;
setSendGuard(activeChatId, {
inFlight: true,
signature: sendSignature,
timestamp: Date.now(),
});
setIsClawdSubmitting(true);
setMessages((previousMessages) =>
sortCanvasMessagesByCreatedAt([
...previousMessages.filter((message) => message.id !== userMessageId),
optimisticUserMessage,
])
);
try {
const response = await fetch("/api/canvas/clawd-chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: rawChatId,
userMessageId,
message: textContent,
metadata: {
composerMode: "clawd",
activeChatId,
},
}),
});
const responseData = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(responseData?.error || "Clawd 请求失败");
}
const returnedMessages = Array.isArray(responseData?.messages)
? (responseData.messages as ChatMessage[])
: [];
if (returnedMessages.length > 0) {
const returnedIds = new Set(returnedMessages.map((message) => message.id));
setMessages((previousMessages) =>
sortCanvasMessagesByCreatedAt([
...previousMessages.filter(
(message) =>
message.id !== userMessageId && !returnedIds.has(message.id)
),
...returnedMessages,
])
);
}
if (rawChatId) {
void mutate(buildHistoryMessagesUrl(rawChatId, { cacheBust: true }));
}
hasHydratedHistoryRef.current = true;
setInput("");
setAttachments([]);
clearSelection();
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (editableDiv) {
editableDiv.innerHTML = "";
}
} catch (error) {
console.error("[CanvasChat] Clawd request failed:", error);
toast.error(error instanceof Error ? error.message : "Clawd 请求失败");
} finally {
setIsClawdSubmitting(false);
releaseSendGuard(activeChatId);
}
return;
}
if (!input.trim() && attachments.length === 0 && !selectedSkillTag && !hasWorkflowContext) {
return;
}
@ -8770,6 +8903,7 @@ export function CanvasChatPanel({
imageParams,
selectedImageNodes,
isDetailGenerationMode,
isClawdMode,
canUseDetailGeneration,
handleDetailGeneration,
ensureImagePointsBeforeSend,
@ -8781,8 +8915,11 @@ export function CanvasChatPanel({
effectiveAgentEnabled,
releaseSendGuard,
setSendGuard,
setMessages,
shouldBlockDuplicateSend,
status,
mutate,
buildHistoryMessagesUrl,
workflowContext,
]);
@ -9271,13 +9408,15 @@ export function CanvasChatPanel({
);
const isLoading = status === 'streaming' || status === 'submitted';
const isSubmitting = isLoading || isDetailGenerating;
const isSubmitting = isLoading || isDetailGenerating || isClawdSubmitting;
const hasInputOrAttachments = Boolean(
input.trim() || attachments.length > 0 || (!isDetailGenerationMode && selectedSkillTag)
);
const hasEnoughAgentUploads = !hasAgentUploadSlots || attachments.length >= agentRequiredUploadCount;
const composerPlaceholder = isWorkspacePresentation
? isDetailGenerationMode
? isClawdMode
? "输入要交给 Clawd 的问题"
: isDetailGenerationMode
? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)"
: isVideoWorkspace
? "描述镜头、主体和运动方式,也可以先切到首帧或首尾帧模式"
@ -9286,7 +9425,9 @@ export function CanvasChatPanel({
: hasAgentUploadSlots
? "可选补充本次生图要求,例如:高级感、暖灰色、近景特写、生成3张。"
: "可选补充本次生成要求,例如:更高级、更生活化、暖调灯光。"
: isDetailGenerationMode
: isClawdMode
? "输入要交给 Clawd 的问题"
: isDetailGenerationMode
? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)"
: isVideoWorkspace
? isVideoModel && videoFrameSlots.length > 0
@ -9309,10 +9450,12 @@ export function CanvasChatPanel({
detailProgress.total > 0
? Math.min(100, Math.round((detailProgress.current / detailProgress.total) * 100))
: 0;
const canSend = isDetailGenerationMode
? detailValidationIssues.length === 0 && !isDetailGenerating
: hasInputOrAttachments &&
hasEnoughAgentUploads;
const canSend = isClawdMode
? Boolean(input.trim()) && !isClawdSubmitting
: isDetailGenerationMode
? detailValidationIssues.length === 0 && !isDetailGenerating
: hasInputOrAttachments &&
hasEnoughAgentUploads;
const currentVideoImageLimit = isVideoModel
? getVideoImageLimit(videoParams, currentModel?.providerType, currentModel?.videoToolCapability)
: null;
@ -9604,13 +9747,17 @@ export function CanvasChatPanel({
!isLoading &&
!shouldShowImageLoading;
const workspaceEmptyTitle =
activeComposerMode === "agent" && !isPresetAgentSelection
isClawdMode
? "开始 Clawd 对话"
: activeComposerMode === "agent" && !isPresetAgentSelection
? "开始 Auto Agent 对话"
: hasAgentUploadSlots && attachments.length < agentRequiredUploadCount
? `请先补齐图片槽位(${attachments.length}/${agentRequiredUploadCount})`
: "准备开始生成";
const workspaceEmptyDescription =
activeComposerMode === "agent" && !isPresetAgentSelection
isClawdMode
? "输入问题后会通过已配置的大龙虾 OpenClaw 聊天接口回答。"
: activeComposerMode === "agent" && !isPresetAgentSelection
? hasSelectedAgentPreset
? ""
: ""
@ -12202,7 +12349,7 @@ export function CanvasChatPanel({
}}
/>
))}
{!isWorkspacePresentation && !isDetailGenerationMode && activeComposerMode !== "image" && (!isVideoModel || showVideoAttachmentButton) && (
{!isWorkspacePresentation && !isDetailGenerationMode && activeComposerMode !== "image" && activeComposerMode !== "clawd" && (!isVideoModel || showVideoAttachmentButton) && (
<Button
variant="ghost"
size="icon"
@ -12240,7 +12387,9 @@ export function CanvasChatPanel({
)}
>
<span className="flex min-w-0 items-center">
{activeComposerMode === "video" ? (
{activeComposerMode === "clawd" ? (
<MessageCircle className="mr-1.5 h-5 w-5 shrink-0" />
) : activeComposerMode === "video" ? (
<CanvasToolbarIcon
className="mr-1.5 h-5 w-5 shrink-0"
fallback={Video}
@ -12315,6 +12464,30 @@ export function CanvasChatPanel({
) : null}
</DropdownMenuItem>
) : null}
{isClawdModeEnabled ? (
<DropdownMenuItem
onClick={() => handleSelectGenerationMode("clawd")}
className={cn(
"h-11 cursor-pointer rounded-[12px] px-3 text-[16px] font-normal [&_svg]:!size-5",
isVideoWorkspace
? "text-foreground"
: "text-[#2e3238] dark:text-zinc-100",
activeComposerMode === "clawd"
? isVideoWorkspace
? "bg-background"
: "bg-transparent dark:bg-white/10"
: isVideoWorkspace
? "hover:bg-background/80"
: "hover:bg-[#f5f6f7] dark:hover:bg-white/8"
)}
>
<MessageCircle className="h-5 w-5" />
<span className="flex-1">Clawd模式</span>
{activeComposerMode === "clawd" ? (
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#1f2937]")} />
) : null}
</DropdownMenuItem>
) : null}
{canUseDetailGeneration ? (
<DropdownMenuItem
onClick={() => handleSelectGenerationMode("detail")}
@ -12436,7 +12609,7 @@ export function CanvasChatPanel({
showInlineDivider={false}
/>
) : null}
{!isWorkspacePresentation && activeComposerMode !== "detail" ? (
{!isWorkspacePresentation && activeComposerMode !== "detail" && activeComposerMode !== "clawd" ? (
<Select
value={modelSelectValue}
onValueChange={(value) => {

View File

@ -0,0 +1,92 @@
export const OPENCLAW_CHAT_CONFIG_KEY = "openclaw_chat_config";
export const DEFAULT_OPENCLAW_CHAT_BASE_URL = "http://192.168.42.154:18790";
export type OpenClawChatConfig = {
enabled: boolean;
baseUrl: string;
token: string;
timeoutMs: number;
};
export type PublicOpenClawChatConfig = {
enabled: boolean;
baseUrl: string;
};
export type AdminOpenClawChatConfig = Omit<OpenClawChatConfig, "token"> & {
token: "";
hasToken: boolean;
tokenPreview: string;
};
const DEFAULT_TIMEOUT_MS = 180_000;
const MIN_TIMEOUT_MS = 5_000;
const MAX_TIMEOUT_MS = 300_000;
function readRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function normalizeBaseUrl(value: unknown) {
return (
String(value || "").trim().replace(/\/+$/, "") ||
DEFAULT_OPENCLAW_CHAT_BASE_URL
);
}
function normalizeTimeoutMs(value: unknown) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return DEFAULT_TIMEOUT_MS;
return Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, Math.round(numeric)));
}
export function normalizeOpenClawChatConfig(
value: unknown,
fallbackToken = ""
): OpenClawChatConfig {
const raw = readRecord(value);
const token = String(raw.token || "").trim() || fallbackToken.trim();
return {
enabled: raw.enabled === true,
baseUrl: normalizeBaseUrl(raw.baseUrl),
token,
timeoutMs: normalizeTimeoutMs(raw.timeoutMs),
};
}
export function normalizePublicOpenClawChatConfig(
value: unknown
): PublicOpenClawChatConfig {
const config = normalizeOpenClawChatConfig(value);
return {
enabled: config.enabled && Boolean(config.baseUrl) && Boolean(config.token),
baseUrl: config.baseUrl,
};
}
export function maskOpenClawToken(token: string) {
const normalized = token.trim();
if (!normalized) return "";
if (normalized.length <= 8) return "********";
return `${normalized.slice(0, 4)}****${normalized.slice(-4)}`;
}
export function toAdminOpenClawChatConfig(
value: unknown
): AdminOpenClawChatConfig {
const config = normalizeOpenClawChatConfig(value);
return {
enabled: config.enabled,
baseUrl: config.baseUrl,
token: "",
hasToken: Boolean(config.token),
tokenPreview: maskOpenClawToken(config.token),
timeoutMs: config.timeoutMs,
};
}