Files
CodePilot/src/components/bridge/FeishuBridgeSection.tsx
T
7418andClaude Opus 4.6 7cc9c90242 refactor: simplify Feishu settings, fix card race conditions, update docs
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>
2026-03-14 16:30:15 +08:00

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>
);
}