feat: add canvas Clawd mode
This commit is contained in:
parent
a752737327
commit
d9bb014742
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
212
app/api/canvas/clawd-chat/route.ts
Normal file
212
app/api/canvas/clawd-chat/route.ts
Normal 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() },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@ -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)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
92
lib/openclaw-chat/config.ts
Normal file
92
lib/openclaw-chat/config.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user