Settings cleanup: - Remove encryptKey/verificationToken (WSClient handles auth internally) - Remove renderMode/blockStreaming toggles (streaming always enabled) - Remove footer toggles (always show status + elapsed) - Simplify FeishuConfig type, always-on cardStreamConfig - Merge UI into 2 cards (credentials + access/behavior) with dirty-state save buttons Bug fixes: - Fix card creation race: cardCreatePromise awaited before finalize/interrupt - Fix tool-first turns: onToolEvent bootstraps card when no text precedes tools - Fix /cwd picker: source from channel_bindings (not chat_sessions), filter active only - Remove stale renderMode reference in bridge-manager /status Docs: - Update bridge-system.md: Feishu data flow, design decisions 13-18, V2 status, settings table - Add design decision 18: single-operator model for /cwd picker scope Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
491 lines
15 KiB
TypeScript
491 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, useEffect, useRef } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { SpinnerGap, CheckCircle, Warning } from "@/components/ui/icon";
|
|
import { useTranslation } from "@/hooks/useTranslation";
|
|
import { SettingsCard } from "@/components/patterns/SettingsCard";
|
|
import { FieldRow } from "@/components/patterns/FieldRow";
|
|
import { StatusBanner } from "@/components/patterns/StatusBanner";
|
|
|
|
interface FeishuBridgeSettings {
|
|
bridge_feishu_app_id: string;
|
|
bridge_feishu_app_secret: string;
|
|
bridge_feishu_domain: string;
|
|
bridge_feishu_allow_from: string;
|
|
bridge_feishu_dm_policy: string;
|
|
bridge_feishu_thread_session: string;
|
|
bridge_feishu_group_policy: string;
|
|
bridge_feishu_group_allow_from: string;
|
|
bridge_feishu_require_mention: string;
|
|
}
|
|
|
|
const DEFAULT_SETTINGS: FeishuBridgeSettings = {
|
|
bridge_feishu_app_id: "",
|
|
bridge_feishu_app_secret: "",
|
|
bridge_feishu_domain: "feishu",
|
|
bridge_feishu_allow_from: "",
|
|
bridge_feishu_dm_policy: "open",
|
|
bridge_feishu_thread_session: "false",
|
|
bridge_feishu_group_policy: "open",
|
|
bridge_feishu_group_allow_from: "",
|
|
bridge_feishu_require_mention: "false",
|
|
};
|
|
|
|
/** SaveButton: shows Save / Saving / Saved based on dirty + saving state. */
|
|
function SaveButton({
|
|
dirty,
|
|
saving,
|
|
onClick,
|
|
label,
|
|
savedLabel,
|
|
}: {
|
|
dirty: boolean;
|
|
saving: boolean;
|
|
onClick: () => void;
|
|
label: string;
|
|
savedLabel: string;
|
|
}) {
|
|
return (
|
|
<Button size="sm" onClick={onClick} disabled={saving || !dirty}>
|
|
{saving ? (
|
|
<>
|
|
<SpinnerGap size={14} className="animate-spin mr-1.5" />
|
|
{label}
|
|
</>
|
|
) : dirty ? (
|
|
label
|
|
) : (
|
|
savedLabel
|
|
)}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
export function FeishuBridgeSection() {
|
|
// ── Credentials state ──
|
|
const [appId, setAppId] = useState("");
|
|
const [appSecret, setAppSecret] = useState("");
|
|
const [domain, setDomain] = useState("feishu");
|
|
const [credentialsSaving, setCredentialsSaving] = useState(false);
|
|
const [credentialsDirty, setCredentialsDirty] = useState(false);
|
|
const savedCredentials = useRef({ appId: "", appSecret: "", domain: "feishu" });
|
|
|
|
// ── Access & Behavior state ──
|
|
const [allowFrom, setAllowFrom] = useState("");
|
|
const [dmPolicy, setDmPolicy] = useState("open");
|
|
const [threadSession, setThreadSession] = useState(false);
|
|
const [groupPolicy, setGroupPolicy] = useState("open");
|
|
const [groupAllowFrom, setGroupAllowFrom] = useState("");
|
|
const [requireMention, setRequireMention] = useState(false);
|
|
const [behaviorSaving, setBehaviorSaving] = useState(false);
|
|
const [behaviorDirty, setBehaviorDirty] = useState(false);
|
|
const savedBehavior = useRef({
|
|
allowFrom: "", dmPolicy: "open", threadSession: false,
|
|
groupPolicy: "open", groupAllowFrom: "", requireMention: false,
|
|
});
|
|
|
|
// ── Verify state ──
|
|
const [verifying, setVerifying] = useState(false);
|
|
const [verifyResult, setVerifyResult] = useState<{
|
|
ok: boolean;
|
|
message: string;
|
|
} | null>(null);
|
|
const { t } = useTranslation();
|
|
|
|
// ── Dirty tracking ──
|
|
useEffect(() => {
|
|
const s = savedCredentials.current;
|
|
setCredentialsDirty(
|
|
appId !== s.appId || appSecret !== s.appSecret || domain !== s.domain
|
|
);
|
|
}, [appId, appSecret, domain]);
|
|
|
|
useEffect(() => {
|
|
const s = savedBehavior.current;
|
|
setBehaviorDirty(
|
|
allowFrom !== s.allowFrom ||
|
|
dmPolicy !== s.dmPolicy ||
|
|
threadSession !== s.threadSession ||
|
|
groupPolicy !== s.groupPolicy ||
|
|
groupAllowFrom !== s.groupAllowFrom ||
|
|
requireMention !== s.requireMention
|
|
);
|
|
}, [allowFrom, dmPolicy, threadSession, groupPolicy, groupAllowFrom, requireMention]);
|
|
|
|
// ── Fetch ──
|
|
const fetchSettings = useCallback(async () => {
|
|
try {
|
|
const res = await fetch("/api/settings/feishu");
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
const s = { ...DEFAULT_SETTINGS, ...data.settings };
|
|
setAppId(s.bridge_feishu_app_id);
|
|
setAppSecret(s.bridge_feishu_app_secret);
|
|
setDomain(s.bridge_feishu_domain || "feishu");
|
|
setAllowFrom(s.bridge_feishu_allow_from);
|
|
setDmPolicy(s.bridge_feishu_dm_policy || "open");
|
|
setThreadSession(s.bridge_feishu_thread_session === "true");
|
|
setGroupPolicy(s.bridge_feishu_group_policy || "open");
|
|
setGroupAllowFrom(s.bridge_feishu_group_allow_from);
|
|
setRequireMention(s.bridge_feishu_require_mention === "true");
|
|
|
|
// Snapshot as "saved" baseline
|
|
savedCredentials.current = {
|
|
appId: s.bridge_feishu_app_id,
|
|
appSecret: s.bridge_feishu_app_secret,
|
|
domain: s.bridge_feishu_domain || "feishu",
|
|
};
|
|
savedBehavior.current = {
|
|
allowFrom: s.bridge_feishu_allow_from,
|
|
dmPolicy: s.bridge_feishu_dm_policy || "open",
|
|
threadSession: s.bridge_feishu_thread_session === "true",
|
|
groupPolicy: s.bridge_feishu_group_policy || "open",
|
|
groupAllowFrom: s.bridge_feishu_group_allow_from,
|
|
requireMention: s.bridge_feishu_require_mention === "true",
|
|
};
|
|
setCredentialsDirty(false);
|
|
setBehaviorDirty(false);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchSettings();
|
|
}, [fetchSettings]);
|
|
|
|
// ── Save helpers ──
|
|
const saveToApi = async (updates: Partial<FeishuBridgeSettings>) => {
|
|
const res = await fetch("/api/settings/feishu", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ settings: updates }),
|
|
});
|
|
return res.ok;
|
|
};
|
|
|
|
const handleSaveCredentials = async () => {
|
|
setCredentialsSaving(true);
|
|
try {
|
|
const updates: Partial<FeishuBridgeSettings> = {
|
|
bridge_feishu_app_id: appId,
|
|
bridge_feishu_domain: domain,
|
|
};
|
|
if (appSecret && !appSecret.startsWith("***")) {
|
|
updates.bridge_feishu_app_secret = appSecret;
|
|
}
|
|
if (await saveToApi(updates)) {
|
|
savedCredentials.current = { appId, appSecret, domain };
|
|
setCredentialsDirty(false);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setCredentialsSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleSaveBehavior = async () => {
|
|
setBehaviorSaving(true);
|
|
try {
|
|
const ok = await saveToApi({
|
|
bridge_feishu_allow_from: allowFrom,
|
|
bridge_feishu_dm_policy: dmPolicy,
|
|
bridge_feishu_thread_session: threadSession ? "true" : "false",
|
|
bridge_feishu_group_policy: groupPolicy,
|
|
bridge_feishu_group_allow_from: groupAllowFrom,
|
|
bridge_feishu_require_mention: requireMention ? "true" : "false",
|
|
});
|
|
if (ok) {
|
|
savedBehavior.current = {
|
|
allowFrom, dmPolicy, threadSession,
|
|
groupPolicy, groupAllowFrom, requireMention,
|
|
};
|
|
setBehaviorDirty(false);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setBehaviorSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleVerify = async () => {
|
|
setVerifying(true);
|
|
setVerifyResult(null);
|
|
try {
|
|
if (!appId) {
|
|
setVerifyResult({
|
|
ok: false,
|
|
message: t("feishu.enterCredentialsFirst"),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const res = await fetch("/api/settings/feishu/verify", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
app_id: appId,
|
|
app_secret: appSecret,
|
|
domain,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (data.verified) {
|
|
setVerifyResult({
|
|
ok: true,
|
|
message: data.botName
|
|
? t("feishu.verifiedAs", { name: data.botName })
|
|
: t("feishu.verified"),
|
|
});
|
|
} else {
|
|
setVerifyResult({
|
|
ok: false,
|
|
message: data.error || t("feishu.verifyFailed"),
|
|
});
|
|
}
|
|
} catch {
|
|
setVerifyResult({ ok: false, message: t("feishu.verifyFailed") });
|
|
} finally {
|
|
setVerifying(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-3xl space-y-6">
|
|
{/* ── App Credentials ── */}
|
|
<SettingsCard
|
|
title={t("feishu.credentials")}
|
|
description={t("feishu.credentialsDesc")}
|
|
>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
|
{t("feishu.appId")}
|
|
</label>
|
|
<Input
|
|
value={appId}
|
|
onChange={(e) => setAppId(e.target.value)}
|
|
placeholder="cli_xxxxxxxxxx"
|
|
className="font-mono text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
|
{t("feishu.appSecret")}
|
|
</label>
|
|
<Input
|
|
type="password"
|
|
value={appSecret}
|
|
onChange={(e) => setAppSecret(e.target.value)}
|
|
placeholder="xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
className="font-mono text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
|
{t("feishu.domain")}
|
|
</label>
|
|
<Select value={domain} onValueChange={setDomain}>
|
|
<SelectTrigger className="w-full text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="feishu">
|
|
{t("feishu.domainFeishu")}
|
|
</SelectItem>
|
|
<SelectItem value="lark">
|
|
{t("feishu.domainLark")}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{t("feishu.domainHint")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<SaveButton
|
|
dirty={credentialsDirty}
|
|
saving={credentialsSaving}
|
|
onClick={handleSaveCredentials}
|
|
label={t("common.save")}
|
|
savedLabel={t("feishu.saved")}
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleVerify}
|
|
disabled={verifying || !appId}
|
|
>
|
|
{verifying ? (
|
|
<SpinnerGap
|
|
size={14}
|
|
className="animate-spin mr-1.5"
|
|
/>
|
|
) : null}
|
|
{t("feishu.verify")}
|
|
</Button>
|
|
</div>
|
|
|
|
{verifyResult && (
|
|
<StatusBanner
|
|
variant={verifyResult.ok ? "success" : "error"}
|
|
icon={verifyResult.ok ? <CheckCircle size={16} className="shrink-0" /> : <Warning size={16} className="shrink-0" />}
|
|
>
|
|
{verifyResult.message}
|
|
</StatusBanner>
|
|
)}
|
|
</SettingsCard>
|
|
|
|
{/* ── Access & Behavior ── */}
|
|
<SettingsCard
|
|
title={t("feishu.accessBehavior")}
|
|
description={t("feishu.accessBehaviorDesc")}
|
|
>
|
|
<div className="space-y-4">
|
|
{/* DM Policy */}
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-semibold text-foreground block">
|
|
{t("feishu.dmPolicy")}
|
|
</label>
|
|
<Select value={dmPolicy} onValueChange={setDmPolicy}>
|
|
<SelectTrigger className="w-full text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="open">
|
|
{t("feishu.dmPolicyOpen")}
|
|
</SelectItem>
|
|
<SelectItem value="pairing">
|
|
{t("feishu.dmPolicyPairing")}
|
|
</SelectItem>
|
|
<SelectItem value="allowlist">
|
|
{t("feishu.dmPolicyAllowlist")}
|
|
</SelectItem>
|
|
<SelectItem value="disabled">
|
|
{t("feishu.dmPolicyDisabled")}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
|
{t("feishu.allowFrom")}
|
|
</label>
|
|
<Input
|
|
value={allowFrom}
|
|
onChange={(e) => setAllowFrom(e.target.value)}
|
|
placeholder="*, ou_xxxxxxxxxx, ou_yyyyyyyyyy"
|
|
className="font-mono text-sm"
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{t("feishu.allowFromHint")}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="border-t pt-3 space-y-2">
|
|
<label className="text-xs font-semibold text-foreground block">
|
|
{t("feishu.groupPolicy")}
|
|
</label>
|
|
<Select value={groupPolicy} onValueChange={setGroupPolicy}>
|
|
<SelectTrigger className="w-full text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="open">
|
|
{t("feishu.groupPolicyOpen")}
|
|
</SelectItem>
|
|
<SelectItem value="allowlist">
|
|
{t("feishu.groupPolicyAllowlist")}
|
|
</SelectItem>
|
|
<SelectItem value="disabled">
|
|
{t("feishu.groupPolicyDisabled")}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{groupPolicy === "allowlist" && (
|
|
<div>
|
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
|
{t("feishu.groupAllowFrom")}
|
|
</label>
|
|
<Input
|
|
value={groupAllowFrom}
|
|
onChange={(e) => setGroupAllowFrom(e.target.value)}
|
|
placeholder="oc_xxxxxxxxxx, oc_yyyyyyyyyy"
|
|
className="font-mono text-sm"
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{t("feishu.groupAllowFromHint")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="border-t pt-3">
|
|
<FieldRow
|
|
label={t("feishu.requireMention")}
|
|
description={t("feishu.requireMentionDesc")}
|
|
>
|
|
<Switch
|
|
checked={requireMention}
|
|
onCheckedChange={setRequireMention}
|
|
/>
|
|
</FieldRow>
|
|
</div>
|
|
|
|
<div className="border-t pt-3">
|
|
<FieldRow
|
|
label={t("feishu.threadSession")}
|
|
description={t("feishu.threadSessionDesc")}
|
|
>
|
|
<Switch
|
|
checked={threadSession}
|
|
onCheckedChange={setThreadSession}
|
|
/>
|
|
</FieldRow>
|
|
</div>
|
|
</div>
|
|
|
|
<SaveButton
|
|
dirty={behaviorDirty}
|
|
saving={behaviorSaving}
|
|
onClick={handleSaveBehavior}
|
|
label={t("common.save")}
|
|
savedLabel={t("feishu.saved")}
|
|
/>
|
|
</SettingsCard>
|
|
|
|
{/* ── Setup Guide ── */}
|
|
<SettingsCard title={t("feishu.setupGuide")}>
|
|
<ol className="text-xs text-muted-foreground space-y-1.5 list-decimal pl-4">
|
|
<li>{t("feishu.step1")}</li>
|
|
<li>{t("feishu.step2")}</li>
|
|
<li>{t("feishu.step3")}</li>
|
|
<li>{t("feishu.step4")}</li>
|
|
<li>{t("feishu.step5")}</li>
|
|
<li>{t("feishu.step6")}</li>
|
|
</ol>
|
|
</SettingsCard>
|
|
</div>
|
|
);
|
|
}
|