Merge branch 'master' into dhk-1

This commit is contained in:
fengxiangdebaozi committed 2026-06-23 19:45:41 +08:00
commit 85aa242fd5
48 files changed
+1818 -207

No files matched your search

+6
View File
@@ -7,6 +7,7 @@ import { FontScaleApplier } from "@/components/font-scale";
import { QueryClientProvider } from "@/components/query-client-provider";
import { AuthSyncWatcher } from "@/core/auth/sync";
import { I18nProvider } from "@/core/i18n/context";
import { fetchYLogConfig } from "@/core/log/y-log-config";
import { detectLocale } from "@/core/i18n/locale";
import { resolveForcedThemeForPath } from "@/core/page-layout/chats-iframe-route";
import { ChatProvider } from "@/contexts/ChatContext";
@@ -50,6 +51,11 @@ function AppProviders({ children }: { children: React.ReactNode }) {
useEffect(() => {
syncEmbedSessionFromSearch(location.search);
}, [location.search]);
// 操作日志(Y-Log):启动早期预热上报配置,使 writeYLog 同步读缓存即可工作
// (刷新后 module 缓存清空,token 登录用户也能继续上报)。失败静默。
useEffect(() => {
void fetchYLogConfig();
}, []);
const forcedTheme = useMemo(() => {
// iframe 嵌入模式(外部系统 `&embed=1`)最高优先:按本标签页的嵌入主题强制明/暗。
// 走 next-themes forcedTheme(不写 localStorage)→ 同浏览器另开的正常系统页不受影响。
@@ -826,7 +826,13 @@ const DocumentUploadPopupContent: FC<{
</Button>
</div>
)}
{listData.length === 0 && <Empty />}
{isSearching ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
listData.length === 0 && <Empty />
)}
{searchType === "human" && (
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
{listData.map((item: listDataItem, index: number) => {
@@ -545,7 +545,12 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
</div>
)}
<div className="p-[10px]">
<div className="grid grid-cols-4 gap-[10px] user-list">
{isSearching ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : null}
<div className={`grid grid-cols-4 gap-[10px] user-list${isSearching ? ' hidden' : ''}`}>
{userList.map((item, index) => (
<div key={index} className="user-item p-[10px] relative">
<div className="absolute top-[5px] right-[10px]">
@@ -32,6 +32,10 @@ interface AgentSelectorProps {
disabled?: boolean;
/** 紧凑模式(CM 参考图):触发器改为灰底胶囊 + 主色机器人图标。 */
compact?: boolean;
/** 图标模式:用于 iframe 等底栏场景,仅显示机器人图标和可选清空按钮。 */
triggerVariant?: "default" | "icon";
iconButtonClassName?: string;
iconActiveClassName?: string;
}
/**
@@ -48,6 +52,9 @@ export function AgentSelector({
onAgentChange,
disabled,
compact = false,
triggerVariant = "default",
iconButtonClassName,
iconActiveClassName,
}: AgentSelectorProps) {
const [open, setOpen] = useState(false);
const [manageOpen, setManageOpen] = useState(false);
@@ -79,48 +86,84 @@ export function AgentSelector({
};
const triggerLabel = selectedAgent ? desensitize(selectedAgent.name) : "选择 Agent";
const iconMode = triggerVariant === "icon";
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<PromptInputButton
type="button"
disabled={disabled}
className={cn(
"gap-1.5! rounded-full border px-3! shadow-none transition-colors",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
compact
? "border-transparent bg-gray-50/60 text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700"
: selectedAgent
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
)}
>
<BotIcon
className={cn("size-3", compact && "text-[var(--primary)]")}
/>
<span className="max-w-[140px] truncate text-xs font-normal">
{triggerLabel}
</span>
{selectedAgent && (
<span
role="button"
tabIndex={0}
aria-label="清除已选 agent"
className="ml-0.5 inline-flex items-center justify-center rounded-full p-0.5 hover:bg-[#ffd9a7]"
onClick={handleClear}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onAgentChange(null);
}
}}
>
<XIcon className="size-3" />
{iconMode ? (
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled}
aria-label={selectedAgent ? `已选择智能体:${triggerLabel}` : "选择智能体"}
className={cn(
"relative size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground",
selectedAgent && "text-amber-600 hover:text-amber-700",
selectedAgent && iconActiveClassName,
iconButtonClassName,
)}
>
<BotIcon className="size-4" />
{selectedAgent && (
<span
role="button"
tabIndex={0}
aria-label="清除已选 agent"
className="absolute -right-1 -top-1 inline-flex size-4 items-center justify-center rounded-full border bg-background text-foreground shadow-sm hover:bg-muted"
onClick={handleClear}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onAgentChange(null);
}
}}
>
<XIcon className="size-2.5" />
</span>
)}
</Button>
) : (
<PromptInputButton
type="button"
disabled={disabled}
className={cn(
"gap-1.5! rounded-full border px-3! shadow-none transition-colors",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
compact
? "border-transparent bg-gray-50/60 text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700"
: selectedAgent
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
)}
>
<BotIcon
className={cn("size-3", compact && "text-[var(--primary)]")}
/>
<span className="max-w-[140px] truncate text-xs font-normal">
{triggerLabel}
</span>
)}
</PromptInputButton>
{selectedAgent && (
<span
role="button"
tabIndex={0}
aria-label="清除已选 agent"
className="ml-0.5 inline-flex items-center justify-center rounded-full p-0.5 hover:bg-[#ffd9a7]"
onClick={handleClear}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onAgentChange(null);
}
}}
>
<XIcon className="size-3" />
</span>
)}
</PromptInputButton>
)}
</DialogTrigger>
<DialogContent className="overflow-hidden p-0 sm:max-w-md">
<DialogTitle className="sr-only">选择 Agent</DialogTitle>
@@ -7,6 +7,8 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { clearStoredAuth, getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { OptType, writeYLog } from "@/core/log/y-log";
import { clearYLogToken } from "@/core/log/y-log-token";
import { SettingsSection } from "./settings-section";
@@ -40,7 +42,15 @@ export function AccountSettingsPage() {
const displayName = getAccountDisplayName(auth);
const handleLogout = () => {
// 操作日志(Y-Log):在清除 token1 之前上报登出(此时 token1 还在),keepalive 保证发出。
writeYLog({
optType: OptType.logout,
optDetail: "B1-CM助手-登出",
url: "/api/v1/auth/logout",
keepalive: true,
});
clearStoredAuth();
clearYLogToken();
// Match the rest of the app's logout flow: blow away the cookie session
// by hitting the gateway logout endpoint and then forcing a full reload
// so any cached query state goes too.
+127 -8
View File
@@ -1,5 +1,6 @@
import { getBackendBaseURL } from "@/core/config";
import { apiFetch } from "@/core/api";
import { OptType, writeYLog } from "@/core/log/y-log";
import type {
Agent,
@@ -58,6 +59,13 @@ export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]
);
if (!res.ok) throw new Error(`Failed to load agents: ${res.statusText}`);
const data = (await res.json()) as { agents: Agent[] };
writeYLog({
optType: OptType.search,
optDetail: "B1-CM助手-智能体列表",
url: `/api/agents${query ? `?${query}` : ""}`,
reqParam: params,
returnParam: data.agents,
});
return data.agents;
}
@@ -76,12 +84,20 @@ export async function listAgentsPaginated(
page?: number;
page_size?: number;
};
return {
const result: PaginatedAgents = {
agents: data.agents,
total: data.total ?? data.agents.length,
page: data.page ?? params.page ?? 1,
pageSize: data.page_size ?? params.pageSize ?? data.agents.length,
};
writeYLog({
optType: OptType.search,
optDetail: "B1-CM助手-智能体列表",
url: `/api/agents${query ? `?${query}` : ""}`,
reqParam: params,
returnParam: result,
});
return result;
}
export async function listAllAgentsAdmin(): Promise<Agent[]> {
@@ -91,6 +107,12 @@ export async function listAllAgentsAdmin(): Promise<Agent[]> {
throw new Error(err.detail ?? `Failed to load agents: ${res.statusText}`);
}
const data = (await res.json()) as { agents: Agent[] };
writeYLog({
optType: OptType.search,
optDetail: "B1-CM助手-智能体列表",
url: `/api/agents?admin=true`,
returnParam: data.agents,
});
return data.agents;
}
@@ -110,7 +132,15 @@ export async function createAgent(request: CreateAgentRequest): Promise<Agent> {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to create agent: ${res.statusText}`);
}
return res.json() as Promise<Agent>;
const created = (await res.json()) as Agent;
writeYLog({
optType: OptType.create,
optDetail: "B1-CM助手-智能体-新建",
url: `/api/agents`,
reqParam: request,
returnParam: created,
});
return created;
}
export async function updateAgent(
@@ -126,7 +156,15 @@ export async function updateAgent(
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to update agent: ${res.statusText}`);
}
return res.json() as Promise<Agent>;
const updated = (await res.json()) as Agent;
writeYLog({
optType: OptType.edit,
optDetail: "B1-CM助手-智能体-修改",
url: `/api/agents/${id}`,
reqParam: { id, ...request },
returnParam: updated,
});
return updated;
}
export async function deleteAgent(id: string): Promise<void> {
@@ -134,6 +172,12 @@ export async function deleteAgent(id: string): Promise<void> {
method: "DELETE",
});
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
writeYLog({
optType: OptType.delete,
optDetail: "B1-CM助手-智能体-删除",
url: `/api/agents/${id}`,
reqParam: { id },
});
}
export async function getAgentSelector(): Promise<AgentSelectorResponse> {
@@ -150,6 +194,12 @@ export async function favoriteAgent(id: string): Promise<void> {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to favorite agent: ${res.statusText}`);
}
writeYLog({
optType: OptType.edit,
optDetail: "B1-CM助手-智能体-收藏",
url: `/api/agents/${id}/favorite`,
reqParam: { id },
});
}
export async function unfavoriteAgent(id: string): Promise<void> {
@@ -160,6 +210,12 @@ export async function unfavoriteAgent(id: string): Promise<void> {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to unfavorite agent: ${res.statusText}`);
}
writeYLog({
optType: OptType.edit,
optDetail: "B1-CM助手-智能体-取消收藏",
url: `/api/agents/${id}/favorite`,
reqParam: { id },
});
}
export async function pinAgent(id: string): Promise<void> {
@@ -170,6 +226,12 @@ export async function pinAgent(id: string): Promise<void> {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to pin agent: ${res.statusText}`);
}
writeYLog({
optType: OptType.edit,
optDetail: "B1-CM助手-智能体-置顶",
url: `/api/agents/${id}/pin`,
reqParam: { id },
});
}
export async function unpinAgent(id: string): Promise<void> {
@@ -180,6 +242,12 @@ export async function unpinAgent(id: string): Promise<void> {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to unpin agent: ${res.statusText}`);
}
writeYLog({
optType: OptType.edit,
optDetail: "B1-CM助手-智能体-取消置顶",
url: `/api/agents/${id}/pin`,
reqParam: { id },
});
}
export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> {
@@ -197,6 +265,12 @@ export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> {
err.detail ?? `Failed to reorder featured agents: ${res.statusText}`,
);
}
writeYLog({
optType: OptType.edit,
optDetail: "B1-CM助手-智能体-精选排序",
url: `/api/agents/featured/order`,
reqParam: { agent_ids: agentIds },
});
}
export async function listPrivateAgents(password: string): Promise<Agent[]> {
@@ -215,6 +289,12 @@ export async function listPrivateAgents(password: string): Promise<Agent[]> {
);
}
const data = (await res.json()) as { agents: Agent[] };
writeYLog({
optType: OptType.search,
optDetail: "B1-CM助手-私有广场智能体列表",
url: `/api/agents/private-square/list`,
returnParam: data.agents,
});
return data.agents;
}
@@ -261,12 +341,20 @@ export async function listPrivateAgentsPaginated(
page?: number;
page_size?: number;
};
return {
const result: PaginatedAgents = {
agents: data.agents,
total: data.total ?? data.agents.length,
page: data.page ?? params.page ?? 1,
pageSize: data.page_size ?? params.pageSize ?? data.agents.length,
};
writeYLog({
optType: OptType.search,
optDetail: "B1-CM助手-私有广场智能体列表",
url: `/api/agents/private-square/list`,
reqParam: { ...params, password: undefined },
returnParam: result,
});
return result;
}
export async function setAgentPrivate(
@@ -287,7 +375,15 @@ export async function setAgentPrivate(
err.detail ?? `Failed to update agent privacy: ${res.statusText}`,
);
}
return res.json() as Promise<Agent>;
const updated = (await res.json()) as Agent;
writeYLog({
optType: OptType.edit,
optDetail: "B1-CM助手-智能体-设置私有",
url: `/api/agents/${id}/private`,
reqParam: { id, is_private: isPrivate },
returnParam: updated,
});
return updated;
}
export async function setAgentFeatured(
@@ -308,7 +404,15 @@ export async function setAgentFeatured(
err.detail ?? `Failed to set featured: ${res.statusText}`,
);
}
return res.json() as Promise<Agent>;
const updated = (await res.json()) as Agent;
writeYLog({
optType: OptType.edit,
optDetail: "B1-CM助手-智能体-设置精选",
url: `/api/agents/${id}/featured`,
reqParam: { id, order },
returnParam: updated,
});
return updated;
}
export interface AgentSkillsSyncRemoval {
@@ -333,7 +437,14 @@ export async function adminSyncAgentSkills(): Promise<AgentSkillsSyncResult> {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to sync agent skills: ${res.statusText}`);
}
return res.json() as Promise<AgentSkillsSyncResult>;
const result = (await res.json()) as AgentSkillsSyncResult;
writeYLog({
optType: OptType.edit,
optDetail: "B1-CM助手-智能体-同步技能",
url: `/api/agents/admin/sync-skills`,
returnParam: result,
});
return result;
}
export interface KnowledgeIngestAttempt {
@@ -371,7 +482,15 @@ export async function publishArtifactToKnowledge(request: {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `发布入库失败: ${res.statusText}`);
}
return res.json() as Promise<KnowledgeIngestPublishResult>;
const result = (await res.json()) as KnowledgeIngestPublishResult;
writeYLog({
optType: OptType.export,
optDetail: "B1-CM助手-智能体-产物发布入库",
url: `/api/knowledge-ingest/publish`,
reqParam: request,
returnParam: result,
});
return result;
}
export async function checkAgentName(
@@ -1,5 +1,6 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
function filenameFromContentDisposition(header: string | null): string | null {
@@ -79,13 +80,31 @@ export async function downloadMarkdownDocxFromBackend(options: {
if (response.status === 404) {
await downloadMarkdownAsDocx(markdown, options.title?.trim() || "document", options.title);
writeYLog({
optType: OptType.export,
optDetail: "B1-CM助手-导出-Word",
url: `${getBackendBaseURL()}/api/writing/export/docx`,
reqParam: { title: options.title?.trim() || undefined },
});
return;
}
await downloadBlobResponse(response, fallbackFilename);
writeYLog({
optType: OptType.export,
optDetail: "B1-CM助手-导出-Word",
url: `${getBackendBaseURL()}/api/writing/export/docx`,
reqParam: { title: options.title?.trim() || undefined },
});
} catch (err) {
if (err instanceof TypeError) {
await downloadMarkdownAsDocx(markdown, options.title?.trim() || "document", options.title);
writeYLog({
optType: OptType.export,
optDetail: "B1-CM助手-导出-Word",
url: `${getBackendBaseURL()}/api/writing/export/docx`,
reqParam: { title: options.title?.trim() || undefined },
});
return;
}
throw err;
@@ -23,8 +23,10 @@ export interface TaskDeeplinkConfig {
actionTest: boolean;
/** 目的树查询完整 URL(调用追加 ?taskId=)。 */
purposeDetailUrl: string;
/** 删除完整 URL(调用追加 ?taskId=)。 */
/** 删除完整 URL(rwfx 与 xdfx 共用同一接口):rwfx 追加 ?taskId=、xdfx 追加 ?actionId=(另一参数不传)。 */
deleteUrl: string;
/** xdfx「行动历史是否存在」查询完整 URL(调用追加 ?actionId=);留空 → 前端视作无历史、不阻断删除(地址待提供)。 */
actionHistoryUrl: string;
/** B 系统树形完整编辑页 URL(跳转追加 ?id=)。 */
treemapEditUrl: string;
/** B 系统树形下一步页 URL(跳转追加 ?id=)。 */
@@ -50,10 +52,12 @@ export const DEFAULT_TASK_DEEPLINK_CONFIG: TaskDeeplinkConfig = {
deleteTest: false,
actionTest: false,
purposeDetailUrl: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail",
deleteUrl: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete",
deleteUrl: "https://ch1.b.uat.4.cn/bwjt/dropSixOrSevenData",
treemapEditUrl: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit",
treemapNextUrl: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next",
actionDetailUrl: "https://ch1.b.uat.4.cn/consumer/plan/action",
// 行动历史查询地址待提供:空字符串 → 前端不查询、视作无历史(不阻断删除)。
actionHistoryUrl: "",
detail3qUrl: "",
mock: {},
};
@@ -74,6 +78,8 @@ function normalize(body: unknown): TaskDeeplinkConfig {
treemapEditUrl: str(o.treemap_edit_url, d.treemapEditUrl),
treemapNextUrl: str(o.treemap_next_url, d.treemapNextUrl),
actionDetailUrl: str(o.action_detail_url, d.actionDetailUrl),
// action_history_url 允许为空(地址待提供):空 → 前端不查询、视作无历史。不回退默认。
actionHistoryUrl: typeof o.action_history_url === "string" ? o.action_history_url : d.actionHistoryUrl,
// detail_3q_url 允许为空(地址待定),不回退默认;只在配了非空时才生效。
detail3qUrl: typeof o.detail_3q_url === "string" ? o.detail_3q_url : d.detail3qUrl,
mock: {
+50 -5
View File
@@ -254,20 +254,35 @@ export interface DeleteTaskPurposeResult {
}
/**
* 删除任务目的树(覆盖前清空 / 手动清除任务记录都走这里)。``delete_test=true`` 时
* 用后端配置里的假数据(接口未开发完时用);否则打真实接口。
* 删除记录的「按哪种 id 删」:
* - ``task``(默认,rwfx):传 ``?taskId=``,不传 actionId;
* - ``action``(xdfx 行动分析):传 ``?actionId=``,不传 taskId。
* rwfx 与 xdfx 共用同一个删除接口(``delete_url`` = dropSixOrSevenData);
* 「不传」= 该参数**根本不出现在 URL 里**(只把要传的那个 key 放进 params,axios 自然省略另一个),
* 不是传空值/None。
*/
export type DeleteIdKind = "task" | "action";
/**
* 删除任务/行动记录(rwfx 覆盖前清空 / 手动清除 / xdfx 清除行动记录都走这里)。
* ``delete_test=true`` 时用后端配置里的假数据(接口未开发完时用);否则打真实接口
* (rwfx 传 ``taskId``、xdfx 传 ``actionId``,见 ``DeleteIdKind``)。
*/
export async function deleteTaskPurpose(
taskId: string,
id: string,
opts?: { idKind?: DeleteIdKind },
): Promise<DeleteTaskPurposeResult> {
if (!taskId) return { message: "缺少 taskId", success: false };
const idKind = opts?.idKind ?? "task";
if (!id) return { message: idKind === "action" ? "缺少 actionId" : "缺少 taskId", success: false };
const cfg = await fetchTaskDeeplinkConfig();
if (cfg.deleteTest) {
const m = cfg.mock.delete ?? {};
return { message: typeof m.message === "string" ? m.message : "", success: m.success === true };
}
if (!cfg.deleteUrl) return { message: "缺少删除接口地址", success: false };
const body = await requestCrudService.get(cfg.deleteUrl, { taskId });
// 只放要传的那个 id key:另一参数直接不出现在 URL(axios 不会序列化缺省 key)。
const params = idKind === "action" ? { actionId: id } : { taskId: id };
const body = await requestCrudService.get(cfg.deleteUrl, params);
const obj =
body && typeof body === "object" ? (body as unknown as Record<string, unknown>) : {};
return {
@@ -276,6 +291,36 @@ export async function deleteTaskPurpose(
};
}
export interface ActionHistoryResult {
/** 是否存在可删除的「行动历史」记录。 */
exists: boolean;
/** 是否成功判定。接口未配置 / 请求失败 → false(调用方据此**不**做「无历史」拦截,仍允许删除)。 */
known: boolean;
}
/**
* 查询 xdfx「行动历史是否存在」(清除行动记录前的预检),按「行动id」调用。
*
* 查询接口(``action_history_url``)地址**目前待提供**:为空时直接返回
* ``{ exists: false, known: false }``——视作空白、不查询,也不阻断删除;后端配上真实地址后自动生效。
*
* ⚠️ 真实返回结构待定:拿到接口后按实际字段调整「是否存在」的判定(当前暂按 ``data`` 非空兜底)。
*/
export async function fetchActionHistoryExists(actionId: string): Promise<ActionHistoryResult> {
if (!actionId) return { exists: false, known: false };
const cfg = await fetchTaskDeeplinkConfig();
if (!cfg.actionHistoryUrl) return { exists: false, known: false };
try {
const body = await requestCrudService.get(cfg.actionHistoryUrl, { actionId });
// TODO(接口就绪):按真实返回结构判定。暂按整包 body.data 非空作「存在历史」。
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
const exists = Array.isArray(data) ? data.length > 0 : data != null && data !== "";
return { exists, known: true };
} catch {
return { exists: false, known: false };
}
}
/* ----------------------------- 跳转地址 ----------------------------- */
/** B 系统「树形完整编辑」页地址(问答后「编辑」按钮,新标签打开)。用同步缓存配置。 */
+77 -7
View File
@@ -1,5 +1,6 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
import type {
BatchIngestPayload,
@@ -41,9 +42,17 @@ export async function listKnowledgeNotes(params: KnowledgeListParams = {}) {
if (params.folder !== undefined) qs.set("folder", params.folder);
qs.set("limit", String(params.limit ?? 20));
qs.set("offset", String(params.offset ?? 0));
return parseResponse<KnowledgeListResponse>(
const result = await parseResponse<KnowledgeListResponse>(
await apiFetch(`${baseUrl()}/notes?${qs.toString()}`),
);
writeYLog({
optType: OptType.search,
optDetail: "B1-CM助手-知识库-检索",
url: `${baseUrl()}/notes?${qs.toString()}`,
reqParam: params,
returnParam: result,
});
return result;
}
export async function getKnowledgeNote(noteId: string) {
@@ -67,6 +76,13 @@ export async function createKnowledgeNote(data: {
body: JSON.stringify(data),
}),
);
writeYLog({
optType: OptType.create,
optDetail: "B1-CM助手-知识库-新建",
url: `${baseUrl()}/notes`,
reqParam: data,
returnParam: res.note,
});
return res.note;
}
@@ -81,22 +97,38 @@ export async function updateKnowledgeNote(
folder: string | null;
}>,
) {
return parseResponse<KnowledgeNote>(
const result = await parseResponse<KnowledgeNote>(
await apiFetch(`${baseUrl()}/notes/${encodeURIComponent(noteId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
);
writeYLog({
optType: OptType.edit,
optDetail: "B1-CM助手-知识库-修改",
url: `${baseUrl()}/notes/${encodeURIComponent(noteId)}`,
reqParam: { noteId, data },
returnParam: result,
});
return result;
}
export async function deleteKnowledgeNote(noteId: string, hard = false) {
return parseResponse<{ ok: boolean; note_id: string }>(
const result = await parseResponse<{ ok: boolean; note_id: string }>(
await apiFetch(
`${baseUrl()}/notes/${encodeURIComponent(noteId)}${hard ? "?hard=true" : ""}`,
{ method: "DELETE" },
),
);
writeYLog({
optType: OptType.delete,
optDetail: "B1-CM助手-知识库-删除",
url: `${baseUrl()}/notes/${encodeURIComponent(noteId)}${hard ? "?hard=true" : ""}`,
reqParam: { noteId, hard },
returnParam: result,
});
return result;
}
export async function searchKnowledge(query: string, limit = 8) {
@@ -107,11 +139,18 @@ export async function searchKnowledge(query: string, limit = 8) {
body: JSON.stringify({ query, mode: "keyword", limit }),
}),
);
writeYLog({
optType: OptType.search,
optDetail: "B1-CM助手-知识库-检索",
url: `${baseUrl()}/search`,
reqParam: { query, mode: "keyword", limit },
returnParam: res.items,
});
return res.items;
}
export async function ingestThread(threadId: string, payload: IngestThreadPayload = {}) {
return parseResponse<{
const result = await parseResponse<{
note?: KnowledgeNote;
created?: boolean;
queued?: boolean;
@@ -122,16 +161,32 @@ export async function ingestThread(threadId: string, payload: IngestThreadPayloa
body: JSON.stringify(payload),
}),
);
writeYLog({
optType: OptType.import,
optDetail: "B1-CM助手-知识库-导入",
url: `${baseUrl()}/ingest/thread/${encodeURIComponent(threadId)}`,
reqParam: { threadId, payload },
returnParam: result,
});
return result;
}
export async function ingestThreadsBatch(payload: BatchIngestPayload = {}) {
return parseResponse<BatchIngestResult>(
const result = await parseResponse<BatchIngestResult>(
await apiFetch(`${baseUrl()}/ingest/threads/batch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
writeYLog({
optType: OptType.import,
optDetail: "B1-CM助手-知识库-导入",
url: `${baseUrl()}/ingest/threads/batch`,
reqParam: payload,
returnParam: result,
});
return result;
}
export interface IngestFileResult {
@@ -162,9 +217,17 @@ export async function ingestKnowledgeFile(
if (opts.template_id) form.append("template_id", opts.template_id);
if (opts.folder) form.append("folder", opts.folder);
// No Content-Type header — the browser sets the multipart boundary itself.
return parseResponse<IngestFileResult>(
const result = await parseResponse<IngestFileResult>(
await apiFetch(`${baseUrl()}/ingest/file`, { method: "POST", body: form }),
);
writeYLog({
optType: OptType.import,
optDetail: "B1-CM助手-知识库-导入",
url: `${baseUrl()}/ingest/file`,
reqParam: { fileName: file.name, ...opts },
returnParam: result,
});
return result;
}
/** Resolve `[[wikilink]]` targets to note ids / vault pages. */
@@ -197,9 +260,16 @@ export async function fetchVaultPage(path: string) {
}
export async function exportKnowledgeGraph() {
return parseResponse<GraphExportResult>(
const result = await parseResponse<GraphExportResult>(
await apiFetch(`${baseUrl()}/export/graph`, { method: "POST" }),
);
writeYLog({
optType: OptType.export,
optDetail: "B1-CM助手-知识库-导出",
url: `${baseUrl()}/export/graph`,
returnParam: result,
});
return result;
}
/** Absolute URL of a served wiki-export file (for iframe / download). */
+68
View File
@@ -0,0 +1,68 @@
/**
* 操作日志(Y-Log)上报的运行时配置:从后端公开只读接口
* ``GET /api/public/config/y-log`` 读取(地址 + 开关在后端 ``config.yaml`` 的
* ``y_log`` 段,改完即时生效,前端无需重建)。
*
* 仿 [[task-deeplink-config]]:
* - ``fetchYLogConfig()`` 拉取并模块级缓存(启动早期预热一次即可)。
* - ``getCachedYLogConfig()`` 同步读缓存(``writeYLog`` 即发即忘,必须同步可用)。
*
* 拉取失败 → 用内置默认值兜底(``enabled:false``,但保留默认地址),绝不抛错。
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export interface YLogConfig {
/** 是否启用上报。 */
enabled: boolean;
/** 上报外部接口完整地址(POST)。 */
writeUrl: string;
}
/** 内置默认地址(后端拿不到 / 未配置时的兜底地址;enabled 默认 false 不上报)。 */
const DEFAULT_WRITE_URL = "https://ch1.b.uat.4.cn/api/y-log/write";
/** 内置默认配置(拉取失败兜底;默认不上报)。 */
export const DEFAULT_Y_LOG_CONFIG: YLogConfig = {
enabled: false,
writeUrl: DEFAULT_WRITE_URL,
};
/** 后端 snake_case 响应 → 前端 camelCase;缺字段回退默认值。 */
function normalize(body: unknown): YLogConfig {
const o = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
const writeUrl =
typeof o.write_url === "string" && o.write_url.trim() ? o.write_url.trim() : DEFAULT_WRITE_URL;
return {
enabled: o.enabled === true,
writeUrl,
};
}
let cached: YLogConfig | null = null;
let inflight: Promise<YLogConfig> | null = null;
/** 拉取(带模块级缓存 + 并发去重);失败回退默认值。 */
export async function fetchYLogConfig(): Promise<YLogConfig> {
if (cached) return cached;
if (inflight) return inflight;
inflight = (async () => {
try {
const res = await apiFetch(`${getBackendBaseURL()}/api/public/config/y-log`);
if (!res.ok) throw new Error(`y-log config ${res.status}`);
cached = normalize(await res.json());
} catch {
cached = { ...DEFAULT_Y_LOG_CONFIG };
} finally {
inflight = null;
}
return cached;
})();
return inflight;
}
/** 同步读缓存(未拉取过则返回默认值,默认 enabled:false 不上报)。 */
export function getCachedYLogConfig(): YLogConfig {
return cached ?? DEFAULT_Y_LOG_CONFIG;
}
+34
View File
@@ -0,0 +1,34 @@
/**
* token1(token 登录用的上游 token,即 URL 参数 ``?authToken=``)的存取。
*
* 复用 [[scoped-storage]] 的 ``authStorage*``:嵌入(iframe)态自动落 sessionStorage、
* 正常态落 localStorage,使 iframe 里登录的用户 token1 与正常标签页**完全隔离**、
* 互不污染(与其它鉴权键同一隔离语义)。
*
* 只有用 token 登录(存在 token1)才上报操作日志;非 token 登录不写 token1,
* 该会话从此不上报(见 [[y-log]])。
*/
import {
authStorageGet,
authStorageRemove,
authStorageSet,
} from "@/core/auth/scoped-storage";
const Y_LOG_TOKEN_KEY = "ylog.token1";
/** 持久化 token1(token 登录成功后调用;空值忽略)。 */
export function persistYLogToken(token: string | null | undefined): void {
const t = token?.trim();
if (t) authStorageSet(Y_LOG_TOKEN_KEY, t);
}
/** 读 token1(无则返回 null → 不上报)。 */
export function readYLogToken(): string | null {
return authStorageGet(Y_LOG_TOKEN_KEY);
}
/** 清除 token1(登出时调用)。 */
export function clearYLogToken(): void {
authStorageRemove(Y_LOG_TOKEN_KEY);
}
+109
View File
@@ -0,0 +1,109 @@
/**
* 操作日志(Y-Log)上报主函数。
*
* 把用户的关键操作(点击/查询/查看/增删改/启停/导入导出/登录登出/问答等)上报到
* 外部日志接口(见 [[y-log-config]] 的 ``writeUrl``)。**前端直连**该接口,不经后端代理。
*
* 硬性约束(务必满足):
* ① **token 门控**:无 token1(非 token 登录)→ 直接 return,不发请求。
* ② **配置门控**:``enabled=false`` 或 ``writeUrl`` 为空 → 不发请求。
* ③ **异步即发即忘**:``void fetch(...)`` 不被 ``await``,绝不阻塞业务/UI。
* ④ **永不抛错**:整体包在 ``try/catch`` 中,任何异常(取 token/读配置/序列化/网络)静默吞掉。
* ⑤ **只报成功**:埋点只放在操作成功路径,``optResult`` 恒为 ``0``。
* ⑥ **失败静默**:``fetch`` 的 ``.catch`` 吞掉网络/跨域失败。
*/
import { getCachedYLogConfig } from "@/core/log/y-log-config";
import { readYLogToken } from "@/core/log/y-log-token";
/** 操作类型,数值与外部系统约定一致,不可改。 */
export enum OptType {
login = 0,
logout = 1,
search = 3,
view = 4,
edit = 5,
delete = 6,
import = 7,
export = 8,
create = 9,
disabled = 10,
enabled = 11,
other = 12,
}
export interface WriteYLogParams {
/** 操作类型枚举。 */
optType: OptType;
/** 操作详情(简短描述),统一「B1-CM助手-xxx」。 */
optDetail: string;
/** 操作命中的接口地址(完整或相对,内部自动转相对,去 ip/host)。 */
url: string;
/** 入参(对象自动 JSON 序列化)。 */
reqParam?: unknown;
/** 返回参数(对象自动 JSON 序列化)。 */
returnParam?: unknown;
/** 登出 / 页面卸载等场景置 true,保证请求在卸载时仍能发出。 */
keepalive?: boolean;
}
/** 入参/返回体积上限,超出裁剪。 */
const MAX_PARAM_LEN = 50_000;
/** 上报一条操作日志(异步即发即忘,永不抛错)。 */
export function writeYLog(params: WriteYLogParams): void {
try {
const token = readYLogToken();
if (!token) return; // ① 无 token1 不上报
const { enabled, writeUrl } = getCachedYLogConfig();
if (!enabled || !writeUrl) return; // ② 配置门控
if (typeof fetch === "undefined") return;
const body = {
optType: params.optType,
optDetail: params.optDetail ?? "",
url: toRelativeUrl(params.url ?? ""), // ③ 去 ip/host
reqParam: toParamString(params.reqParam), // ④ 对象→字符串+裁剪
returnParam: toParamString(params.returnParam),
optResult: 0, // ⑤ 恒为 0(只报成功)
};
void fetch(writeUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
keepalive: params.keepalive ?? false,
}).catch(() => undefined); // ⑥ 失败静默
} catch {
/* 绝不影响业务 */
}
}
function toParamString(v: unknown): string {
if (v == null) return "";
const s = typeof v === "string" ? v : safeStringify(v);
return s.length > MAX_PARAM_LEN ? `${s.slice(0, MAX_PARAM_LEN)}…[truncated]` : s;
}
function safeStringify(v: unknown): string {
try {
return JSON.stringify(v) ?? "";
} catch {
return String(v);
}
}
/** 去掉 ip/host,只留 path + query。 */
function toRelativeUrl(url: string): string {
if (!url) return "";
try {
const base = typeof window !== "undefined" ? window.location.origin : "http://localhost";
const u = new URL(url, base);
return `${u.pathname}${u.search}`;
} catch {
return url;
}
}
+73 -8
View File
@@ -1,5 +1,6 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
import type {
HtmlPageFavorite,
@@ -51,9 +52,17 @@ export async function listScheduledTasks(params: ListScheduledTasksParams = {})
if (params.search) qs.set("search", params.search);
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
const query = qs.toString();
return parseResponse<ScheduledTask[]>(
const result = await parseResponse<ScheduledTask[]>(
await apiFetch(`${baseUrl()}${query ? `?${query}` : ""}`),
);
writeYLog({
optType: OptType.search,
optDetail: "B1-CM助手-定时任务-列表",
url: `${baseUrl()}${query ? `?${query}` : ""}`,
reqParam: params,
returnParam: result,
});
return result;
}
export async function createScheduledTask(data: {
@@ -66,13 +75,21 @@ export async function createScheduledTask(data: {
require_markdown?: boolean;
execution_context?: ScheduledTaskExecutionContext | null;
}) {
return parseResponse<ScheduledTask>(
const result = await parseResponse<ScheduledTask>(
await apiFetch(baseUrl(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
);
writeYLog({
optType: OptType.create,
optDetail: "B1-CM助手-定时任务-新建",
url: baseUrl(),
reqParam: data,
returnParam: result,
});
return result;
}
export async function listScheduledTaskRuns(taskId: string) {
@@ -82,9 +99,17 @@ export async function listScheduledTaskRuns(taskId: string) {
}
export async function getScheduledTaskRun(runId: string) {
return parseResponse<ScheduledTaskRun>(
const result = await parseResponse<ScheduledTaskRun>(
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}`),
);
writeYLog({
optType: OptType.view,
optDetail: "B1-CM助手-定时任务-运行详情",
url: `${baseUrl()}/runs/${encodeURIComponent(runId)}`,
reqParam: { runId },
returnParam: result,
});
return result;
}
export async function deleteScheduledTaskRun(runId: string) {
@@ -228,45 +253,85 @@ export async function updateScheduledTask(
execution_context: ScheduledTaskExecutionContext | null;
}>,
) {
return parseResponse<ScheduledTask>(
const result = await parseResponse<ScheduledTask>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
);
writeYLog({
optType: OptType.edit,
optDetail: "B1-CM助手-定时任务-修改",
url: `${baseUrl()}/${encodeURIComponent(taskId)}`,
reqParam: { taskId, data },
returnParam: result,
});
return result;
}
export async function deleteScheduledTask(taskId: string) {
return parseResponse<{ success: boolean }>(
const result = await parseResponse<{ success: boolean }>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}`, {
method: "DELETE",
}),
);
writeYLog({
optType: OptType.delete,
optDetail: "B1-CM助手-定时任务-删除",
url: `${baseUrl()}/${encodeURIComponent(taskId)}`,
reqParam: { taskId },
returnParam: result,
});
return result;
}
export async function pauseScheduledTask(taskId: string) {
return parseResponse<ScheduledTask>(
const result = await parseResponse<ScheduledTask>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/pause`, {
method: "POST",
}),
);
writeYLog({
optType: OptType.disabled,
optDetail: "B1-CM助手-定时任务-暂停",
url: `${baseUrl()}/${encodeURIComponent(taskId)}/pause`,
reqParam: { taskId },
returnParam: result,
});
return result;
}
export async function resumeScheduledTask(taskId: string) {
return parseResponse<ScheduledTask>(
const result = await parseResponse<ScheduledTask>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/resume`, {
method: "POST",
}),
);
writeYLog({
optType: OptType.enabled,
optDetail: "B1-CM助手-定时任务-恢复",
url: `${baseUrl()}/${encodeURIComponent(taskId)}/resume`,
reqParam: { taskId },
returnParam: result,
});
return result;
}
export async function triggerScheduledTask(taskId: string) {
return parseResponse<{ success: boolean; task: ScheduledTask }>(
const result = await parseResponse<{ success: boolean; task: ScheduledTask }>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/trigger`, {
method: "POST",
}),
);
writeYLog({
optType: OptType.other,
optDetail: "B1-CM助手-定时任务-手动运行",
url: `${baseUrl()}/${encodeURIComponent(taskId)}/trigger`,
reqParam: { taskId },
returnParam: result,
});
return result;
}
export async function listPublishedScheduledTasks(params: { limit?: number; offset?: number } = {}) {
@@ -42,6 +42,8 @@ import {
type IRunOptions,
} from "docx";
import { OptType, writeYLog } from "@/core/log/y-log";
const BLACK = "000000";
const BODY_FONT = { ascii: "Calibri", eastAsia: "Microsoft YaHei" };
const MONO_FONT = { ascii: "Consolas", eastAsia: "Microsoft YaHei" };
@@ -484,6 +486,12 @@ export async function downloadMarkdownAsDocx(
title?: string,
): Promise<void> {
const blob = await markdownToDocxBlob(markdown, title);
writeYLog({
optType: OptType.export,
optDetail: "B1-CM助手-定时任务-导出",
url: "/scheduled-tasks/export/docx",
reqParam: { fileName, title },
});
const safeName = fileName.replace(/\.(md|markdown)$/i, "") || "document";
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
+16
View File
@@ -30,6 +30,7 @@ import { useNotification } from "@/core/notification/hooks";
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
import { stopThreadRun, useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { OptType, writeYLog } from "@/core/log/y-log";
import { env } from "@/env";
import { readTaskContext, taskThreadMetadata } from "@/core/auth/task-purpose";
import { TaskPurposePanel } from "@/strategy-components/components/task-components/TaskPurposePanel";
@@ -157,6 +158,21 @@ export default function AgentChatPage({
);
},
onFinish: (state) => {
// 操作日志(Y-Log):专家代理问答完成(成功)后,用最终汇总文本上报一条。
try {
const msgs = state.messages ?? [];
const lastAi = [...msgs].reverse().find((m) => (m as { type?: string }).type === "ai");
const lastHuman = [...msgs].reverse().find((m) => (m as { type?: string }).type === "human");
writeYLog({
optType: OptType.other,
optDetail: "B1-CM助手-专家代理问答",
url: `/api/threads/${threadId ?? ""}/runs/stream`,
reqParam: { agent_id, question: lastHuman ? textOfMessage(lastHuman) : "" },
returnParam: { data: lastAi ? textOfMessage(lastAi) : "" },
});
} catch {
/* 上报绝不影响问答 */
}
if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished";
const lastMessage = state.messages[state.messages.length - 1];
+16
View File
@@ -53,6 +53,7 @@ import { useThreadStream } from "@/core/threads/hooks";
import { useLayoutMode } from "@/core/system-settings/hooks";
import { useRouteBase } from "@/core/route-base";
import { textOfMessage } from "@/core/threads/utils";
import { OptType, writeYLog } from "@/core/log/y-log";
import { env } from "@/env";
import { cn } from "@/lib/utils";
@@ -187,6 +188,21 @@ export default function ChatPage() {
);
},
onFinish: (state) => {
// 操作日志(Y-Log):问答完成(成功)后,用最终汇总文本上报一条。
try {
const msgs = state.messages ?? [];
const lastAi = [...msgs].reverse().find((m) => (m as { type?: string }).type === "ai");
const lastHuman = [...msgs].reverse().find((m) => (m as { type?: string }).type === "human");
writeYLog({
optType: OptType.other,
optDetail: "B1-CM助手-专家代理问答",
url: `/api/threads/${threadId ?? ""}/runs/stream`,
reqParam: { agent_id: selectedAgentId, question: lastHuman ? textOfMessage(lastHuman) : "" },
returnParam: { data: lastAi ? textOfMessage(lastAi) : "" },
});
} catch {
/* 上报绝不影响问答 */
}
if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished";
const lastMessage = state.messages.at(-1);
+116 -69
View File
@@ -5,8 +5,8 @@ import {
BrainCircuitIcon,
FileTextIcon,
GraduationCapIcon,
ShrinkIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
@@ -46,6 +46,8 @@ import {
type BrainyChatSubmitMessage,
parseBrainyChatSubmitMessage,
} from "./iframe-chat-submit";
import { resolveIframeAssistantId } from "./iframe-chat-agent";
import { buildIframeModeButtons } from "./iframe-chat-mode-buttons";
type BrainyChatStatus = "started" | "finished";
type BrainyAuthTokenMessage = {
@@ -93,7 +95,6 @@ function postBrainyAuthToken(accessToken: string) {
export default function IframeChatPage() {
const routeBase = useRouteBase();
const router = useRouter();
const { t } = useI18n();
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
@@ -152,11 +153,19 @@ export default function IframeChatPage() {
}, [selectedAgentId, selectorAgents, selectorLoading]);
const { showNotification } = useNotification();
const activeAssistantId = resolveIframeAssistantId({
selectedAgentId,
threadMetadataAgentId: null,
});
const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
context: settings.context,
context: {
...settings.context,
...(activeAssistantId ? { agent_id: activeAssistantId } : {}),
},
isMock,
assistantId: activeAssistantId,
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
@@ -193,6 +202,22 @@ export default function IframeChatPage() {
},
});
const threadMetadataAgentId = (() => {
const metadata = (thread as { metadata?: Record<string, unknown> | null })
.metadata;
return typeof metadata?.agent_id === "string" ? metadata.agent_id : null;
})();
useEffect(() => {
const resolvedAgentId = resolveIframeAssistantId({
selectedAgentId,
threadMetadataAgentId,
});
if (resolvedAgentId && resolvedAgentId !== selectedAgentId) {
setSelectedAgentId(resolvedAgentId);
}
}, [selectedAgentId, threadMetadataAgentId]);
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
@@ -210,31 +235,15 @@ export default function IframeChatPage() {
externalContext?: Record<string, unknown>,
options?: { additionalKwargs?: Record<string, unknown> },
) => {
if (
selectedAgentId &&
isNewThread &&
!hasStartedConversation &&
message.text &&
(!message.files || message.files.length === 0)
) {
try {
sessionStorage.setItem(
"pendingAgentMessage",
JSON.stringify({ agentId: selectedAgentId, text: message.text }),
);
} catch {
// sessionStorage may be unavailable in private mode; fall through.
}
router.push(`/page/workspace/agents/${selectedAgentId}/chats/new`);
return;
}
setHasStartedConversation(true);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
const extraContext: Record<string, unknown> = {};
if (activeAssistantId) {
extraContext.agent_id = activeAssistantId;
}
if (settings.context.writing_mode && writingArtifactPath) {
extraContext.writing_artifact_path = writingArtifactPath;
}
@@ -250,10 +259,8 @@ export default function IframeChatPage() {
);
},
[
activeAssistantId,
hasStartedConversation,
isNewThread,
router,
selectedAgentId,
sendMessage,
settings.context.writing_mode,
threadId,
@@ -277,6 +284,8 @@ export default function IframeChatPage() {
const ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled =
localSettings.display.ragModeEnabled || ragModeLocked;
const contextCompressionEnabled =
localSettings.display.contextCompressionEnabled ?? false;
const proModeEnabled = settings.context.mode === "pro";
const toggleWritingMode = useCallback(() => {
@@ -301,6 +310,12 @@ export default function IframeChatPage() {
});
}, [ragModeEnabled, ragModeLocked, setLocalSettings]);
const toggleContextCompression = useCallback(() => {
setLocalSettings("display", {
contextCompressionEnabled: !contextCompressionEnabled,
});
}, [contextCompressionEnabled, setLocalSettings]);
const toggleProMode = useCallback(() => {
setSettings("context", {
mode: proModeEnabled ? "flash" : "pro",
@@ -333,28 +348,29 @@ export default function IframeChatPage() {
const data = event.data;
if (data?.source !== "brainy-extension") return;
if (data?.type === "brainy:chat-interrupt") {
void handleInterrupt(data);
void handleInterrupt(data as BrainyChatInterruptMessage);
return;
}
if (data?.type !== "brainy:chat-submit") return;
const submitMessage = data as BrainyChatSubmitMessage;
const { submitText, files, iframeContext, additionalKwargs } =
parseBrainyChatSubmitMessage(data);
parseBrainyChatSubmitMessage(submitMessage);
console.log("收到插件消息", {
text: data.payload?.text,
prompt: data.payload?.prompt,
pageContext: data.payload?.pageContext,
screenshot: data.payload?.screenshot,
model: data.payload?.model,
requestId: data.requestId,
text: submitMessage.payload?.text,
prompt: submitMessage.payload?.prompt,
pageContext: submitMessage.payload?.pageContext,
screenshot: submitMessage.payload?.screenshot,
model: submitMessage.payload?.model,
requestId: submitMessage.requestId,
filesCount: files.length,
iframeContext,
});
if (!submitText && files.length === 0) return;
pendingRequestIdRef.current = data.requestId ?? null;
pendingRequestIdRef.current = submitMessage.requestId ?? null;
handleSubmit(
{ text: submitText, files },
@@ -371,41 +387,59 @@ export default function IframeChatPage() {
const isLandingState = isNewThread && !hasStartedConversation;
const bottomBarHeightClass = "pb-24";
const modeButtons = [
{
key: "writing",
label: "写作",
icon: FileTextIcon,
active: !!settings.context.writing_mode,
onClick: toggleWritingMode,
activeClassName: "text-amber-600 hover:text-amber-700",
},
{
key: "memory",
label: "记忆",
icon: BrainCircuitIcon,
active: settings.context.memory_injection_enabled ?? true,
onClick: toggleMemoryInjection,
activeClassName: "text-blue-600 hover:text-blue-700",
},
{
key: "references",
label: "参考文献",
icon: BookMarkedIcon,
active: ragModeEnabled,
onClick: toggleRagMode,
disabled: ragModeLocked,
activeClassName: "text-emerald-600 hover:text-emerald-700",
},
{
key: "pro",
label: "专业问答",
icon: GraduationCapIcon,
active: proModeEnabled,
onClick: toggleProMode,
activeClassName: "text-violet-600 hover:text-violet-700",
},
] as const;
const modeButtons = buildIframeModeButtons({
agentSelected: !!selectedAgentId,
writingModeEnabled: !!settings.context.writing_mode,
memoryInjectionEnabled: Boolean(
settings.context.memory_injection_enabled ?? true,
),
ragModeEnabled,
ragModeLocked,
contextCompressionEnabled,
proModeEnabled,
}).map((button) => {
switch (button.key) {
case "agent":
return button;
case "writing":
return {
...button,
icon: FileTextIcon,
onClick: toggleWritingMode,
activeClassName: "text-amber-600 hover:text-amber-700",
};
case "memory":
return {
...button,
icon: BrainCircuitIcon,
onClick: toggleMemoryInjection,
activeClassName: "text-blue-600 hover:text-blue-700",
};
case "references":
return {
...button,
icon: BookMarkedIcon,
onClick: toggleRagMode,
activeClassName: "text-emerald-600 hover:text-emerald-700",
};
case "contextCompression":
return {
...button,
icon: ShrinkIcon,
onClick: toggleContextCompression,
activeClassName: "text-violet-600 hover:text-violet-700",
};
case "pro":
return {
...button,
icon: GraduationCapIcon,
onClick: toggleProMode,
activeClassName: "text-fuchsia-600 hover:text-fuchsia-700",
};
default:
return button;
}
});
return (
<ThreadContext.Provider value={{ thread, isMock }}>
@@ -496,6 +530,19 @@ export default function IframeChatPage() {
<TooltipProvider delayDuration={250}>
<div className="mx-auto flex max-w-(--container-width-lg) flex-wrap items-center justify-center gap-0 px-0 py-1">
{modeButtons.map((button) => {
if (button.key === "agent") {
return (
<AgentSelector
key={button.key}
selectedAgentId={selectedAgentId}
onAgentChange={handleAgentChange}
disabled={isUploading}
triggerVariant="icon"
iconButtonClassName="size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground"
iconActiveClassName="text-amber-600 hover:text-amber-700"
/>
);
}
const Icon = button.icon;
return (
<Tooltip key={button.key} content={button.label}>
+16
View File
@@ -31,6 +31,9 @@ import {
isSidebarEnabledFromSearch,
persistSidebarEnabled,
} from '@/core/page-layout/sidebar-enabled';
import { fetchYLogConfig } from '@/core/log/y-log-config';
import { persistYLogToken } from '@/core/log/y-log-token';
import { OptType, writeYLog } from '@/core/log/y-log';
const DEFAULT_USERNAME = 'guest';
const IFRAME_LOGIN_SOURCE = 'iframe';
@@ -160,6 +163,19 @@ export default function LoginPage() {
return;
}
setStoredAuth(auth);
// 操作日志(Y-Log):只有 token 登录(存在 token1=authToken)才上报。
// 持久化 token1(走 authStorage,嵌入态自动隔离)+ 预热上报配置 + 上报登录。
if (authToken) {
persistYLogToken(authToken);
void fetchYLogConfig();
writeYLog({
optType: OptType.login,
optDetail: 'B1-CM助手-登录',
url: '/api/v1/auth/login/token',
reqParam: { username: loginUserName || auth.user_id },
returnParam: { user_id: auth.user_id },
});
}
// 标准登录把用户名写入 userInfo,供顶栏用户区显示(已有则不覆盖)
setStoredUserName(loginUserName);
// 内网透传的 access_token 合并进 userInfo(在 loginByYUserId 覆盖之后),
@@ -2,6 +2,9 @@ import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import { getNotebookApiBaseURL } from '@/core/config';
import { useTheme, type ThemeType } from '@/contexts/ThemeContext';
const VALID_THEMES: ThemeType[] = ['dark', 'light', 'lightblue'];
interface NotebookTokenData {
doc_ids: number[];
@@ -13,6 +16,17 @@ interface NotebookTokenData {
export default function NotebookAIWritingPage() {
const [searchParams] = useSearchParams();
const token = searchParams.get('token');
const themekey = searchParams.get('themekey');
const { setTheme } = useTheme();
// 跟随调用方传入的 themekey 切换主题;缺省或非法值兜底为 light
useEffect(() => {
const next = themekey && VALID_THEMES.includes(themekey as ThemeType)
? (themekey as ThemeType)
: 'light';
setTheme(next);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themekey]);
const [data, setData] = useState<NotebookTokenData | null>(null);
const [spaceName, setSpaceName] = useState<string | null>(null);
@@ -6,11 +6,13 @@ import {
AlertCircle,
AlignLeft,
ArrowLeft,
ArrowUp,
BookOpen,
Files,
Globe,
Loader2,
Plus,
Square,
X,
} from "lucide-react";
@@ -27,6 +29,7 @@ import { fetchDocumentByChunk } from "@/core/studio/api";
import type { DocumentWithChunks } from "@/core/studio/api";
import type { SourceItem } from "@/core/studio/types";
import { getStoredAuth } from "@/core/auth";
import { useTheme } from "@/contexts/ThemeContext";
import { useModels } from "@/core/models/hooks";
import {
Select,
@@ -118,6 +121,20 @@ function SourceCard({
);
}
// ── 上传中占位卡片 ──────────────────────────────────────
function PendingSourceCard({ title }: { title: string }) {
return (
<div className="group relative flex h-32 w-full flex-col justify-between rounded-2xl p-4 ring-1 ring-border/50 bg-muted/40 cursor-default">
<div className="flex size-10 items-center justify-center rounded-xl bg-background">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="truncate text-xs font-medium text-foreground">{title}</p>
</div>
</div>
);
}
// ── 来源骨架屏 ──────────────────────────────────────────
function SourceCardSkeleton() {
return (
@@ -136,6 +153,7 @@ function SourcesDialog({
sources,
isLoadingSources,
selectedSourceIds,
pendingSources,
onToggle,
onDelete,
}: {
@@ -145,6 +163,7 @@ function SourcesDialog({
sources: SourceItem[];
isLoadingSources: boolean;
selectedSourceIds: number[];
pendingSources: Map<string, { id?: number; title: string }>;
onToggle: (id: number) => void;
onDelete: (id: number) => void;
}) {
@@ -188,15 +207,26 @@ function SourcesDialog({
<div className="grid grid-cols-4 gap-3">
{isLoadingSources
? Array.from({ length: 8 }).map((_, i) => <SourceCardSkeleton key={i} />)
: sources.map((src) => (
<SourceCard
key={src.id}
item={src}
selected={selectedSourceIds.includes(src.id)}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
: <>
{Array.from(pendingSources.entries())
.filter(([, s]) => !sources.some((src) => {
const srcTitle = src.title ?? (src.document as { title?: string } | undefined)?.title ?? "";
return srcTitle === s.title;
}))
.map(([taskId, s]) => (
<PendingSourceCard key={taskId} title={s.title} />
))}
{sources.map((src) => (
<SourceCard
key={src.id}
item={src}
selected={selectedSourceIds.includes(src.id)}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</>
}
</div>
{!isLoadingSources && sources.length === 0 && (
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
@@ -218,6 +248,7 @@ export default function StudioNotebookDetailPage({
const params = useParams<{ notebookId: string }>();
const notebookId =
notebookIdProp != null ? String(notebookIdProp) : params.notebookId;
const { theme } = useTheme(); // 跳转「我的空间」时带上当前主题 key
const { notebooks, isLoading, token: studioToken } = useStudioNotebooks(); // token 用于跳转 callback
const { models } = useModels();
const queryClient = useQueryClient();
@@ -229,6 +260,8 @@ export default function StudioNotebookDetailPage({
// null = 显示历史线程列表;string = 显示该线程的聊天
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
const [citationChunkId, setCitationChunkId] = useState<number | null>(null);
// 轮询成功后待选中的来源标题集合(后端上传时不返回 id,等 sources 刷新后按标题匹配)
const pendingSelectTitlesRef = useRef<Set<string>>(new Set());
const [threadsExpanded, setThreadsExpanded] = useState(false);
const THREADS_LIMIT = 5;
const [citationDoc, setCitationDoc] = useState<DocumentWithChunks | null>(null);
@@ -253,6 +286,22 @@ export default function StudioNotebookDetailPage({
setSelectedSourceIds(sources.map((s) => s.id));
}
}, [sources, notebook?.id, initializedForNotebook]);
// sources 刷新后,将轮询成功的来源(按标题匹配)自动选中
useEffect(() => {
if (pendingSelectTitlesRef.current.size === 0) return;
const matched = sources.filter((s) => {
const title = s.title ?? (s.document as { title?: string } | undefined)?.title ?? "";
return pendingSelectTitlesRef.current.has(title);
});
if (matched.length > 0) {
setSelectedSourceIds((prev) => [...new Set([...prev, ...matched.map((s) => s.id)])]);
matched.forEach((s) => {
const title = s.title ?? (s.document as { title?: string } | undefined)?.title ?? "";
pendingSelectTitlesRef.current.delete(title);
});
}
}, [sources]);
const citationRehypePlugins = useMemo(() => {
const mapped = sources.map((s) => ({
id: s.id,
@@ -297,6 +346,10 @@ export default function StudioNotebookDetailPage({
}
};
const handleStop = useCallback(() => {
void thread.stop();
}, [thread]);
const handleSend = async (e: React.SyntheticEvent) => {
e.preventDefault();
const text = input.trim();
@@ -358,20 +411,17 @@ export default function StudioNotebookDetailPage({
const data = await response.json() as { status: 'processing' | 'success' | 'failed' | 'error' };
const taskStatus = data.status === 'failed' ? 'error' : data.status;
if (taskStatus !== 'processing') {
// 任务完成,从轮询队列移除并刷新来源列表
// 先取出 source 信息,再从轮询队列移除
const source = pendingSources.get(taskId);
setPendingSources((prev) => {
const newMap = new Map(prev);
const source = newMap.get(taskId);
newMap.delete(taskId);
if (source) {
if (taskStatus === 'success') {
// toast.success(`来源 "${source.title}" 处理完成`);
} else {
// toast.error(`来源 "${source.title}" 处理失败`);
}
}
return newMap;
});
// 仅成功时记录标题,等 sources 刷新后按标题匹配 id 并选中
if (taskStatus === 'success' && source?.title) {
pendingSelectTitlesRef.current.add(source.title);
}
// 刷新来源列表
void queryClient.invalidateQueries({ queryKey: ["studio", "sources"] });
// 刷新 notebooks 数据以更新 source_count
@@ -381,7 +431,7 @@ export default function StudioNotebookDetailPage({
} catch (error) {
console.error(`轮询任务 ${taskId} 状态失败:`, error);
}
}, [notebook?.id, queryClient]);
}, [notebook?.id, pendingSources, queryClient]);
// 控制轮询 - 只在对话框打开时轮询
useEffect(() => {
@@ -508,7 +558,7 @@ export default function StudioNotebookDetailPage({
</div>
<div className="flex items-center gap-2">
<a
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat`}
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat&themekey=${theme}`}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/70"
@@ -532,7 +582,7 @@ export default function StudioNotebookDetailPage({
<div className="relative z-10 flex flex-1 flex-col overflow-hidden">
{/* 右上角跳转按钮 */}
<a
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat`}
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat&themekey=${theme}`}
target="_blank"
rel="noreferrer"
className="absolute right-6 top-4 z-10 flex items-center gap-1.5 rounded-full bg-muted/70 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur transition-colors hover:bg-muted"
@@ -583,7 +633,6 @@ export default function StudioNotebookDetailPage({
disabled={isStreaming}
className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
/>
{isStreaming && <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />}
{models.length > 0 && (
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-full border-0 bg-transparent px-2 text-sm text-muted-foreground shadow-none focus:ring-0 hover:bg-muted/60">
@@ -598,6 +647,24 @@ export default function StudioNotebookDetailPage({
</SelectContent>
</Select>
)}
{isStreaming ? (
<button
type="button"
onClick={handleStop}
className="shrink-0 rounded-full bg-muted p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
title="停止回答"
>
<Square className="size-3.5 fill-current" />
</button>
) : input.trim() ? (
<button
type="submit"
className="shrink-0 rounded-full bg-primary p-1.5 text-primary-foreground hover:bg-primary/90 transition-colors"
title="发送"
>
<ArrowUp className="size-3.5" />
</button>
) : null}
</form>
{/* 空状态提示 */}
@@ -711,7 +778,6 @@ export default function StudioNotebookDetailPage({
disabled={isStreaming}
className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
/>
{isStreaming && <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />}
{models.length > 0 && (
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-full border-0 bg-transparent px-2 text-sm text-muted-foreground shadow-none focus:ring-0 hover:bg-muted/60">
@@ -726,6 +792,24 @@ export default function StudioNotebookDetailPage({
</SelectContent>
</Select>
)}
{isStreaming ? (
<button
type="button"
onClick={handleStop}
className="shrink-0 rounded-full bg-muted p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
title="停止回答"
>
<Square className="size-3.5 fill-current" />
</button>
) : input.trim() ? (
<button
type="submit"
className="shrink-0 rounded-full bg-primary p-1.5 text-primary-foreground hover:bg-primary/90 transition-colors"
title="发送"
>
<ArrowUp className="size-3.5" />
</button>
) : null}
</form>
</div>
@@ -769,6 +853,7 @@ export default function StudioNotebookDetailPage({
sources={sources}
isLoadingSources={isLoadingSources}
selectedSourceIds={selectedSourceIds}
pendingSources={pendingSources}
onToggle={handleToggleSource}
onDelete={handleDeleteSource}
/>
+18 -9
View File
@@ -8,6 +8,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useStudioNotebooks } from "@/core/studio/hooks";
import { createSearchSpace, deleteSearchSpace, renameSearchSpace } from "@/core/studio/api";
import { getStoredAuth } from "@/core/auth";
import { useTheme } from "@/contexts/ThemeContext";
import type { StudioNotebook } from "@/core/studio/types";
import {
Dialog,
@@ -23,13 +24,16 @@ import {
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
function NotebookCardSkeleton() {
function NotebookCardSkeleton({ isDark }: { isDark: boolean }) {
return (
<div className="flex flex-col rounded-[20px] bg-[#f2f2f2] p-5" style={{ minHeight: 180 }}>
<div className="size-10 animate-pulse rounded-xl bg-[#e0e0e0]" />
<div
className={`flex flex-col rounded-[20px] p-5 ${isDark ? "bg-[#1e2330]" : "bg-[#f2f2f2]"}`}
style={{ minHeight: 180 }}
>
<div className={`size-10 animate-pulse rounded-xl ${isDark ? "bg-[#2c3346]" : "bg-[#e0e0e0]"}`} />
<div className="mt-auto flex flex-col gap-1.5 pt-8">
<div className="h-4 w-3/4 animate-pulse rounded bg-[#e0e0e0]" />
<div className="h-3 w-1/3 animate-pulse rounded bg-[#e0e0e0]" />
<div className={`h-4 w-3/4 animate-pulse rounded ${isDark ? "bg-[#2c3346]" : "bg-[#e0e0e0]"}`} />
<div className={`h-3 w-1/3 animate-pulse rounded ${isDark ? "bg-[#2c3346]" : "bg-[#e0e0e0]"}`} />
</div>
</div>
);
@@ -37,11 +41,13 @@ function NotebookCardSkeleton() {
function NotebookCard({
nb,
isDark,
onRename,
onDelete,
onOpen,
}: {
nb: StudioNotebook;
isDark: boolean;
onRename: (nb: StudioNotebook) => void;
onDelete: (nb: StudioNotebook) => void;
/** 传入则点击卡片回调(内嵌打开),不再路由跳转。 */
@@ -61,16 +67,16 @@ function NotebookCard({
<div className="size-7" />
</div>
<div className="mt-auto flex flex-col gap-1 pt-8">
<span className="line-clamp-2 text-[15px] font-medium leading-snug text-[#1f1f1f]">
<span className={`line-clamp-2 text-[15px] font-medium leading-snug ${isDark ? "text-[#e8eaf0]" : "text-[#1f1f1f]"}`}>
{nb.name}
</span>
<span className="text-[13px] text-[#666]">{nb.source_count} 个来源</span>
<span className={`text-[13px] ${isDark ? "text-[#8b93a7]" : "text-[#666]"}`}>{nb.source_count} 个来源</span>
</div>
</>
);
return (
<div className="group relative flex flex-col rounded-[20px] bg-[#f2f2f2] transition-colors hover:bg-[#e8e8e8]" style={{ minHeight: 180 }}>
<div className={`group relative flex flex-col rounded-[20px] transition-colors ${isDark ? "bg-[#1e2330] hover:bg-[#262c3b]" : "bg-[#f2f2f2] hover:bg-[#e8e8e8]"}`} style={{ minHeight: 180 }}>
{onOpen ? (
<button
type="button"
@@ -125,6 +131,8 @@ export default function StudioNotebooksPage({
onOpenNotebook?: (id: number) => void;
} = {}) {
const { notebooks, isLoading, isError } = useStudioNotebooks();
const { theme } = useTheme();
const isDark = theme === "dark";
const queryClient = useQueryClient();
// ── 新建 ──
@@ -221,11 +229,12 @@ export default function StudioNotebooksPage({
{/* Grid */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{isLoading
? Array.from({ length: 8 }).map((_, i) => <NotebookCardSkeleton key={i} />)
? Array.from({ length: 8 }).map((_, i) => <NotebookCardSkeleton key={i} isDark={isDark} />)
: notebooks.map((nb) => (
<NotebookCard
key={nb.id}
nb={nb}
isDark={isDark}
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
onOpen={onOpenNotebook}
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-ignore Node's test runner resolves the local .ts module directly.
import { resolveIframeAssistantId } from "./iframe-chat-agent.ts";
test("prefers the currently selected iframe agent", () => {
assert.equal(
resolveIframeAssistantId({
selectedAgentId: "roundtable-structure",
threadMetadataAgentId: "lead_agent",
}),
"roundtable-structure",
);
});
test("reuses the thread-bound agent when reopening an iframe thread", () => {
assert.equal(
resolveIframeAssistantId({
selectedAgentId: null,
threadMetadataAgentId: "roundtable-structure",
}),
"roundtable-structure",
);
});
test("falls back to lead-agent mode when no agent is selected or bound", () => {
assert.equal(
resolveIframeAssistantId({
selectedAgentId: null,
threadMetadataAgentId: null,
}),
undefined,
);
});
@@ -0,0 +1,19 @@
type ResolveIframeAssistantIdInput = {
selectedAgentId: string | null;
threadMetadataAgentId: string | null;
};
function normalizeAgentId(agentId: string | null | undefined): string | undefined {
const trimmed = agentId?.trim();
return trimmed ? trimmed : undefined;
}
export function resolveIframeAssistantId({
selectedAgentId,
threadMetadataAgentId,
}: ResolveIframeAssistantIdInput): string | undefined {
return (
normalizeAgentId(selectedAgentId) ??
normalizeAgentId(threadMetadataAgentId)
);
}
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-ignore Node's test runner resolves the local .ts module directly.
import { buildIframeModeButtons } from "./iframe-chat-mode-buttons.ts";
test("includes the context compression toggle in the iframe chat toolbar", () => {
const buttons = buildIframeModeButtons({
agentSelected: false,
writingModeEnabled: false,
memoryInjectionEnabled: true,
ragModeEnabled: false,
ragModeLocked: false,
contextCompressionEnabled: false,
proModeEnabled: false,
});
assert.deepEqual(
buttons.map((button) => button.key),
[
"writing",
"memory",
"references",
"contextCompression",
"pro",
"agent",
],
);
});
test("returns only concrete toolbar button configs", () => {
const buttons = buildIframeModeButtons({
agentSelected: true,
writingModeEnabled: false,
memoryInjectionEnabled: true,
ragModeEnabled: false,
ragModeLocked: false,
contextCompressionEnabled: false,
proModeEnabled: false,
});
assert.equal(buttons.every((button) => typeof button.key === "string"), true);
});
@@ -0,0 +1,66 @@
export type IframeModeButtonState = {
key:
| "agent"
| "writing"
| "memory"
| "references"
| "contextCompression"
| "pro";
label: string;
active: boolean;
disabled?: boolean;
};
type BuildIframeModeButtonsInput = {
agentSelected: boolean;
writingModeEnabled: boolean;
memoryInjectionEnabled: boolean;
ragModeEnabled: boolean;
ragModeLocked: boolean;
contextCompressionEnabled: boolean;
proModeEnabled: boolean;
};
export function buildIframeModeButtons({
agentSelected,
writingModeEnabled,
memoryInjectionEnabled,
ragModeEnabled,
ragModeLocked,
contextCompressionEnabled,
proModeEnabled,
}: BuildIframeModeButtonsInput): IframeModeButtonState[] {
return [
{
key: "writing",
label: "写作",
active: writingModeEnabled,
},
{
key: "memory",
label: "记忆",
active: memoryInjectionEnabled,
},
{
key: "references",
label: "参考文献",
active: ragModeEnabled,
disabled: ragModeLocked,
},
{
key: "contextCompression",
label: "上下文压缩",
active: contextCompressionEnabled,
},
{
key: "pro",
label: "专业问答",
active: proModeEnabled,
},
{
key: "agent",
label: "智能体",
active: agentSelected,
},
];
}
@@ -14,6 +14,7 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
import type { IntentSummary } from "@/roundtable-planning/api/intent";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
@@ -321,7 +322,14 @@ export function fromUpdate(p: DraftUpdatePayload) {
export async function listDrafts(): Promise<DraftMeta[]> {
const res = await apiFetch(BASE(), { method: "GET" });
const data = await readJson<{ drafts: ServerDraftMeta[] }>(res, "list drafts");
return (data.drafts ?? []).map(toMeta);
const metas = (data.drafts ?? []).map(toMeta);
writeYLog({
optType: OptType.search,
optDetail: "B1-CM助手-会商-查询草稿",
url: BASE(),
returnParam: { count: metas.length },
});
return metas;
}
/** Fetch the full draft (incl. step1/step2). Returns null on 404. */
@@ -339,7 +347,15 @@ export async function createDraft(payload: DraftCreatePayload): Promise<DraftRec
body: JSON.stringify(fromCreate(payload)),
});
const data = await readJson<ServerDraftFull>(res, "create draft");
return toRecord(data);
const record = toRecord(data);
writeYLog({
optType: OptType.create,
optDetail: "B1-CM助手-会商-新建草稿",
url: BASE(),
reqParam: { title: payload.title, furthestStep: payload.furthestStep, taskId: payload.taskId ?? null },
returnParam: { id: record.id, title: record.title },
});
return record;
}
export async function updateDraft(id: string, patch: DraftUpdatePayload): Promise<DraftRecord | null> {
@@ -350,7 +366,15 @@ export async function updateDraft(id: string, patch: DraftUpdatePayload): Promis
});
if (res.status === 404) return null;
const data = await readJson<ServerDraftFull>(res, "update draft");
return toRecord(data);
const record = toRecord(data);
writeYLog({
optType: OptType.edit,
optDetail: "B1-CM助手-会商-更新草稿",
url: `${BASE()}/${id}`,
reqParam: { id, ...patch },
returnParam: { id: record.id, title: record.title },
});
return record;
}
export async function deleteDraft(id: string): Promise<void> {
@@ -359,6 +383,12 @@ export async function deleteDraft(id: string): Promise<void> {
const text = await res.text().catch(() => "");
throw new Error(`delete draft failed (${res.status}): ${text}`);
}
writeYLog({
optType: OptType.delete,
optDetail: "B1-CM助手-会商-删除草稿",
url: `${BASE()}/${id}`,
reqParam: { id },
});
}
/**
@@ -13,6 +13,7 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
import { reportRoundtableError } from "./diagnostics-report";
@@ -129,7 +130,15 @@ export async function startJob(payload: StartJobPayload): Promise<RoundtableJob>
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toStartBody(payload)),
});
return mapJob(await readJson<Record<string, unknown>>(res, "start job"));
const job = mapJob(await readJson<Record<string, unknown>>(res, "start job"));
writeYLog({
optType: OptType.other,
optDetail: "B1-CM助手-会商-运行",
url: BASE(),
reqParam: { draftId: payload.draftId ?? null, taskId: payload.taskId ?? null, model: payload.model },
returnParam: { id: job.id, status: job.status },
});
return job;
}
/**
@@ -186,7 +195,15 @@ export async function resumeJob(jobId: string, answer: string, taskId?: string |
/** 取消作业。`taskId` 非空 → task 共享取消(不分权)。 */
export async function cancelJob(jobId: string, taskId?: string | null): Promise<RoundtableJob> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/cancel${taskQuery(taskId, "?")}`, { method: "POST" });
return mapJob(await readJson<Record<string, unknown>>(res, "cancel job"));
const job = mapJob(await readJson<Record<string, unknown>>(res, "cancel job"));
writeYLog({
optType: OptType.edit,
optDetail: "B1-CM助手-会商-取消",
url: `${BASE()}/${jobId}/cancel`,
reqParam: { jobId, taskId: taskId ?? null },
returnParam: { id: job.id, status: job.status },
});
return job;
}
// ── SSE 进度订阅 ─────────────────────────────────────────────────────────────
@@ -12,6 +12,7 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
import {
fromCreate,
@@ -33,7 +34,15 @@ const BASE = () => `${getBackendBaseURL()}/api/roundtable-task-drafts`;
export async function listTaskDraftsByTask(taskId: string): Promise<DraftMeta[]> {
const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}`, { method: "GET" });
const data = await readJson<{ drafts: ServerDraftMeta[] }>(res, "list task drafts");
return (data.drafts ?? []).map(toMeta);
const metas = (data.drafts ?? []).map(toMeta);
writeYLog({
optType: OptType.search,
optDetail: "B1-CM助手-会商任务-查询草稿",
url: `${BASE()}/by-task/${taskId}`,
reqParam: { taskId },
returnParam: { count: metas.length },
});
return metas;
}
/** 按外部任务 id 取最新一条聊天记录(不分权)。无则 null(404)。 */
@@ -60,7 +69,15 @@ export async function createTaskDraft(payload: DraftCreatePayload): Promise<Draf
body: JSON.stringify(fromCreate(payload)),
});
const data = await readJson<ServerDraftFull>(res, "create task draft");
return toRecord(data);
const record = toRecord(data);
writeYLog({
optType: OptType.create,
optDetail: "B1-CM助手-会商任务-新建草稿",
url: BASE(),
reqParam: { taskId: payload.taskId ?? null, title: payload.title, furthestStep: payload.furthestStep },
returnParam: { id: record.id, title: record.title },
});
return record;
}
export async function updateTaskDraft(id: string, patch: DraftUpdatePayload): Promise<DraftRecord | null> {
@@ -71,7 +88,15 @@ export async function updateTaskDraft(id: string, patch: DraftUpdatePayload): Pr
});
if (res.status === 404) return null;
const data = await readJson<ServerDraftFull>(res, "update task draft");
return toRecord(data);
const record = toRecord(data);
writeYLog({
optType: OptType.edit,
optDetail: "B1-CM助手-会商任务-更新草稿",
url: `${BASE()}/${id}`,
reqParam: { id, ...patch },
returnParam: { id: record.id, title: record.title },
});
return record;
}
export async function deleteTaskDraft(id: string): Promise<void> {
@@ -80,4 +105,10 @@ export async function deleteTaskDraft(id: string): Promise<void> {
const text = await res.text().catch(() => "");
throw new Error(`delete task draft failed (${res.status}): ${text}`);
}
writeYLog({
optType: OptType.delete,
optDetail: "B1-CM助手-会商任务-删除草稿",
url: `${BASE()}/${id}`,
reqParam: { id },
});
}
@@ -16,6 +16,7 @@ import {
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
import { parseThemeMap, stringifyThemeMap } from "@/strategy-components/lib/light-app";
export type LightAppType = "window_open" | "iframe";
@@ -156,7 +157,15 @@ export async function listLightApps(params: ListLightAppsParams = {}): Promise<L
const query = qs.toString();
const res = await apiFetch(`${BASE()}${query ? `?${query}` : ""}`, { method: "GET" });
const data = await readJson<{ apps: ServerApp[] }>(res, "list light apps");
return (data.apps ?? []).map(toApp);
const apps = (data.apps ?? []).map(toApp);
writeYLog({
optType: OptType.search,
optDetail: "B1-CM助手-轻应用-列表",
url: `${BASE()}${query ? `?${query}` : ""}`,
reqParam: params,
returnParam: apps,
});
return apps;
}
export async function getLightApp(appId: string): Promise<LightApp> {
@@ -170,7 +179,15 @@ export async function createLightApp(input: LightAppInput): Promise<LightApp> {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toBody(input)),
});
return toApp(await readJson<ServerApp>(res, "create light app"));
const app = toApp(await readJson<ServerApp>(res, "create light app"));
writeYLog({
optType: OptType.create,
optDetail: "B1-CM助手-轻应用-注册",
url: BASE(),
reqParam: input,
returnParam: app,
});
return app;
}
export async function updateLightApp(
@@ -182,12 +199,26 @@ export async function updateLightApp(
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toBody(patch)),
});
return toApp(await readJson<ServerApp>(res, "update light app"));
const app = toApp(await readJson<ServerApp>(res, "update light app"));
writeYLog({
optType: OptType.edit,
optDetail: "B1-CM助手-轻应用-修改",
url: `${BASE()}/${encodeURIComponent(appId)}`,
reqParam: { appId, patch },
returnParam: app,
});
return app;
}
export async function deleteLightApp(appId: string): Promise<void> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(appId)}`, { method: "DELETE" });
await readJson<{ success: boolean }>(res, "delete light app");
writeYLog({
optType: OptType.delete,
optDetail: "B1-CM助手-轻应用-删除",
url: `${BASE()}/${encodeURIComponent(appId)}`,
reqParam: { appId },
});
}
// ── React Query hooks(管理页 / 列表页 / 侧边栏共用缓存) ───────────────────
@@ -10,6 +10,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
import type { MenuOverride } from "@/core/page-layout/menu-overrides";
const BASE = () => `${getBackendBaseURL()}/api/menu-overrides`;
@@ -67,7 +68,15 @@ export async function replaceMenuOverrides(overrides: MenuOverride[]): Promise<M
body: JSON.stringify({ overrides: overrides.map(toBody) }),
});
const data = await readJson<{ overrides: ServerOverride[] }>(res, "save menu overrides");
return (data.overrides ?? []).map(toOverride);
const saved = (data.overrides ?? []).map(toOverride);
writeYLog({
optType: OptType.edit,
optDetail: "B1-CM助手-菜单管理-保存",
url: "/api/menu-overrides",
reqParam: { overrides },
returnParam: saved,
});
return saved;
}
// ── React Query hooks(侧边栏 + 管理页共用缓存) ────────────────────────────
@@ -10,6 +10,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
const BASE = () => `${getBackendBaseURL()}/api/sensitive-words`;
@@ -71,7 +72,14 @@ async function readJson<T>(res: Response, ctx: string): Promise<T> {
export async function listSensitiveWords(): Promise<SensitiveWord[]> {
const res = await apiFetch(BASE(), { method: "GET" });
const data = await readJson<{ words: ServerWord[] }>(res, "list sensitive words");
return (data.words ?? []).map(toWord);
const words = (data.words ?? []).map(toWord);
writeYLog({
optType: OptType.search,
optDetail: "B1-CM助手-敏感词管理-列表",
url: "/api/sensitive-words",
returnParam: words,
});
return words;
}
export async function replaceSensitiveWords(words: SensitiveWord[]): Promise<SensitiveWord[]> {
@@ -81,7 +89,15 @@ export async function replaceSensitiveWords(words: SensitiveWord[]): Promise<Sen
body: JSON.stringify({ words: words.map(toBody) }),
});
const data = await readJson<{ words: ServerWord[] }>(res, "save sensitive words");
return (data.words ?? []).map(toWord);
const saved = (data.words ?? []).map(toWord);
writeYLog({
optType: OptType.edit,
optDetail: "B1-CM助手-敏感词管理-保存",
url: "/api/sensitive-words",
reqParam: { words },
returnParam: saved,
});
return saved;
}
/** 把「启用」的词条折叠成运行时脱敏所需的 `原始词 → 替换词` 映射。 */
@@ -11,6 +11,7 @@ import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
const BASE = () => `${getBackendBaseURL()}/api/skill-addresses`;
@@ -197,7 +198,15 @@ export async function scanAddresses(kinds?: AddressKind[], skill?: string): Prom
if (skill) params.set("skill", skill);
const qs = params.toString();
const res = await apiFetch(`${BASE()}/scan${qs ? `?${qs}` : ""}`, { method: "GET" });
return toScanResult(await readJson<any>(res, "扫描"));
const result = toScanResult(await readJson<any>(res, "扫描"));
writeYLog({
optType: OptType.search,
optDetail: "B1-CM助手-技能地址管理-扫描",
url: `${BASE()}/scan${qs ? `?${qs}` : ""}`,
reqParam: { kinds, skill },
returnParam: result,
});
return result;
}
/** 流式扫描:逐技能上报进度,返回最终聚合结果。 */
@@ -216,6 +225,13 @@ export async function scanAddressesStream(
else if (ev.type === "result") result = toScanResult(ev);
});
if (!result) throw new Error("扫描未返回结果");
writeYLog({
optType: OptType.search,
optDetail: "B1-CM助手-技能地址管理-扫描",
url: `${BASE()}/scan/stream${qs ? `?${qs}` : ""}`,
reqParam: { kinds: opts.kinds, skill: opts.skill },
returnParam: result,
});
return result;
}
@@ -251,6 +267,13 @@ export async function applyReplacementsStream(
else if (ev.type === "result") result = toApplyResult(ev);
});
if (!result) throw new Error("应用未返回结果");
writeYLog({
optType: OptType.edit,
optDetail: "B1-CM助手-技能地址管理-应用替换",
url: `${BASE()}/apply/stream`,
reqParam: body,
returnParam: result,
});
return result;
}
@@ -269,7 +292,15 @@ export async function listBackups(): Promise<BackupEntry[]> {
export async function rollbackBackup(backupId: string): Promise<{ restored: number }> {
const res = await apiFetch(`${BASE()}/rollback/${encodeURIComponent(backupId)}`, { method: "POST" });
const data = await readJson<{ restored_files: string[] }>(res, "撤销");
return { restored: (data.restored_files ?? []).length };
const result = { restored: (data.restored_files ?? []).length };
writeYLog({
optType: OptType.edit,
optDetail: "B1-CM助手-技能地址管理-回退",
url: `${BASE()}/rollback/${encodeURIComponent(backupId)}`,
reqParam: { backupId },
returnParam: result,
});
return result;
}
// ── React Query ────────────────────────────────────────────────────────────
@@ -10,6 +10,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
import type {
TaskBusiness,
TaskButton,
@@ -91,7 +92,15 @@ export async function replaceTaskButtons(buttons: TaskButton[]): Promise<TaskBut
body: JSON.stringify({ buttons: buttons.map(toBody) }),
});
const data = await readJson<{ buttons: ServerButton[] }>(res, "save task buttons");
return (data.buttons ?? []).map(toButton);
const saved = (data.buttons ?? []).map(toButton);
writeYLog({
optType: OptType.edit,
optDetail: "B1-CM助手-按钮管理-保存",
url: "/api/task-buttons",
reqParam: { buttons },
returnParam: saved,
});
return saved;
}
// ── React Query hooks(工作区 + 管理页共用缓存) ────────────────────────────
@@ -29,7 +29,12 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { deleteTaskPurpose, readTaskContext } from "@/core/auth/task-purpose";
import {
deleteTaskPurpose,
fetchActionHistoryExists,
readTaskContext,
type ActionHistoryResult,
} from "@/core/auth/task-purpose";
import {
fetchActionDetail,
readCopTaskDetail,
@@ -43,7 +48,7 @@ import { cn } from "@/lib/utils";
import AgentChatPage from "@/pages/AgentChatPage";
import { ChatRuntime } from "@/pages/WorkspaceRoutes";
import { TaskJumpBar } from "@/strategy-components/components/task-components/TaskJumpBar";
import { RwfxSqReportGate } from "@/strategy-components/components/task-components/RwfxSqReportGate";
import { RwfxSqGateProvider } from "@/strategy-components/components/task-components/rwfx-sq-gate";
function useChatsBase(taskBase: string): {
taskId: string;
@@ -195,6 +200,8 @@ function TaskConversationsList({
<PlusSquare className="h-4 w-4" /> 新建对话
</Button>
{!lite && <TaskClearButton taskId={taskId} />}
{/* xdfx:按「行动id」清除行动记录(删除前查历史是否存在;与 rwfx 共用同一删除接口)。 */}
{business === "xdfx" && actionId && <ActionClearButton actionId={actionId} />}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
@@ -292,6 +299,103 @@ function TaskClearButton({ taskId }: { taskId: string }) {
);
}
/**
* 「清除记录」按钮(xdfx 行动分析):放在「新建对话」下面,按「行动id」清除该行动在 B 系统里的
* 策划数据(聊天对话保留),与 rwfx 共用同一个删除接口(仅传 ``actionId``、不传 taskId)。
*
* 打开弹框时先「查询历史是否存在」(``fetchActionHistoryExists``):
* - 接口未就绪(地址未配置)→ ``known=false``:当作未知,仍可删除(不拦截);
* - 查到「无历史」(``known && !exists``)→ 禁用「确认清除」(没有可删的数据)。
*/
function ActionClearButton({ actionId }: { actionId: string }) {
const [open, setOpen] = useState(false);
const [clearing, setClearing] = useState(false);
const [checking, setChecking] = useState(false);
const [history, setHistory] = useState<ActionHistoryResult | null>(null);
// 打开弹框时查历史是否存在(接口未配置 → known=false,不影响删除)。
useEffect(() => {
if (!open) return;
let alive = true;
setChecking(true);
setHistory(null);
void fetchActionHistoryExists(actionId)
.then((r) => {
if (alive) setHistory(r);
})
.finally(() => {
if (alive) setChecking(false);
});
return () => {
alive = false;
};
}, [open, actionId]);
const handleClear = async () => {
setClearing(true);
try {
const r = await deleteTaskPurpose(actionId, { idKind: "action" });
if (r.success) toast.success("已清除行动记录");
else toast.error(r.message || "清除失败");
} catch {
toast.error("清除失败,请稍后再试");
} finally {
setClearing(false);
setOpen(false);
}
};
// 仅当「明确查到无历史」时禁用删除;接口未就绪(known=false)仍允许删除。
const noHistory = history?.known === true && history.exists === false;
return (
<>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive dark:text-[#ccd2da] dark:hover:bg-[#5c0011]/35 mt-2 h-9 w-full"
onClick={() => setOpen(true)}
disabled={!actionId}
title="清除行动记录"
>
<Trash2 className="h-3.5 w-3.5" /> 清除记录
</Button>
<Dialog open={open} onOpenChange={(o) => !clearing && setOpen(o)}>
<DialogContent>
<DialogHeader>
<DialogTitle>清除行动记录</DialogTitle>
<DialogDescription>
将清除该行动在 B 系统中的策划数据(聊天对话保留),是否确认?
</DialogDescription>
</DialogHeader>
{checking ? (
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> 正在查询历史…
</div>
) : history?.known === true ? (
<div className="text-muted-foreground text-xs">
{history.exists ? "检测到该行动的历史记录。" : "未检测到该行动的历史记录,无需清除。"}
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={clearing}>
取消
</Button>
<Button
variant="destructive"
onClick={handleClear}
disabled={clearing || checking || noHistory}
>
{clearing ? <Loader2 className="h-4 w-4 animate-spin" /> : "确认清除"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
/**
* 任务工作区布局:左列表 + 右 Outlet。
* ``lite``(3qfx/xdfx)= 精简版:左列表无跳转按钮、无底部任务操作面板。
@@ -306,7 +410,7 @@ export function TaskFxWorkspace({
lite?: boolean;
}) {
const { taskId, agentId, actionId, chatsBase } = useChatsBase(taskBase);
return (
const content = (
<div className="flex size-full min-h-0 flex-col overflow-hidden">
<div className="flex min-h-0 flex-1 overflow-hidden">
<TaskConversationsList
@@ -322,10 +426,14 @@ export function TaskFxWorkspace({
</div>
</div>
<TaskJumpBar business={business} taskId={taskId} />
{/* rwfx 专属:进入时预检 3q 是否完成,未完成则弹框引导去 3qfx。 */}
{business === "rwfx" && <RwfxSqReportGate taskId={taskId} />}
</div>
);
// rwfx 专属:3q 预检门控。进入时若 3q 未完成弹「前往 3qfx」框;并门控目的树为空时的
// 自动发送——3q 无数据时不默认发送,仅当用户点「暂不(不去 3q 分析)」才发送。
if (business === "rwfx") {
return <RwfxSqGateProvider taskId={taskId}>{content}</RwfxSqGateProvider>;
}
return content;
}
/** chats 索引:有历史对话 → 跳最新;否则 → 跳 new(rwfx 走闸门 / 3qfx/xdfx 直接带开场信息)。 */
@@ -31,6 +31,7 @@ import {
type TaskPurposeResult,
} from "@/core/auth/task-purpose";
import { fetchTaskIdFromAction } from "@/core/auth/task-deeplink";
import { useRwfxSqGate } from "@/strategy-components/components/task-components/rwfx-sq-gate";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -176,6 +177,8 @@ export function TaskPurposePanel({
const [collapsed, setCollapsed] = useState(true);
// rwfx 空数据自动开始:防止重复触发。
const autoStartedRef = useRef(false);
// rwfx 3q 预检门控:3q 无数据时不默认发送,交由门控(仅「暂不」才发送)。
const { gateStart } = useRwfxSqGate();
useEffect(() => {
let cancelled = false;
@@ -185,9 +188,10 @@ export function TaskPurposePanel({
if (cancelled) return;
setResult(r);
// 查询任务目的为空数据 → 默认直接开始第一次问答(跳过手动「开始问答」)。
// 经 3q 门控:3q 完成/未知立即发送;3q 未完成则挂起,仅当用户「暂不(不去 3q 分析)」才发送。
if (autoStartWhenEmpty && !r.hasData && !autoStartedRef.current) {
autoStartedRef.current = true;
onStart();
gateStart(onStart);
}
})
.catch(() => {
@@ -0,0 +1,188 @@
/**
* rwfx(任务分析)专用「3q 预检门控」。
*
* 进入任务分析工作区时调研判情报报告接口(``fetchSqReportStatus``)判断 3q 是否完成:
* - ``incomplete``(3q 未完成 / 无数据)→ 弹「3q 未完成,是否前往 3qfx」框(暂不 / 前往 3qfx)。
* - ``complete`` / ``unknown`` → 不打扰。
*
* 关键点:rwfx「目的树为空时自动发送第一次问题」必须**经本门控**(``gateStart``),不能在 3q
* 无数据时还默认发送:
* - 3q 完成 / 无法判定 → 立即发送;
* - 3q 未完成 → 挂起,**仅当用户点「暂不」(不去 3q 分析)才发送**;点「前往 3qfx」则跳转、丢弃发送。
*
* Provider 由 ``TaskFxWorkspace``(仅 rwfx)包裹整个工作区;``TaskPurposePanel`` 经 ``useRwfxSqGate``
* 读取门控。非 rwfx / 无 Provider → ``gateStart`` 直接发送(默认值)。见 [[dashboard-input]] / [[task-deeplink]]。
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useNavigate } from "react-router-dom";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { fetchSqReportStatus } from "@/core/auth/dashboard-input";
import { readCopTaskDetail, resolveTaskDeeplinkTarget } from "@/core/auth/task-deeplink";
type GateStatus = "loading" | "incomplete" | "ok";
interface RwfxSqGateValue {
/**
* rwfx 自动开始第一次问答的门控。
* - 3q 完成 / 无法判定 → 立即 ``start()``。
* - 3q 未完成 → 挂起 ``start``,弹「前往 3qfx」框;用户点「暂不」才 ``start()``,点「前往 3qfx」跳转并丢弃。
* 非 rwfx / 无 Provider → 直接 ``start()``(默认值)。
*/
gateStart: (start: () => void) => void;
}
const RwfxSqGateContext = createContext<RwfxSqGateValue>({
gateStart: (start) => start(),
});
export function useRwfxSqGate(): RwfxSqGateValue {
return useContext(RwfxSqGateContext);
}
export function RwfxSqGateProvider({
taskId,
children,
}: {
taskId: string;
children: ReactNode;
}) {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [jumping, setJumping] = useState(false);
const statusRef = useRef<GateStatus>(taskId ? "loading" : "ok");
// 待触发的「开始问答」回调:3q 未完成时挂起,点「暂不」/关闭弹框后触发。
const pendingStartRef = useRef<(() => void) | null>(null);
// taskId 变化:重置门控状态并预检 3q。
useEffect(() => {
pendingStartRef.current = null;
setOpen(false);
if (!taskId) {
statusRef.current = "ok";
return;
}
statusRef.current = "loading";
let alive = true;
void fetchSqReportStatus(taskId).then((s) => {
if (!alive) return;
// complete / unknown 都按「放行」(不打扰);仅明确 incomplete 才门控。
const next: GateStatus = s === "incomplete" ? "incomplete" : "ok";
statusRef.current = next;
if (next === "incomplete") {
setOpen(true); // 进入即提醒(无论是否有挂起的自动发送)
} else if (pendingStartRef.current) {
// 加载期间已请求自动开始 → 放行后补发。
const start = pendingStartRef.current;
pendingStartRef.current = null;
start();
}
});
return () => {
alive = false;
};
}, [taskId]);
const gateStart = useCallback((start: () => void) => {
const status = statusRef.current;
if (status === "ok") {
start();
return;
}
// loading / incomplete:挂起。loading 解析为 ok 时补发;incomplete 时等用户「暂不」。
pendingStartRef.current = start;
if (status === "incomplete") setOpen(true);
}, []);
/** 暂不(不去 3q 分析)/ 关闭弹框 → 补发挂起的「开始问答」(无则无操作)。 */
const stayAndStart = useCallback(() => {
setOpen(false);
const start = pendingStartRef.current;
pendingStartRef.current = null;
start?.();
}, []);
const handleGo3qfx = async () => {
const detail = readCopTaskDetail();
if (!detail || !taskId) {
toast.error("缺少任务信息,无法跳转");
return;
}
setJumping(true);
try {
// 复用深链解析:清掉当前 rwfx 任务上下文并按 3qfx 业务重新绑定(同一 taskId)。
const target = await resolveTaskDeeplinkTarget("3qfx", detail, taskId);
if (target) {
pendingStartRef.current = null; // 去 3q 分析 → 丢弃自动发送
setOpen(false);
navigate(target.path, { state: target.state });
} else {
toast.error("未配置三情分析(3qfx)对应的业务智能体");
}
} catch {
toast.error("跳转失败,请稍后再试");
} finally {
setJumping(false);
}
};
const value = useMemo<RwfxSqGateValue>(() => ({ gateStart }), [gateStart]);
return (
<RwfxSqGateContext.Provider value={value}>
{children}
<Dialog
open={open}
onOpenChange={(o) => {
if (jumping) return;
if (o) setOpen(true);
else stayAndStart(); // 关闭(暂不/ESC/点外部)= 不去 3q 分析 → 发送
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>3q 未完成</DialogTitle>
<DialogDescription>
未查询到该任务的研判情报报告(3q 未完成),是否前往该任务的三情分析(3qfx)?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={stayAndStart} disabled={jumping}>
暂不
</Button>
<Button
// 弹框经 Radix Portal 渲染到 body,在 `.strategy-data` 作用域之外,故用全局按主题
// 定义的品牌色 token(:root/.dark/.lightblue 都有),三套主题都能正确取色。
className="bg-[var(--workspace-brand)] text-[var(--workspace-brand-foreground)] hover:opacity-90"
onClick={handleGo3qfx}
disabled={jumping}
>
{jumping ? <Loader2 className="h-4 w-4 animate-spin" /> : "前往 3qfx"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</RwfxSqGateContext.Provider>
);
}