sync update from LAN repo (source master @ ca48c4ec)

This commit is contained in:
gozeros committed 2026-11-05 10:53:54 +08:00
1 parent 8bc8b0e082
commit ea9dcff9c0
873 files changed
+107866 -14011

No files matched your search

+3 -11
View File
@@ -1,4 +1,5 @@
import { apiFetch } from "@/core/api/fetch-client";
import { fetchWithBindingRetry } from "@/core/auth/login-transport";
import { getBackendBaseURL } from "@/core/config";
import type { LoginByUsernameResponse } from "./index";
@@ -29,10 +30,10 @@ async function accountRequest<T>(path: string, init: RequestInit = {}): Promise<
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 20_000);
try {
const response = await apiFetch(`${getBackendBaseURL()}/api/v1/auth${path}`, {
const response = await fetchWithBindingRetry(() => apiFetch(`${getBackendBaseURL()}/api/v1/auth${path}`, {
...init,
signal: controller.signal,
});
}));
const body: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(accountError(body, "请求失败,请稍后重试"));
return body as T;
@@ -45,15 +46,6 @@ async function accountRequest<T>(path: string, init: RequestInit = {}): Promise<
}
}
export async function fetchAuthenticationOptions(): Promise<AuthenticationOptions> {
const options = await accountRequest<AuthenticationOptions>("/options", { cache: "no-store" });
if (!options || [options.password_login_enabled, options.registration_enabled, options.sso_enabled]
.some((value) => typeof value !== "boolean")) {
throw new Error("无法读取登录设置,请联系管理员或重试");
}
return options;
}
export function loginWithPassword(username: string, password: string): Promise<LoginByUsernameResponse> {
return accountRequest("/login/local", {
method: "POST",
+80 -22
View File
@@ -1,4 +1,5 @@
import { apiFetch } from "@/core/api/fetch-client";
import { fetchWithBindingRetry } from "@/core/auth/login-transport";
import { getBackendBaseURL } from "@/core/config";
import type { LoginByUsernameResponse } from "./index";
@@ -9,29 +10,78 @@ export interface UserListItem {
system_role: "admin" | "user";
skill_count: number;
agent_count: number;
/** 问答次数:用户提问的次数(一次提问算一次)。 */
/** 近 30 天问答次数:用户提问的次数(一次提问算一次)。 */
qa_count: number;
/** 登录白名单放行标记:开关开启后仅 approved 用户(或 admin)可登录。 */
approved: boolean;
/**
* 仅兼容尚未迁移的历史口令。新密码使用散列存储,不回传明文或散列,
* 是否已设置凭据以 has_login_password 为准。
* 仅兼容尚未迁移的历史口令。新密码使用散列存储,不回传明文或散列,
* 是否已设置凭据以 has_login_password 为准。
*/
login_password: string | null;
/** 已设置密码/口令,散列凭据不会返回给客户端。 */
has_login_password: boolean;
/** 管理员重置自己的密码时,服务器同步刷新当前会话。 */
access_token?: string;
token_type?: string;
expires_in?: number;
/** 已设置密码/口令,散列凭据不会返回给客户端。 */
has_login_password: boolean;
/** 管理员重置自己的密码时,服务器同步刷新当前会话。 */
access_token?: string;
token_type?: string;
expires_in?: number;
}
export async function listAllUsers(): Promise<UserListItem[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users`);
/**
* 拉全部注册用户。`includeCounts=false` 时后端只查 users 表、跳过问答次数
* 统计与 skill/agent 计数(返回 0)——白名单管理页用,避免 runs 表慢查询。
*/
export async function listAllUsers(includeCounts = true): Promise<UserListItem[]> {
const url = new URL(`${getBackendBaseURL()}/api/v1/auth/users`);
if (!includeCounts) url.searchParams.set("include_counts", "false");
const res = await apiFetch(url.toString());
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<UserListItem[]>;
}
/** 分页用户列表(后端传 limit 时返回的 UserListPage 形状)。 */
export interface UserListPage {
items: UserListItem[];
total: number;
approved_count: number;
limit: number;
offset: number;
}
/** 服务端分页拉用户(白名单管理页):只查一页 users 表 + 两个 COUNT。 */
export async function listUsersPaged(opts: {
q?: string;
limit: number;
offset: number;
}, signal?: AbortSignal): Promise<UserListPage> {
const url = new URL(`${getBackendBaseURL()}/api/v1/auth/users`);
url.searchParams.set("limit", String(opts.limit));
url.searchParams.set("offset", String(opts.offset));
url.searchParams.set("include_counts", "false");
if (opts.q) url.searchParams.set("q", opts.q);
const controller = new AbortController();
const cancel = () => controller.abort();
if (signal?.aborted) cancel();
else signal?.addEventListener("abort", cancel, { once: true });
const timer = setTimeout(cancel, 20_000);
try {
const res = await apiFetch(url.toString(), { signal: controller.signal });
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(extractErrorMessage(data, `用户列表加载失败(HTTP ${res.status})`));
}
return await res.json() as UserListPage;
} catch (error) {
if (controller.signal.aborted && !signal?.aborted) {
throw new Error("用户列表加载超时,请重试");
}
throw error;
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", cancel);
}
}
/** 放行 / 取消放行某个注册用户(登录白名单)。Admin only。 */
export async function setUserApproval(userId: string, approved: boolean): Promise<UserListItem> {
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users/${encodeURIComponent(userId)}/approval`, {
@@ -47,7 +97,7 @@ export async function setUserApproval(userId: string, approved: boolean): Promis
}
/**
* 管理员设置 / 重置统一密码与登录口令。服务器不再允许清除以回退免密登录。
* 管理员设置 / 重置统一密码与登录口令。服务器不再允许清除以回退免密登录。
*/
export async function setUserLoginPassword(userId: string, password: string | null): Promise<UserListItem> {
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users/${encodeURIComponent(userId)}/login-password`, {
@@ -95,13 +145,15 @@ export async function loginByUsername(
password != null && password !== ""
? `${base}?password=${encodeURIComponent(password)}`
: base;
const response = await apiFetch(url, {
// application/x-www-form-urlencoded is a CORS simple content type. A JSON
// body makes old Firefox preflight the login and then abort it.
const response = await fetchWithBindingRetry(() => apiFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: JSON.stringify({ username }),
});
body: new URLSearchParams({ username }),
}));
if (!response.ok) {
const error = await response.json().catch(() => null);
throw new Error(extractErrorMessage(error));
@@ -122,6 +174,7 @@ const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve,
export async function loginByToken(
token: string,
isCurrent: () => boolean = () => true,
): Promise<LoginByUsernameResponse> {
// Token-exchange login: the gateway resolves the upstream token to a username
// and signs the matching local account in.
@@ -129,13 +182,18 @@ export async function loginByToken(
let lastError = "Login failed.";
for (let attempt = 1; attempt <= TOKEN_LOGIN_MAX_ATTEMPTS; attempt += 1) {
const response = await apiFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
if (!isCurrent()) throw new Error("登录请求已被新的登录替代");
let response: Response;
try {
// No JSON content type and no custom header: old Firefox can send this
// POST without a preflight. A dropped connection is retried once.
response = await fetchWithBindingRetry(() => apiFetch(url, { method: "POST" }));
} catch (error) {
if (!isCurrent()) throw new Error("登录请求已被新的登录替代");
throw error;
}
if (response.ok) {
if (!isCurrent()) throw new Error("登录请求已被新的登录替代");
return response.json() as Promise<LoginByUsernameResponse>;
}
+172
View File
@@ -0,0 +1,172 @@
/**
* b5-1「任务分析」单智能体深链。
*
* 与既有 3qfx/rwfx/xdfx 的区别是:任务详情由接入系统预先写入 DeerFlow 的
* ``/api/task-deeplink-details/b5-1/{id}``,登录后只从本系统读取,不依赖对方的
* 详情接口。智能体默认由「业务链条管理」中的 b5-1 行配置,但深链 URL 可用
* ``?agentId=`` 直接指定(优先于配置行);``?type=`` 业务类型与 taskId 一起决定
* 聊天记录的独立分区。
*/
import { apiFetch } from "@/core/api/fetch-client";
import { clearTaskContext, persistTaskContext } from "@/core/auth/task-purpose";
import { persistB51Theme } from "@/core/auth/b5-1-theme";
import { getBackendBaseURL } from "@/core/config";
import type { TaskDeeplinkTarget } from "@/core/auth/task-deeplink";
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
export const B5_1_GOPATH = "b5-1";
export const B5_1_TASK_BASE = "/page/strategy/task-management/plan-task-b5-1";
export const B5_1_DETAIL_STORAGE_KEY = "task.b5-1.detail";
/** 与后端索引列 task_deeplink_threads.sub_type 同宽。 */
const B51_TASK_TYPE_MAX_LENGTH = 64;
export interface B51TaskDetail {
type: "b5-1";
id: string;
taskName: string;
taskContent: string;
overview: string;
extra: Record<string, unknown>;
/** 深链 ``?type=`` 业务类型。不来自后端详情接口,由前端随快照一并登记。 */
taskType?: string;
}
function asText(value: unknown): string {
return typeof value === "string" ? value : "";
}
function asExtra(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
/** 归一化 ``?type=``:去空白并截到索引列宽,空值返回 ""(无类型分区)。 */
export function normalizeB51TaskType(value: string | null | undefined): string {
return (value ?? "").trim().slice(0, B51_TASK_TYPE_MAX_LENGTH);
}
/** 判断 goPath 是否精确为 b5-1(大小写不敏感)。 */
export function isB51GoPath(goPath: string): boolean {
return goPath.trim().toLowerCase() === B5_1_GOPATH;
}
/** 读取已由接入方写入的任务详情(按 taskId + 业务类型分区取)。
* 404 表示该 (id, type) 尚未同步,不应带空详情进入问答。 */
export async function fetchB51TaskDetail(
taskId: string,
taskType?: string,
): Promise<B51TaskDetail | null> {
const id = taskId.trim();
if (!id) return null;
const normalizedType = normalizeB51TaskType(taskType);
try {
const query = new URLSearchParams();
if (normalizedType) query.set("type", normalizedType);
const suffix = normalizedType ? `?${query.toString()}` : "";
const response = await apiFetch(
`${getBackendBaseURL()}/api/task-deeplink-details/${B5_1_GOPATH}/${encodeURIComponent(id)}${suffix}`,
{ method: "GET" },
);
if (!response.ok) {
console.warn("[b5-1-deeplink] task detail unavailable", { id, normalizedType, status: response.status });
return null;
}
const body = (await response.json().catch(() => null)) as { data?: unknown } | null;
const data = body?.data;
if (!data || typeof data !== "object") return null;
const row = data as Record<string, unknown>;
const detail: B51TaskDetail = {
type: B5_1_GOPATH,
id: asText(row.id).trim() || id,
taskName: asText(row.taskName),
taskContent: asText(row.taskContent),
overview: asText(row.overview),
extra: asExtra(row.extra),
taskType: normalizeB51TaskType(asText(row.taskType)) || normalizedType || undefined,
};
return detail.taskName && detail.taskContent ? detail : null;
} catch (error) {
console.warn("[b5-1-deeplink] task detail request failed", { id, normalizedType, error });
return null;
}
}
export function persistB51TaskDetail(detail: B51TaskDetail): void {
try {
sessionStorage.setItem(B5_1_DETAIL_STORAGE_KEY, JSON.stringify(detail));
} catch {
/* noop */
}
}
export function readB51TaskDetail(): B51TaskDetail | null {
try {
const raw = sessionStorage.getItem(B5_1_DETAIL_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<B51TaskDetail>;
const id = asText(parsed.id).trim();
if (!id) return null;
return {
type: B5_1_GOPATH,
id,
taskName: asText(parsed.taskName),
taskContent: asText(parsed.taskContent),
overview: asText(parsed.overview),
extra: asExtra(parsed.extra),
taskType: normalizeB51TaskType(asText(parsed.taskType)) || undefined,
};
} catch {
return null;
}
}
export function buildB51OpeningText(detail: B51TaskDetail, taskId: string): string {
return [
detail.taskName ? `任务名称:${detail.taskName}` : "",
detail.taskContent ? `任务详情:${detail.taskContent}` : "",
detail.overview ? `任务概述:${detail.overview}` : "",
taskId ? `任务id(task_id)为${taskId}` : "",
]
.filter(Boolean)
.join("\n");
}
/** 「业务链条管理」b5-1 行配置的智能体(旧链接未带 ?agentId= 时的回退)。 */
async function resolveB51MappedAgentId(): Promise<string> {
const mappings = await listBusinessMappings();
return mappings
.find((item) => item.businessCode.trim().toLowerCase() === B5_1_GOPATH)
?.agentId?.trim() ?? "";
}
/**
* 建立任务上下文后进入独立的任务分析工作区。
* 智能体优先取深链 ``?agentId=``,未传时回退配置管理中 b5-1 行;
* ``?type=`` 业务类型随上下文与快照登记,聊天记录按 taskId + type 分区;
* ``?theme=`` 主题色随深链会话登记(不传则由 b5-1-theme 默认深蓝)。
*/
export async function resolveB51DeeplinkTarget(
detail: B51TaskDetail,
taskId: string,
opts?: { agentId?: string; taskType?: string; theme?: string },
): Promise<TaskDeeplinkTarget | null> {
const urlAgentId = (opts?.agentId ?? "").trim();
const agentId = urlAgentId || (await resolveB51MappedAgentId());
if (!agentId) return null;
const taskType = normalizeB51TaskType(opts?.taskType);
persistB51Theme(opts?.theme ?? "");
clearTaskContext();
persistB51TaskDetail(taskType ? { ...detail, taskType } : detail);
persistTaskContext({
taskId,
agentId,
openingText: buildB51OpeningText(detail, taskId),
goPath: B5_1_GOPATH,
...(taskType ? { taskType } : {}),
});
return { path: `${B5_1_TASK_BASE}/agent/${agentId}/chats` };
}
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
B51_DEFAULT_THEME,
persistB51Theme,
resolveB51ForcedTheme,
resolveB51ThemeName,
} from "./b5-1-theme.ts";
const WORKSPACE = "/page/strategy/task-management/plan-task-b5-1/agent/analyst/chats";
test("non-b5-1 routes are untouched", () => {
assert.equal(resolveB51ForcedTheme("/page/workspace/chats/new"), undefined);
assert.equal(resolveB51ForcedTheme("/login"), undefined);
assert.equal(resolveB51ForcedTheme("/login?goPath=3qfx&taskId=1"), undefined);
assert.equal(resolveB51ForcedTheme("/login?goPath=agentfx"), undefined);
});
test("workspace defaults to deep blue (dark) without a theme param", () => {
assert.equal(resolveB51ForcedTheme(WORKSPACE), "dark");
assert.equal(resolveB51ForcedTheme(WORKSPACE, "?threadId=t1"), "dark");
assert.equal(B51_DEFAULT_THEME, "dark-blue");
});
test("supplied theme wins over the default", () => {
assert.equal(resolveB51ForcedTheme(WORKSPACE, "?theme=lightblue"), "light");
assert.equal(resolveB51ForcedTheme(WORKSPACE, "?theme=light"), "light");
assert.equal(resolveB51ForcedTheme(WORKSPACE, "?theme=dark-blue"), "dark");
assert.equal(resolveB51ForcedTheme(WORKSPACE, "?theme=%E6%B7%B1%E8%93%9D"), "dark");
});
test("b5-1 login page is forced too, case-insensitive on goPath", () => {
assert.equal(
resolveB51ForcedTheme("/login", "?taskId=1&goPath=b5-1"),
"dark",
);
assert.equal(
resolveB51ForcedTheme("/login", "?taskId=1&goPath=B5-1&theme=lightblue"),
"light",
);
});
test("theme resolution order falls back to the default without storage", () => {
assert.equal(resolveB51ThemeName(), "dark-blue");
assert.equal(resolveB51ThemeName("?theme=iceblue"), "iceblue");
// persistB51Theme 在无 sessionStorage 的环境(node 测试)里静默跳过,
// 浏览器里则登记后由 resolveB51ThemeName() 优先读 URL、其次读登记值。
persistB51Theme("lightgreen");
assert.equal(resolveB51ThemeName("?theme=dark"), "dark");
});
+71
View File
@@ -0,0 +1,71 @@
/**
* b5-1「任务分析」深链的主题色规则。
*
* 产品约定:接入方不传 ``?theme=`` 时**默认深蓝**(映射为 dark 主题),传了
* (如 ``dark-blue``/``lightblue``…)则用传入值经既有嵌入映射解析明暗。
* 主题随深链会话存 sessionStorage(按标签页),登录跳转/刷新后仍生效,
* 且只在 b5-1 工作区路径与 goPath=b5-1 的登录页强制——离开即恢复用户
* 自己的外观偏好,不写 localStorage、不影响其它页面。
*/
import { resolveEmbedThemeMode } from "../embed/theme-mode.mjs";
export const B51_THEME_STORAGE_KEY = "task.b5-1.theme";
/** 不传 theme 时的缺省主题名(经 resolveEmbedThemeMode 映射为深色)。 */
export const B51_DEFAULT_THEME = "dark-blue";
const B51_WORKSPACE_PATH_PREFIX = "/page/strategy/task-management/plan-task-b5-1";
function readThemeParam(search: string): string {
const value = new URLSearchParams(
search.startsWith("?") ? search.slice(1) : search,
).get("theme");
return (value ?? "").trim();
}
function readGoPathParam(search: string): string {
const value = new URLSearchParams(
search.startsWith("?") ? search.slice(1) : search,
).get("goPath");
return (value ?? "").trim().toLowerCase();
}
/** 深链进入任务分析工作区时登记接入方传入的主题(空串 = 用默认)。 */
export function persistB51Theme(rawTheme: string): void {
try {
sessionStorage.setItem(B51_THEME_STORAGE_KEY, (rawTheme ?? "").trim());
} catch {
/* noop */
}
}
function readStoredB51Theme(): string {
try {
return (sessionStorage.getItem(B51_THEME_STORAGE_KEY) ?? "").trim();
} catch {
return "";
}
}
/** 解析 b5-1 深链应使用的原始主题名:URL 参数 > 登记值 > 默认深蓝。 */
export function resolveB51ThemeName(search?: string): string {
const fromUrl = search === undefined ? "" : readThemeParam(search);
if (fromUrl) return fromUrl;
return readStoredB51Theme() || B51_DEFAULT_THEME;
}
/**
* 路由级强制主题:仅 b5-1 工作区路径与 goPath=b5-1 的登录页返回明/暗,
* 其它路径返回 undefined(不影响既有主题行为)。
*/
export function resolveB51ForcedTheme(
pathname: string,
search = "",
): "light" | "dark" | undefined {
const isWorkspace = pathname.startsWith(B51_WORKSPACE_PATH_PREFIX);
const isB51Login =
(pathname === "/login" || pathname.startsWith("/login/")) &&
readGoPathParam(search) === "b5-1";
if (!isWorkspace && !isB51Login) return undefined;
return resolveEmbedThemeMode(resolveB51ThemeName(search));
}
+25 -5
View File
@@ -1,17 +1,37 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { OptType, writeYLog } from "@/core/log/y-log";
import { listAllUsers, setUserApproval, setUserLoginPassword } from "./api";
import { listAllUsers, listUsersPaged, setUserApproval, setUserLoginPassword } from "./api";
export function useAllUsers(enabled: boolean) {
export function useAllUsers(enabled: boolean, includeCounts = true) {
return useQuery({
queryKey: ["admin", "users"],
queryFn: listAllUsers,
// "full"/"lite" 后缀区分缓存;放行/改密后按 ["admin","users"] 前缀失效,两种都刷新。
queryKey: ["admin", "users", includeCounts ? "full" : "lite"],
queryFn: () => listAllUsers(includeCounts),
enabled,
});
}
/** 服务端分页的用户列表(白名单管理页)。q 为搜索词,page 从 1 开始。 */
export function useUsersPage(
enabled: boolean,
params: { q: string; page: number; pageSize: number },
) {
return useQuery({
queryKey: ["admin", "users", "page", params.q, params.page, params.pageSize],
queryFn: ({ signal }) =>
listUsersPaged({
q: params.q.trim() || undefined,
limit: params.pageSize,
offset: (params.page - 1) * params.pageSize,
}, signal),
enabled,
placeholderData: keepPreviousData,
retry: false,
});
}
/** 放行 / 取消放行某个用户(登录白名单),成功后刷新用户列表缓存。 */
export function useSetUserApproval() {
const queryClient = useQueryClient();
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import test from "node:test";
import { beginLoginAttempt, holdLoginAttempt } from "./scoped-storage";
class MemoryStorage {
data = new Map<string, string>();
get length() { return this.data.size; }
key(index: number) { return [...this.data.keys()][index] ?? null; }
getItem(key: string) { return this.data.get(key) ?? null; }
setItem(key: string, value: string) { this.data.set(key, value); }
removeItem(key: string) { this.data.delete(key); }
clear() { this.data.clear(); }
}
function browser() {
const localStorage = new MemoryStorage();
const sessionStorage = new MemoryStorage();
const listeners = new Map<string, Set<(event: Event) => void>>();
const window = {
localStorage,
sessionStorage,
addEventListener(type: string, listener: (event: Event) => void) {
const set = listeners.get(type) ?? new Set();
set.add(listener);
listeners.set(type, set);
},
removeEventListener(type: string, listener: (event: Event) => void) {
listeners.get(type)?.delete(listener);
},
dispatchEvent(event: Event) {
listeners.get(event.type)?.forEach((listener) => listener(event));
return true;
},
setTimeout: globalThis.setTimeout.bind(globalThis),
clearTimeout: globalThis.clearTimeout.bind(globalThis),
} as Window & typeof globalThis;
window.self = window;
window.top = window;
Object.defineProperty(globalThis, "window", { value: window, configurable: true });
return { localStorage, sessionStorage, window };
}
test("a newer login invalidates the previous response without clearing the current session", async () => {
const { localStorage } = browser();
localStorage.setItem("deerflow.auth", "admin");
localStorage.setItem("userInfo", "admin-profile");
const old = beginLoginAttempt();
const pending = Promise.resolve().then(() => {
if (old()) localStorage.setItem("deerflow.auth", "old-token-user");
});
const latest = beginLoginAttempt();
localStorage.setItem("deerflow.auth", "new-token-user");
await pending;
assert.equal(old(), false);
assert.equal(latest(), true);
assert.equal(localStorage.getItem("deerflow.auth"), "new-token-user");
assert.equal(localStorage.getItem("userInfo"), "admin-profile");
});
test("another normal tab starting a login invalidates this tab's pending login", () => {
const { localStorage } = browser();
const pending = beginLoginAttempt();
localStorage.setItem("deerflow.login-attempt", "another-tab-login");
assert.equal(pending(), false);
});
test("another window's late account write is put back while this login still owns the attempt", () => {
const { localStorage, window } = browser();
const current = beginLoginAttempt();
localStorage.setItem("deerflow.auth", "admin");
localStorage.setItem("userInfo", "{\"userName\":\"admin\"}");
holdLoginAttempt(current);
localStorage.setItem("deerflow.auth", "old-token-user");
localStorage.setItem("userInfo", "{\"userName\":\"external-user\"}");
window.dispatchEvent({
type: "storage",
key: "deerflow.auth",
newValue: "old-token-user",
storageArea: localStorage,
} as unknown as Event);
assert.equal(localStorage.getItem("deerflow.auth"), "admin");
assert.equal(localStorage.getItem("userInfo"), "{\"userName\":\"admin\"}");
});
test("a newer login in the other window is kept", () => {
const { localStorage, window } = browser();
const first = beginLoginAttempt();
localStorage.setItem("deerflow.auth", "admin");
holdLoginAttempt(first);
beginLoginAttempt();
localStorage.setItem("deerflow.auth", "new-token-user");
window.dispatchEvent({
type: "storage",
key: "deerflow.auth",
newValue: "new-token-user",
storageArea: localStorage,
} as unknown as Event);
assert.equal(localStorage.getItem("deerflow.auth"), "new-token-user");
});
test("storage failure stops the login instead of keeping a stale writer", () => {
const { localStorage } = browser();
localStorage.setItem("deerflow.auth", "old-user");
localStorage.setItem = () => { throw new Error("storage unavailable"); };
assert.throws(() => beginLoginAttempt(), /storage unavailable/);
assert.equal(localStorage.getItem("deerflow.auth"), "old-user");
});
+43 -1
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { accountPageSearch, mergeLoginSearch, shouldStartSso, shouldUseStoredTicket } from "./login-entry";
import { accountPageSearch, claimSsoLoginStart, loginParamsForEntry, mergeLoginSearch, shouldStartSso, shouldUseStoredTicket } from "./login-entry";
const options = { password_login_enabled: true, registration_enabled: true, sso_enabled: true };
@@ -12,6 +12,18 @@ test("password entry never starts a guest login; explicit SSO still works", () =
assert.equal(shouldStartSso({ ...options, password_login_enabled: false }, true, false), true);
});
test("repeated automatic-login effect starts one request; explicit retry can claim the guard again", () => {
const started = { current: false };
assert.equal(claimSsoLoginStart(started), true);
// React StrictMode and dependency-driven effect reruns use the same ref.
assert.equal(claimSsoLoginStart(started), false);
assert.equal(claimSsoLoginStart(started), false);
// LoginPage resets the ref only when the user explicitly retries.
started.current = false;
assert.equal(claimSsoLoginStart(started), true);
});
test("expired sessions wait for an explicit re-login instead of repeatedly kicking the other client", () => {
const legacy = { ...options, password_login_enabled: false };
assert.equal(shouldStartSso(legacy, false, false, true), false);
@@ -19,6 +31,35 @@ test("expired sessions wait for an explicit re-login instead of repeatedly kicki
assert.equal(shouldStartSso(legacy, false, false, false), true);
});
test("username password entry drops an earlier external token left before the hash", () => {
const params = loginParamsForEntry(
"password=123ewq",
"?authToken=old-token&yUserId=old-user&ticket=old-ticket&token=old-tools&access_token=old-access&userId=old-id&theme=dark-blue",
"admin",
);
assert.equal(params.get("password"), "123ewq");
assert.equal(params.get("theme"), "dark-blue");
for (const key of ["authToken", "yUserId", "ticket", "token", "access_token", "userId"]) {
assert.equal(params.get(key), null, key);
}
});
test("identity params inside the password route itself are kept", () => {
const params = loginParamsForEntry(
"password=123ewq&authToken=this-entry&yUserId=this-user",
"?authToken=old-token&yUserId=old-user",
"admin",
);
assert.equal(params.get("authToken"), "this-entry");
assert.equal(params.get("yUserId"), "this-user");
});
test("outer token stays for a bare entry, and a path username only drops it when a password is present", () => {
assert.equal(loginParamsForEntry("", "?authToken=outer", undefined).get("authToken"), "outer");
assert.equal(loginParamsForEntry("", "?authToken=outer", "admin").get("authToken"), "outer");
assert.equal(loginParamsForEntry("", "?authToken=outer&password=123ewq", "admin").get("authToken"), null);
});
test("external login credentials before the hash are read, and route values take precedence", () => {
const params = mergeLoginSearch("?authToken=current&password=&taskId=42", "?authToken=stale&password=old&ticket=t&theme=dark-blue");
assert.equal(params.get("authToken"), "current");
@@ -32,6 +73,7 @@ test("external login credentials before the hash are read, and route values take
test("stored tickets cannot override explicit login links or password entry", () => {
assert.equal(shouldUseStoredTicket(options, false), false);
assert.equal(shouldUseStoredTicket({ ...options, password_login_enabled: false }, true), false);
assert.equal(shouldUseStoredTicket({ ...options, password_login_enabled: false }, false, true), false);
assert.equal(shouldUseStoredTicket({ ...options, password_login_enabled: false }, false), true);
assert.equal(shouldUseStoredTicket({ ...options, password_login_enabled: false, sso_enabled: false }, false), false);
assert.equal(shouldUseStoredTicket(null, false), false);
+34 -1
View File
@@ -11,6 +11,13 @@ export function shouldStartSso(
(explicitSso || !options.password_login_enabled));
}
/** Claim the one automatic SSO login started by a mounted LoginPage. */
export function claimSsoLoginStart(started: { current: boolean }): boolean {
if (started.current) return false;
started.current = true;
return true;
}
/** External callers put login parameters before or after the hash route. */
export function mergeLoginSearch(routeSearch: string, outerSearch: string): URLSearchParams {
const params = new URLSearchParams(outerSearch);
@@ -20,6 +27,31 @@ export function mergeLoginSearch(routeSearch: string, outerSearch: string): URLS
return params;
}
const OUTER_IDENTITY_PARAMS = ["authToken", "yUserId", "ticket", "token", "access_token", "userId"] as const;
/**
* `/login/:username?password=` names the account. Identity params left in front
* of the hash by an earlier external-token entry must not sign that user back
* in. The same keys written inside the hash route are still honored.
*/
export function loginParamsForEntry(
routeSearch: string,
outerSearch: string,
routeUsername?: string | null,
): URLSearchParams {
const params = mergeLoginSearch(routeSearch, outerSearch);
const route = new URLSearchParams(routeSearch);
const username = routeUsername?.trim() ?? "";
const password = route.has("password")
? (route.get("password") ?? "")
: (new URLSearchParams(outerSearch).get("password") ?? "");
if (!username || !password) return params;
for (const key of OUTER_IDENTITY_PARAMS) {
if (!route.has(key)) params.delete(key);
}
return params;
}
export const LOGIN_CREDENTIAL_PARAMS = [
"ticket", "userId", "yUserId", "token", "access_token", "password", "authToken", "routeKey",
] as const;
@@ -28,8 +60,9 @@ export const LOGIN_CREDENTIAL_PARAMS = [
export function shouldUseStoredTicket(
options: AuthenticationOptions | null,
explicitSso: boolean,
hasCurrentSession = false,
): boolean {
return Boolean(options?.sso_enabled && !options.password_login_enabled && !explicitSso);
return Boolean(options?.sso_enabled && !options.password_login_enabled && !explicitSso && !hasCurrentSession);
}
/** Keep the destination/presentation when switching forms; discard credentials. */
@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import test from "node:test";
import { fetchWithBindingRetry, isBrowserBindingAbort } from "./login-transport";
test("recognizes the Firefox binding abort and a dropped fetch", () => {
assert.equal(isBrowserBindingAbort(new TypeError("NetworkError when attempting to fetch resource.")), true);
assert.equal(isBrowserBindingAbort(Object.assign(new Error("NS_BINDING_ABORTED"), { name: "NS_ERROR_NET_RESET" })), true);
const timeout = new Error("The operation was aborted");
timeout.name = "AbortError";
assert.equal(isBrowserBindingAbort(timeout), false);
assert.equal(isBrowserBindingAbort(new Error("口令错误")), false);
});
test("retries a binding abort once", async () => {
let calls = 0;
const response = await fetchWithBindingRetry(async () => {
calls += 1;
if (calls === 1) throw new TypeError("Failed to fetch");
return new Response("ok");
});
assert.equal(calls, 2);
assert.equal(await response.text(), "ok");
});
test("does not retry an application error", async () => {
let calls = 0;
await assert.rejects(
() => fetchWithBindingRetry(async () => {
calls += 1;
throw new Error("口令错误");
}),
/口令错误/,
);
assert.equal(calls, 1);
});
@@ -0,0 +1,23 @@
/**
* Old Firefox cancels a credentialed POST that needed a CORS preflight and
* reports NS_BINDING_ABORTED. Fetch rejects that as a TypeError, not an
* HTTP response. A caller-owned AbortController uses AbortError instead, so
* an intentional timeout is not retried here.
*/
export function isBrowserBindingAbort(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const text = `${error.name} ${error.message}`.toLowerCase();
if (text.includes("ns_binding_aborted") || text.includes("binding aborted")) return true;
if (error.name !== "TypeError") return false;
return /networkerror|failed to fetch|load failed|network error/.test(text);
}
export async function fetchWithBindingRetry(run: () => Promise<Response>): Promise<Response> {
try {
return await run();
} catch (error) {
if (!isBrowserBindingAbort(error)) throw error;
await new Promise((resolve) => setTimeout(resolve, 300));
return await run();
}
}
+17 -20
View File
@@ -9,7 +9,13 @@ import {
getStoredAuth,
setStoredAuth,
} from "./index";
import { checkAccountAccess, isSameAccountSession, type AccountAccess } from "./session-validation";
import {
checkAccountAccess,
isRecentlyAuthenticatedLogin,
isSameAccountIdentity,
isSameAccountSession,
type AccountAccess,
} from "./session-validation";
type GateState = (Extract<AccountAccess, { status: "allowed" }> & { warning?: string })
| Extract<AccountAccess, { status: "unauthorized" }>
@@ -24,7 +30,6 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
const [attempt, setAttempt] = useState(0);
const [state, setState] = useState<GateState>({ status: "checking" });
const writingVerifiedAccount = useRef(false);
const requestInFlight = useRef(false);
useEffect(() => {
const refresh = () => {
@@ -34,21 +39,11 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
if (!isEmbedActive() && !isIsolatedSession()
&& (event.key === "deerflow.auth" || event.key === null)) refresh();
};
const recheck = () => { if (!requestInFlight.current) refresh(); };
const onVisibility = () => { if (document.visibilityState === "visible") recheck(); };
window.addEventListener(AUTH_STATE_CHANGED_EVENT, refresh);
window.addEventListener("storage", onStorage);
window.addEventListener("focus", recheck);
document.addEventListener("visibilitychange", onVisibility);
// A different browser can revoke this account's session. Also refresh the
// policy so enabling login or single-session mode takes effect in open tabs.
const timer = setInterval(onVisibility, 15_000);
return () => {
window.removeEventListener(AUTH_STATE_CHANGED_EVENT, refresh);
window.removeEventListener("storage", onStorage);
window.removeEventListener("focus", recheck);
document.removeEventListener("visibilitychange", onVisibility);
clearInterval(timer);
};
}, []);
@@ -56,12 +51,16 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
const controller = new AbortController();
const requestedAuth = getStoredAuth();
let disposed = false;
requestInFlight.current = true;
if (isRecentlyAuthenticatedLogin(requestedAuth)) {
setState({ status: "allowed", auth: requestedAuth });
return () => controller.abort();
}
const timeout = setTimeout(() => controller.abort(), 20_000);
// A background recheck must not unmount an active editor on every poll.
// A changed account/token still blocks immediately until its own check ends.
// Keep the protected tree mounted while a renewed token for the same
// account is verified. Unmounting here destroys nested iframes and resets
// an active chat even though the user's identity did not change.
setState((previous) => previous.status === "allowed"
&& JSON.stringify(previous.auth) === JSON.stringify(requestedAuth)
&& isSameAccountIdentity(previous.auth, requestedAuth)
? previous : { status: "checking" });
void checkAccountAccess({
@@ -92,16 +91,14 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
: error instanceof TypeError ? "无法连接服务器,请检查网络后重试"
: error instanceof Error ? error.message : "暂时无法验证登录状态,请稍后重试";
setState((previous) => previous.status === "allowed"
&& JSON.stringify(previous.auth) === JSON.stringify(requestedAuth)
&& isSameAccountIdentity(previous.auth, requestedAuth)
&& isSameAccountSession(getStoredAuth(), requestedAuth)
? { ...previous, warning: message } : { status: "error", message });
}).finally(() => {
clearTimeout(timeout);
if (!disposed) requestInFlight.current = false;
});
return () => {
disposed = true;
requestInFlight.current = false;
controller.abort();
clearTimeout(timeout);
};
@@ -119,7 +116,7 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
onClick={() => setAttempt((value) => value + 1)}>重新验证</button>
</div>;
}
if (state.status === "allowed" && JSON.stringify(state.auth) === JSON.stringify(getStoredAuth())) {
if (state.status === "allowed" && isSameAccountIdentity(state.auth, getStoredAuth())) {
return <div className="contents">
{children}
{state.warning && <div role="alert" className="fixed right-4 bottom-4 z-50 flex max-w-md items-center gap-3 rounded-md border bg-background p-4 text-sm shadow-lg">
@@ -69,6 +69,80 @@ function isProtectedAuthKey(key: string): boolean {
return PROTECTED_AUTH_EXACT_KEYS.includes(key) || key.startsWith(PROTECTED_AUTH_PREFIX);
}
const LOGIN_ATTEMPT_KEY = "deerflow.login-attempt";
/**
* Mark the login that is allowed to write this tab's auth storage.
*
* A newer call, including one from another normal tab, replaces the marker.
* The previous login keeps its already issued session until it is actually
* replaced; callers must consult the returned check immediately before writing
* so a late token or profile response cannot put the old user back.
* Embed and isolated tabs use their own sessionStorage marker and do not
* cancel a normal tab's login.
*/
export function beginLoginAttempt(): () => boolean {
const storage = authStorage();
if (!storage) {
throw new Error("浏览器无法保存登录信息,请允许网站存储后重试。");
}
const attempt = `${Date.now()}-${Math.random()}-${Math.random()}`;
storage.setItem(LOGIN_ATTEMPT_KEY, attempt);
return () => authStorage() === storage && storage.getItem(LOGIN_ATTEMPT_KEY) === attempt;
}
const LOGIN_HOLD_MS = 8_000;
const EXTRA_HELD_KEYS = ["ylog.token1"];
function isHeldAuthKey(key: string): boolean {
return isProtectedAuthKey(key) || EXTRA_HELD_KEYS.includes(key);
}
/**
* Keep the account just written by this login if another window's late response
* puts the previous account back. A newer login replaces the attempt marker and
* this hold then steps aside.
*/
export function holdLoginAttempt(isCurrent: () => boolean): void {
const storage = authStorage();
if (!storage || typeof window === "undefined" || !isCurrent()) return;
const snapshot = new Map<string, string | null>();
for (let i = 0; i < storage.length; i += 1) {
const key = storage.key(i);
if (key && isHeldAuthKey(key)) snapshot.set(key, storage.getItem(key));
}
for (const key of [...PROTECTED_AUTH_EXACT_KEYS, ...EXTRA_HELD_KEYS]) {
if (!snapshot.has(key)) snapshot.set(key, null);
}
const restore = () => {
if (!isCurrent()) return;
for (let i = storage.length - 1; i >= 0; i -= 1) {
const key = storage.key(i);
if (!key || !isHeldAuthKey(key)) continue;
const expected = snapshot.has(key) ? snapshot.get(key) ?? null : null;
if (expected === null) storage.removeItem(key);
else if (storage.getItem(key) !== expected) storage.setItem(key, expected);
}
snapshot.forEach((value, key) => {
if (value !== null && storage.getItem(key) !== value) storage.setItem(key, value);
});
};
const onStorage = (event: StorageEvent) => {
if (!isCurrent()) {
window.removeEventListener("storage", onStorage);
return;
}
if (event.storageArea !== storage || !event.key || !isHeldAuthKey(event.key)) return;
const expected = snapshot.has(event.key) ? snapshot.get(event.key) ?? null : null;
if (event.newValue === expected) return;
restore();
};
window.addEventListener("storage", onStorage);
const timer = window.setTimeout(() => window.removeEventListener("storage", onStorage), LOGIN_HOLD_MS);
(timer as unknown as { unref?: () => void }).unref?.();
}
/**
* 把 localStorage 里的鉴权 / 身份键**快照复制**进 sessionStorage(覆盖式)。
*
@@ -1,7 +1,13 @@
import assert from "node:assert/strict";
import test from "node:test";
import { checkAccountAccess, isSameAccountSession } from "./session-validation";
import {
checkAccountAccess,
isRecentlyAuthenticatedLogin,
isSameAccountIdentity,
isSameAccountSession,
rememberSuccessfulLogin,
} from "./session-validation";
const auth = {
access_token: "browser-token", token_type: "bearer", expires_in: 3600,
@@ -9,63 +15,63 @@ const auth = {
};
const user = { id: "user-1", email: "actual@example.com", system_role: "user", needs_setup: false };
function server(enabled: boolean, me: Response = Response.json(user), singleSession = false) {
function server(me: Response = Response.json(user)) {
const calls: { url: string; init?: RequestInit }[] = [];
const fetcher: typeof fetch = async (input, init) => {
calls.push({ url: String(input), init });
return String(input).endsWith("/options")
? Response.json({ password_login_enabled: enabled, single_session_enabled: singleSession }) : me;
return me;
};
return { calls, fetcher };
}
test("enabled login rejects anonymous accounts including standalone mode before /me", async () => {
const { fetcher, calls } = server(true);
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", allowLegacyAnonymous: true, fetcher }), { status: "unauthorized" });
assert.equal(calls.length, 1);
test("anonymous access is decided locally without fetching login options", async () => {
const { fetcher, calls } = server();
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", fetcher }), { status: "unauthorized" });
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", allowLegacyAnonymous: true, fetcher }), { status: "allowed", auth: null });
assert.equal(calls.length, 0);
});
test("a cached token never grants access before /me validates it", async () => {
const { fetcher, calls } = server(true, new Response(null, { status: 401 }));
const { fetcher, calls } = server(new Response(null, { status: 401 }));
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "https://gateway", fetcher }), { status: "unauthorized", reason: "session-expired" });
assert.equal(calls[1]?.url, "https://gateway/api/v1/auth/me");
assert.equal(new Headers(calls[1]?.init?.headers).get("Authorization"), "Bearer browser-token");
assert.equal(calls[1]?.init?.cache, "no-store");
assert.equal(calls[0]?.url, "https://gateway/api/v1/auth/me");
assert.equal(new Headers(calls[0]?.init?.headers).get("Authorization"), "Bearer browser-token");
assert.equal(calls[0]?.init?.cache, "no-store");
});
test("verified server permissions replace claimed local permissions", async () => {
const { fetcher } = server(true);
const { fetcher } = server();
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), {
status: "allowed", auth: { ...auth, email: user.email, system_role: user.system_role, needs_setup: false },
});
});
test("a token for another account cannot authenticate the cached account", async () => {
const { fetcher } = server(true, Response.json({ ...user, id: "another-user" }));
const { fetcher } = server(Response.json({ ...user, id: "another-user" }));
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), { status: "unauthorized", reason: "session-expired" });
});
test("disabled login preserves legacy sessions and the standalone anonymous entry", async () => {
const { fetcher, calls } = server(false);
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), { status: "allowed", auth });
test("legacy anonymous access does not make a session request", async () => {
const { fetcher, calls } = server();
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", fetcher }), { status: "unauthorized" });
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", allowLegacyAnonymous: true, fetcher }), { status: "allowed", auth: null });
assert.ok(calls.every(({ url }) => url.endsWith("/options")));
assert.equal(calls.length, 0);
});
test("outages and invalid policy responses fail closed without treating the session as invalid", async () => {
const { fetcher } = server(true, new Response(null, { status: 503 }));
test("outages and invalid user responses fail closed without treating the session as invalid", async () => {
const { fetcher } = server(new Response(null, { status: 503 }));
await assert.rejects(checkAccountAccess({ auth, baseURL: "", fetcher }), /无法验证登录/);
await assert.rejects(checkAccountAccess({ auth, baseURL: "", fetcher: async () => Response.json({}) }), /无法读取登录设置/);
await assert.rejects(checkAccountAccess({ auth, baseURL: "", fetcher: async () => Response.json({}) }), /登录信息不完整/);
await assert.rejects(checkAccountAccess({ auth, baseURL: "", fetcher: async () => { throw new TypeError("offline"); } }), /offline/);
});
test("single-session mode detects revoked sessions even with the password page disabled", async () => {
const { fetcher, calls } = server(false, new Response(null, { status: 401 }), true);
test("revoked sessions are detected through the existing session endpoint", async () => {
const { fetcher, calls } = server(new Response(null, { status: 401 }));
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), {
status: "unauthorized", reason: "session-expired",
});
assert.ok(calls.some(({ url }) => url.endsWith("/me")));
assert.equal(calls.length, 1);
assert.ok(calls[0].url.endsWith("/me"));
});
test("late failures cannot clear a renewed token or a different account", () => {
@@ -74,3 +80,33 @@ test("late failures cannot clear a renewed token or a different account", () =>
assert.equal(isSameAccountSession({ ...auth, user_id: "another-user" }, auth), false);
assert.equal(isSameAccountSession(null, auth), false);
});
test("a successful login response avoids a redundant immediate /me round trip only briefly", () => {
const login = { ...auth, access_token: "fresh-login-token" };
const now = 1_000_000;
rememberSuccessfulLogin(login, now);
assert.equal(isRecentlyAuthenticatedLogin(login, now + 1), true);
assert.equal(isRecentlyAuthenticatedLogin({ ...login, user_id: "another-user" }, now + 1), false);
assert.equal(isRecentlyAuthenticatedLogin(login, now + 15_001), false);
});
test("same-account token renewal preserves the mounted account identity", () => {
assert.equal(isSameAccountIdentity({ ...auth }, auth), true);
assert.equal(isSameAccountIdentity({ ...auth, access_token: "renewed" }, auth), true);
assert.equal(
isSameAccountIdentity(
{ ...auth, user_id: "migrated-user", email: "claimed@new-domain.example" },
auth,
),
true,
);
assert.equal(
isSameAccountIdentity(
{ ...auth, access_token: "other", email: "another@example.com" },
auth,
),
false,
);
assert.equal(isSameAccountIdentity(null, null), true);
assert.equal(isSameAccountIdentity(null, auth), false);
});
@@ -1,7 +1,28 @@
import type { LoginByUsernameResponse } from "./index";
import { getAccountIdentityKey, type LoginByUsernameResponse } from "./index";
type StoredAccount = LoginByUsernameResponse | null;
const RECENT_LOGIN_TTL_MS = 15_000;
const recentSuccessfulLogins = new Map<string, { userId: string; expiresAt: number }>();
/** A fresh login response is already server-validated; avoid an immediate duplicate `/me`. */
export function rememberSuccessfulLogin(auth: LoginByUsernameResponse, now = Date.now()): void {
if (!hasAccountSession(auth)) return;
recentSuccessfulLogins.set(auth.access_token, {
userId: auth.user_id,
expiresAt: now + RECENT_LOGIN_TTL_MS,
});
}
export function isRecentlyAuthenticatedLogin(auth: StoredAccount, now = Date.now()): boolean {
if (!hasAccountSession(auth)) return false;
for (const [token, record] of recentSuccessfulLogins) {
if (record.expiresAt <= now) recentSuccessfulLogins.delete(token);
}
const record = recentSuccessfulLogins.get(auth.access_token);
return Boolean(record && record.expiresAt > now && record.userId === auth.user_id);
}
export type AccountAccess =
| { status: "allowed"; auth: StoredAccount }
| { status: "unauthorized"; reason?: "session-expired" };
@@ -11,12 +32,27 @@ export function isSameAccountSession(current: StoredAccount, requested: StoredAc
return current?.access_token === requested?.access_token && current?.user_id === requested?.user_id;
}
/**
* Compare the stable account principal while deliberately ignoring a renewed
* access token. A same-account login in another tab must be revalidated, but
* it must not tear down the already mounted workspace (and its iframe tree)
* while that validation is in flight.
*/
export function isSameAccountIdentity(current: StoredAccount, requested: StoredAccount): boolean {
if (current === requested) return true;
if (!current || !requested) return false;
const currentIdentity = getAccountIdentityKey(current);
const requestedIdentity = getAccountIdentityKey(requested);
return currentIdentity !== null && currentIdentity === requestedIdentity;
}
export function hasAccountSession(auth: StoredAccount): auth is LoginByUsernameResponse {
return Boolean(typeof auth?.user_id === "string" && auth.user_id.trim()
&& typeof auth.access_token === "string" && auth.access_token.trim());
}
/** The server owns both the login policy and the account's current permissions. */
/** Validate an existing session without fetching login options on page entry. */
export async function checkAccountAccess({
auth,
baseURL,
@@ -30,25 +66,10 @@ export async function checkAccountAccess({
allowLegacyAnonymous?: boolean;
fetcher?: typeof fetch;
}): Promise<AccountAccess> {
const optionsResponse = await fetcher(`${baseURL}/api/v1/auth/options`, {
credentials: "include", cache: "no-store", signal,
});
if (!optionsResponse.ok) throw new Error("无法读取登录设置,请稍后重试");
const options: unknown = await optionsResponse.json();
if (!options || typeof options !== "object"
|| !("password_login_enabled" in options)
|| typeof options.password_login_enabled !== "boolean") {
throw new Error("无法读取登录设置,请稍后重试");
if (!hasAccountSession(auth)) {
return allowLegacyAnonymous ? { status: "allowed", auth: null } : { status: "unauthorized" };
}
const singleSessionEnabled = "single_session_enabled" in options && options.single_session_enabled === true;
if (!options.password_login_enabled && !singleSessionEnabled) {
return allowLegacyAnonymous || hasAccountSession(auth)
? { status: "allowed", auth }
: { status: "unauthorized" };
}
if (!hasAccountSession(auth)) return { status: "unauthorized" };
const response = await fetcher(`${baseURL}/api/v1/auth/me`, {
credentials: "include",
cache: "no-store",
+4 -1
View File
@@ -75,7 +75,10 @@ export function AuthSyncWatcher({
id: IDENTITY_TOAST_ID,
description: "当前页面已同步退出状态。",
});
navigate("/login", { replace: true });
// A bare legacy /login entry auto-starts the guest SSO flow. Cross-tab
// logout must stop at the explicit re-login screen instead of silently
// creating a guest session that can overwrite the account in another tab.
navigate("/login?reason=session-expired", { replace: true });
return;
}
@@ -0,0 +1,155 @@
import assert from "node:assert/strict";
import test from "node:test";
import { requestCrudService } from "@/service/request-axios-service";
import {
buildTaskOpeningText,
ensureNarrativeSummary,
fetchTaskActionStructure,
} from "./task-deeplink";
const CONSUMER = "https://ch1.b.uat.4.cn/consumer";
const memory = new Map<string, string>();
Object.assign(globalThis, {
window: {
__CM_RUNTIME_CONFIG__: { VITE_AIPI_CONSUMER_URL: CONSUMER },
},
sessionStorage: {
getItem: (key: string) => memory.get(key) ?? null,
setItem: (key: string, value: string) => {
memory.set(key, String(value));
},
removeItem: (key: string) => {
memory.delete(key);
},
clear: () => {
memory.clear();
},
key: (index: number) => [...memory.keys()][index] ?? null,
get length() {
return memory.size;
},
},
});
interface RecordedCall {
method: "GET";
endpoint: string;
params: Record<string, unknown>;
}
const calls: RecordedCall[] = [];
const actionDetails: Record<string, unknown> = {
"9001": {
state: 200,
msg: "操作成功",
data: {
actionId: "9001",
purpose: null,
actor: null,
expectedBehavior: null,
drivingFactor: null,
narrationList: null,
},
},
"9002": {
state: 200,
msg: "操作成功",
data: {
actionId: "9002",
purpose: "扩大影响",
actor: "媒体账号",
expectedBehavior: "转发评论",
drivingFactor: "认同感",
narrationList: ["叙事03"],
},
},
};
requestCrudService.get = async (endpoint: string, params: Record<string, unknown> = {}) => {
calls.push({ method: "GET", endpoint, params: { ...params } });
if (endpoint.endsWith("/task-action/actionDetail")) {
const actionId = String(params.actionId ?? "");
const body = actionDetails[actionId];
if (!body) throw new Error(`no mock for actionId=${actionId}`);
return body;
}
if (endpoint.endsWith("/mainnets/narratives-by-name")) {
return {
state: 200,
msg: "操作成功",
data: { summary: "这条传播网主要讨论暑期文旅消费。" },
};
}
throw new Error(`unexpected GET ${endpoint}`);
};
function queryString(params: Record<string, unknown>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
search.set(key, String(value));
}
const text = search.toString();
return text ? `?${text}` : "";
}
test("模拟 xdfx 行动结构与传播网简介的 consumer 调用", async () => {
const task = {
id: "2055",
overview: "",
taskDirection: "",
taskName: "任务01:暑期文旅舆情综合研判",
taskContent: "围绕暑期文旅消费高峰做综合研判。",
taskType: "1",
cbwName: "暑期文旅传播网",
};
await fetchTaskActionStructure("9001");
await ensureNarrativeSummary("9001", task.cbwName);
const missingPurpose = buildTaskOpeningText(task, "2055", {
actionId: "9001",
actionName: "暑期引导行动",
narration: "引导公众讨论文旅消费。",
cbwId: "88",
cbwName: task.cbwName,
});
await fetchTaskActionStructure("9002");
await ensureNarrativeSummary("9002", task.cbwName);
const complete = buildTaskOpeningText(task, "2055", {
actionId: "9002",
actionName: "暑期引导行动",
});
console.log("\n--- 模拟调用 ---");
for (const call of calls) {
console.log(`${call.method} ${call.endpoint}${queryString(call.params)}`);
console.log(`params ${JSON.stringify(call.params)}`);
}
console.log("\n--- purpose 为 null 时的用户消息 ---");
console.log(missingPurpose);
console.log("\n--- purpose 和 actor 都有值时的用户消息 ---");
console.log(complete);
console.log("---");
assert.deepEqual(
calls.map((call) => ({ endpoint: call.endpoint, params: call.params })),
[
{ endpoint: `${CONSUMER}/task-action/actionDetail`, params: { actionId: "9001" } },
{
endpoint: `${CONSUMER}/mainnets/narratives-by-name`,
params: { cbwName: "暑期文旅传播网" },
},
{ endpoint: `${CONSUMER}/task-action/actionDetail`, params: { actionId: "9002" } },
],
);
for (const label of ["目的:", "行为体:", "预期行为:", "驱动因素:", "叙事:"]) {
assert.equal(missingPurpose.includes(label), false, label);
}
assert.match(missingPurpose, /传播网简介:这条传播网主要讨论暑期文旅消费。/);
assert.equal(complete.includes("传播网简介"), false);
assert.match(complete, /目的:扩大影响/);
assert.match(complete, /叙事:叙事03/);
});
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
actionNeedsNarrative,
parseNarrativeSummary,
parseTaskActionStructure,
taskActionStructureLines,
} from "./task-deeplink";
test("purpose 或 actor 为 null 时需要传播网简介", () => {
const missingPurpose = parseTaskActionStructure(
{
state: 200,
msg: "操作成功",
data: {
actionId: "9",
purpose: null,
actor: "媒体",
expectedBehavior: "转发",
drivingFactor: "认同",
narrationList: ["叙事01", "叙事02"],
},
},
"9",
);
assert.equal(missingPurpose?.purpose, null);
assert.equal(missingPurpose?.actor, "媒体");
assert.equal(actionNeedsNarrative(missingPurpose), true);
assert.equal(missingPurpose?.narration, "叙事01;叙事02");
const bothPresent = parseTaskActionStructure(
{
state: 200,
data: { purpose: "扩大影响", actor: "媒体", narrationList: [] },
},
"9",
);
assert.equal(actionNeedsNarrative(bothPresent), false);
});
test("目的和行为体等字段为 null 时不写入用户消息", () => {
const detail = parseTaskActionStructure(
{
state: 200,
data: {
actionId: "9",
purpose: null,
actor: null,
expectedBehavior: null,
drivingFactor: null,
narrationList: null,
},
},
"9",
);
assert.ok(detail);
detail.narrativeSummary = "这条传播网主要讨论暑期文旅。";
assert.deepEqual(taskActionStructureLines(detail), ["传播网简介:这条传播网主要讨论暑期文旅。"]);
});
test("传播网简介拼进用户消息,空简介不占行", () => {
const summary = parseNarrativeSummary({
state: "200",
msg: "操作成功",
data: { summary: "这条传播网主要讨论暑期文旅。" },
});
assert.equal(summary, "这条传播网主要讨论暑期文旅。");
const lines = taskActionStructureLines({
actionId: "9",
purpose: null,
actor: "媒体",
expectedBehavior: "",
drivingFactor: "",
narration: "",
narrativeSummary: summary,
});
assert.deepEqual(lines, ["行为体:媒体", "传播网简介:这条传播网主要讨论暑期文旅。"]);
});
+1 -1
View File
@@ -61,7 +61,7 @@ export const TASK_BUSINESSES: {
},
{
code: "3qfx",
name: "三情分析",
name: "三情研判",
goPath: "3qfx",
desc: "精简版工作区(无目的树闸门),通过 ?goPath=3qfx 进入。",
},
+434 -22
View File
@@ -5,7 +5,8 @@
* 流程:
* 1. 调 consumer 接口 ``GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=``,
* 请求头 ``Authorization: Bearer admin``(由 service 拦截器统一注入)。
* 2. 把返回的 data(id/overview/taskDirection/taskName/taskContent)整包存到 sessionStorage。
* 2. 把返回的 data(id/overview/taskDirection/taskName/taskContent/taskType)整包存到 sessionStorage。
* ``taskType`` 是字典码,拼开发送的开场信息时映射为 1 打击 / 2 塑势 / 3 防御。
* 3. 按 goPath 解析出业务码 + 模式:
* - ``3qfx`` / ``rwfx`` / ``xdfx`` → 单智能体问答(用「配置管理」里该业务配置的智能体)
* - ``3qfx-A`` / ``rwfx-A`` / ``xdfx-A`` → 多智能体会商(用该业务配置的业务链条)
@@ -19,7 +20,10 @@ import { requestCrudService } from "@/service/request-axios-service";
import { clearTaskContext, persistTaskContext } from "@/core/auth/task-purpose";
import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config";
import { readRuntimeEnv, readRuntimeFlag } from "@/env";
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
import {
listBusinessMappings,
type BusinessMapping,
} from "@/strategy-components/api/business-mapping";
/** xdfx 业务码:深链传入的是「行动id」,需先调 action 接口解析出真正的「任务id」。 */
const XDFX_BUSINESS_CODE = "7BF";
@@ -31,6 +35,41 @@ export interface CopTaskDetail {
taskDirection: string;
taskName: string;
taskContent: string;
/**
* 任务类型字典码(cop-task-detail 的 ``taskType``)。
* 1 打击 / 2 塑势 / 3 防御;缺省或无法识别时为空,发送时省略该行。
*/
taskType?: string;
/** 传播网名称。xdfx 在目的或行为体为 null 时,用它请求传播网简介。 */
cbwName?: string;
}
/** cop-task-detail ``taskType`` 字典码 → 发给模型的任务类型。 */
const COP_TASK_TYPE_LABELS: Record<string, string> = {
"1": "打击",
"2": "塑势",
"3": "防御",
};
const COP_TASK_TYPE_LABEL_VALUES = new Set(Object.values(COP_TASK_TYPE_LABELS));
/** 把任务类型字典码映射成中文;数字、数字字符串和已是中文的值都接受。无法识别时返回空串。 */
export function mapCopTaskTypeLabel(value: unknown): string {
let key = "";
if (typeof value === "number" && Number.isFinite(value)) {
key = String(value);
} else if (typeof value === "string") {
key = value.trim();
}
if (!key) return "";
if (COP_TASK_TYPE_LABEL_VALUES.has(key)) return key;
return COP_TASK_TYPE_LABELS[key] ?? "";
}
function readTaskTypeCode(value: unknown): string {
if (typeof value === "number" && Number.isFinite(value)) return String(value);
if (typeof value === "string") return value.trim();
return "";
}
/** 任务详情整包暂存的 sessionStorage key(目标页/后续可读取)。 */
@@ -39,6 +78,12 @@ export const COP_TASK_DETAIL_STORAGE_KEY = "login.copTaskDetail";
/** 行动详情整包暂存的 sessionStorage key(xdfx 行动分析:会商/单智能体读取行动名称/叙事)。 */
export const ACTION_DETAIL_STORAGE_KEY = "login.actionDetail";
/**
* consumer ``GET /task-action/actionDetail`` 的结构化行动详情。
* 与 ``login.actionDetail``(行动名称/任务 id/传播网)不是同一份数据。
*/
export const TASK_ACTION_STRUCTURE_STORAGE_KEY = "login.taskActionStructure";
/** goPath 基础码 → 业务码(配置管理 businessCode)。 */
const GOPATH_BUSINESS_CODE: Record<string, string> = {
"3qfx": "3Q", // 三情分析
@@ -58,20 +103,33 @@ const BUSINESS_TASK_BASE: Record<string, string> = {
export interface ParsedGoPath {
businessCode: string;
goPath: "3qfx" | "rwfx" | "xdfx";
/** Base goPath, normalized to lowercase and without the optional `-A`. */
goPath: string;
/** true = 多智能体会商(``-A`` 后缀);false = 单智能体问答。 */
roundtable: boolean;
}
/** 解析 goPath(大小写不敏感);``-A`` 后缀表示多智能体会商。无法识别返回 null。 */
export function parseGoPath(goPath: string): ParsedGoPath | null {
/** Normalize any syntactically valid goPath before resolving the mapping. */
function parseGoPathToken(goPath: string): Pick<ParsedGoPath, "goPath" | "roundtable"> | null {
const raw = goPath.trim().toLowerCase();
if (!raw) return null;
const roundtable = raw.endsWith("-a");
const base = roundtable ? raw.slice(0, -2) : raw;
if (!base || base.length > 32) return null;
return { goPath: base, roundtable };
}
/**
* Parse the built-in goPaths (case-insensitive). Custom goPaths are resolved
* from the persisted business-mapping catalogue by `resolveTaskDeeplinkTarget`.
*/
export function parseGoPath(goPath: string): ParsedGoPath | null {
const token = parseGoPathToken(goPath);
if (!token) return null;
const { goPath: base, roundtable } = token;
const businessCode = GOPATH_BUSINESS_CODE[base];
if (!businessCode) return null;
return { businessCode, goPath: base as ParsedGoPath["goPath"], roundtable };
return { businessCode, goPath: base, roundtable };
}
function consumerBaseUrl(): string {
@@ -93,6 +151,8 @@ function mockCopTaskDetail(taskId: string): CopTaskDetail {
taskName: "任务01:暑期文旅舆情综合研判",
taskContent:
"围绕暑期文旅消费高峰,对重点城市、重点景区的公众情绪与舆情态势做综合研判,识别情绪拐点与潜在风险,提出正向引导与处置建议。",
taskType: "1",
cbwName: "暑期文旅传播网",
};
}
@@ -104,6 +164,7 @@ export function stubCopTaskDetail(taskId: string): CopTaskDetail {
taskDirection: "",
taskName: "",
taskContent: "",
taskType: "",
};
}
@@ -126,6 +187,8 @@ export async function fetchCopTaskDetail(taskId: string): Promise<CopTaskDetail
taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "",
taskName: typeof d.taskName === "string" ? d.taskName : "",
taskContent: typeof d.taskContent === "string" ? d.taskContent : "",
taskType: readTaskTypeCode(d.taskType),
cbwName: normalizeCbwName(d.cbwName),
};
} catch (error) {
console.warn("[task-deeplink] cop-task-detail failed", { taskId, error });
@@ -171,6 +234,236 @@ export function readActionDetail(): ActionDetail | null {
}
}
/**
* 传播网 id 是否可下发给模型。JSON ``null``、缺字段、空串、字面量 ``null`` 都视为不传。
*/
export function normalizeCbwId(value: unknown): string | null {
if (value == null) return null;
if (typeof value === "number") return Number.isFinite(value) ? String(value) : null;
if (typeof value !== "string") return null;
const text = value.trim();
if (!text || text.toLowerCase() === "null" || text.toLowerCase() === "undefined") return null;
return text;
}
/** 传播网名称。空值不下发;没有有效传播网 id 时调用方应整段省略。 */
export function normalizeCbwName(value: unknown): string {
if (typeof value !== "string") return "";
const text = value.trim();
if (!text || text.toLowerCase() === "null" || text.toLowerCase() === "undefined") return "";
return text;
}
/**
* consumer ``GET {VITE_AIPI_CONSUMER_URL}/task-action/actionDetail?actionId=`` 的 data。
* 写入 xdfx 用户消息时:purpose→目的,actor→行为体,expectedBehavior→预期行为,
* drivingFactor→驱动因素,narrationList→叙事(列表元素拼成一句)。
*/
export interface TaskActionStructure {
/** 本次请求使用的行动 id(深链 URL 的 actionId)。 */
actionId: string;
/** JSON null / 缺字段保持 null,用来决定要不要补拉传播网简介。 */
purpose: string | null;
actor: string | null;
expectedBehavior: string;
drivingFactor: string;
/** ``narrationList`` 拼成的一句话。 */
narration: string;
/** ``mainnets/narratives-by-name`` 的 data.summary。仅在目的或行为体为 null 时才有。 */
narrativeSummary: string;
}
function textField(value: unknown): string {
if (typeof value === "number" && Number.isFinite(value)) return String(value);
if (typeof value !== "string") return "";
return value.trim();
}
/** null / 缺字段保持 null;空串仍是空串,不触发传播网简介。 */
function nullableText(value: unknown): string | null {
if (value == null) return null;
return textField(value);
}
/** purpose 或 actor 有一个为 null,就补拉传播网简介。 */
export function actionNeedsNarrative(detail: {
purpose: string | null;
actor: string | null;
} | null | undefined): boolean {
if (!detail) return false;
return detail.purpose === null || detail.actor === null;
}
/** 把叙事列表收成一句。空元素丢掉,条目之间用分号连接。 */
export function joinNarrationList(value: unknown): string {
if (!Array.isArray(value)) return "";
return value.map((item) => textField(item)).filter(Boolean).join(";");
}
function isConsumerOk(state: unknown): boolean {
return state === 200 || state === "200";
}
/** 解析 ``{state, msg, data}``。state 不是 200、或 data 不是对象时返回 null。 */
export function parseTaskActionStructure(body: unknown, actionId: string): TaskActionStructure | null {
if (!body || typeof body !== "object") return null;
const envelope = body as { state?: unknown; data?: unknown };
if (!isConsumerOk(envelope.state)) return null;
const data = envelope.data;
if (!data || typeof data !== "object") return null;
const row = data as Record<string, unknown>;
return {
actionId,
purpose: nullableText(row.purpose),
actor: nullableText(row.actor),
expectedBehavior: textField(row.expectedBehavior),
drivingFactor: textField(row.drivingFactor),
narration: joinNarrationList(row.narrationList),
narrativeSummary: "",
};
}
/** 解析 ``mainnets/narratives-by-name`` 的 ``data.summary``。失败或无简介返回空串。 */
export function parseNarrativeSummary(body: unknown): string {
if (!body || typeof body !== "object") return "";
const envelope = body as { state?: unknown; data?: unknown };
if (!isConsumerOk(envelope.state)) return "";
const data = envelope.data;
if (!data || typeof data !== "object") return "";
return textField((data as { summary?: unknown }).summary);
}
export function taskActionStructureLines(detail: TaskActionStructure | null | undefined): string[] {
if (!detail) return [];
return [
detail.purpose ? `目的:${detail.purpose}` : "",
detail.actor ? `行为体:${detail.actor}` : "",
detail.expectedBehavior ? `预期行为:${detail.expectedBehavior}` : "",
detail.drivingFactor ? `驱动因素:${detail.drivingFactor}` : "",
detail.narration ? `叙事:${detail.narration}` : "",
detail.narrativeSummary ? `传播网简介:${detail.narrativeSummary}` : "",
].filter(Boolean);
}
function persistTaskActionStructure(detail: TaskActionStructure): void {
try {
sessionStorage.setItem(TASK_ACTION_STRUCTURE_STORAGE_KEY, JSON.stringify(detail));
} catch {
/* noop */
}
}
export function readTaskActionStructure(): TaskActionStructure | null {
try {
const raw = sessionStorage.getItem(TASK_ACTION_STRUCTURE_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<TaskActionStructure>;
if (!parsed || typeof parsed !== "object") return null;
return {
actionId: typeof parsed.actionId === "string" ? parsed.actionId : "",
purpose: parsed.purpose == null ? null : typeof parsed.purpose === "string" ? parsed.purpose : "",
actor: parsed.actor == null ? null : typeof parsed.actor === "string" ? parsed.actor : "",
expectedBehavior: typeof parsed.expectedBehavior === "string" ? parsed.expectedBehavior : "",
drivingFactor: typeof parsed.drivingFactor === "string" ? parsed.drivingFactor : "",
narration: typeof parsed.narration === "string" ? parsed.narration : "",
narrativeSummary: typeof parsed.narrativeSummary === "string" ? parsed.narrativeSummary : "",
};
} catch {
return null;
}
}
/** 只取与当前行动 id 匹配的结构化详情,避免串到上一次深链。 */
export function readTaskActionStructureFor(actionId: string): TaskActionStructure | null {
const id = actionId.trim();
if (!id) return null;
const stored = readTaskActionStructure();
if (!stored || stored.actionId !== id) return null;
return stored;
}
const taskActionStructureRequests = new Map<string, Promise<TaskActionStructure | null>>();
const narrativeSummaryRequests = new Map<string, Promise<string>>();
function narrativeCbwName(task: { cbwName?: string | null } | null | undefined, action: { cbwName?: string | null } | null | undefined): string {
return normalizeCbwName(task?.cbwName) || normalizeCbwName(action?.cbwName);
}
async function loadNarrativeSummary(cbwName: string): Promise<string> {
const base = consumerBaseUrl();
if (!base) return "";
try {
const body = await requestCrudService.get(`${base}/mainnets/narratives-by-name`, { cbwName });
return parseNarrativeSummary(body);
} catch (error) {
console.warn("[task-deeplink] narratives-by-name failed", { cbwName, error });
return "";
}
}
/** 按传播网名称取简介。同一名称的在途请求会复用。 */
export function fetchNarrativeSummary(cbwName: string): Promise<string> {
const name = normalizeCbwName(cbwName);
if (!name) return Promise.resolve("");
const existing = narrativeSummaryRequests.get(name);
if (existing) return existing;
const pending = loadNarrativeSummary(name);
narrativeSummaryRequests.set(name, pending);
return pending;
}
/**
* 行动结构里目的或行为体为 null 时,用任务详情(没有则用行动详情)的 cbwName
* 拉传播网简介,写回同一份行动结构,供用户消息拼接。
*/
export async function ensureNarrativeSummary(actionId: string, cbwName: string): Promise<void> {
const id = actionId.trim();
const name = normalizeCbwName(cbwName);
if (!id || !name) return;
const structure = readTaskActionStructureFor(id) ?? (await fetchTaskActionStructure(id));
if (!actionNeedsNarrative(structure)) return;
const summary = await fetchNarrativeSummary(name);
const latest = readTaskActionStructureFor(id) ?? structure;
if (!latest) return;
persistTaskActionStructure({ ...latest, narrativeSummary: summary });
}
async function loadTaskActionStructure(actionId: string): Promise<TaskActionStructure | null> {
const base = consumerBaseUrl();
if (!base) return null;
try {
const body = await requestCrudService.get(`${base}/task-action/actionDetail`, { actionId });
const detail = parseTaskActionStructure(body, actionId);
if (detail) persistTaskActionStructure(detail);
return detail;
} catch (error) {
console.warn("[task-deeplink] task-action/actionDetail failed", { actionId, error });
return null;
}
}
/**
* xdfx 深链:按行动 id 拉 consumer 行动结构。同一 actionId 的在途/已完成请求会复用,
* 登录预取和开场词拼装不会各打一次。失败返回 null,不阻断跳转。
*/
export function fetchTaskActionStructure(actionId: string): Promise<TaskActionStructure | null> {
const id = actionId.trim();
if (!id) return Promise.resolve(null);
const existing = taskActionStructureRequests.get(id);
if (existing) return existing;
const pending = loadTaskActionStructure(id);
taskActionStructureRequests.set(id, pending);
return pending;
}
/** xdfx 发给模型的传播网两行。``cbwId`` 无效时返回空数组,名称也不带。 */
export function cbwModelLines(cbwId: unknown, cbwName: unknown): string[] {
const id = normalizeCbwId(cbwId);
if (!id) return [];
const name = normalizeCbwName(cbwName);
return [`传播网id(cbw_id)为${id}`, ...(name ? [`传播网名称:${name}`] : [])];
}
/** 行动详情接口 ``{action_detail_url}/{actionId}`` 返回的 ``data`` 字段(xdfx 行动分析用)。 */
export interface ActionDetail {
/** 行动id(深链 ?taskId= 传入的就是它)。 */
@@ -185,6 +478,10 @@ export interface ActionDetail {
actionStatus: number | string;
/** 行动叙事 / 描述。 */
narration: string;
/** 传播网 id。接口为 null / 空时保持 null,开场信息不带传播网。 */
cbwId: string | null;
/** 传播网名称。仅在 ``cbwId`` 有值时随开场信息下发。 */
cbwName: string;
}
/**
@@ -216,6 +513,8 @@ export async function fetchActionDetail(actionId: string): Promise<ActionDetail
actionStatus:
typeof d.actionStatus === "number" || typeof d.actionStatus === "string" ? d.actionStatus : "",
narration: typeof d.narration === "string" ? d.narration : "",
cbwId: normalizeCbwId(d.cbwId),
cbwName: normalizeCbwName(d.cbwName),
};
// 取到即整包暂存:登录解析任务id 时(resolveDeeplinkTaskId→fetchTaskIdFromAction)就会缓存它,
// 之后会商左上角「当前行动」、单智能体行动详情卡都能直接 readActionDetail(),避免重复请求。
@@ -250,13 +549,61 @@ export async function resolveDeeplinkTaskId(goPath: string, rawId: string): Prom
return rawId;
}
/**
* 深链预取:登录**进行中**就把不依赖 DeerFlow 登录态的深链数据并行拉好——
* 深链配置(公开端点)、xdfx 行动详情(含其内部缓存的配置)、任务详情
* (consumer 接口走静态 admin Bearer,与登录无关)。登录成功后登录页的
* 深链解析直接消费这些在途/已完成的结果,省掉「配置→行动→任务详情」
* 的整段登录后串行等待。
*
* 失败语义与登录页原逻辑一致:行动详情失败按原始 id 回退拉任务详情、
* realTaskId 给 ""(调用方回退原始 id);任务详情失败为 null(调用方
* 走 stub)。xdfx 同时预取 consumer ``task-action/actionDetail``,失败只是
* 开场词里不带目的/行为体等行。这些 promise 均不 reject。
*/
export interface TaskDeeplinkPrefetch {
/** 真实任务 id(xdfx:行动→任务;其余原样返回)。 */
realTaskId: Promise<string>;
/** 任务详情(xdfx 已串好「行动详情→任务详情」链;无数据/失败为 null)。 */
taskDetail: Promise<CopTaskDetail | null>;
/** xdfx:consumer 行动结构。其它业务为已完成的 null。失败不 reject。 */
actionStructure: Promise<TaskActionStructure | null>;
}
export function prefetchTaskDeeplink(taskId: string, goPath: string): TaskDeeplinkPrefetch {
const parsed = parseGoPath(goPath);
if (parsed?.businessCode === XDFX_BUSINESS_CODE) {
const actionDetail = fetchActionDetail(taskId).catch((error: unknown) => {
console.warn("[task-deeplink] prefetch action detail failed", { taskId, error });
return null;
});
const taskDetail = actionDetail.then((ad) => fetchCopTaskDetail(ad?.taskId || taskId));
const actionStructure = fetchTaskActionStructure(taskId).then(async (structure) => {
if (!actionNeedsNarrative(structure)) return structure;
const [ad, task] = await Promise.all([actionDetail, taskDetail]);
await ensureNarrativeSummary(taskId, narrativeCbwName(task, ad));
return readTaskActionStructureFor(taskId) ?? structure;
});
return {
realTaskId: actionDetail.then((ad) => ad?.taskId ?? ""),
taskDetail,
actionStructure,
};
}
return {
realTaskId: Promise.resolve(taskId),
taskDetail: fetchCopTaskDetail(taskId),
actionStructure: Promise.resolve(null),
};
}
export interface TaskDeeplinkTarget {
path: string;
/** react-router navigate 的 state(多智能体会商用 roundtablePrefill)。 */
state?: Record<string, unknown>;
}
/** xdfx(行动分析)开场信息里要追加的行动信息:行动标题 + 行动内容 + 行动id。 */
/** xdfx(行动分析)开场信息里要追加的行动信息:行动标题 + 行动内容 + 行动id + 传播网。 */
export interface ActionOpeningInfo {
/** 行动id(action_id)。 */
actionId?: string;
@@ -264,20 +611,32 @@ export interface ActionOpeningInfo {
actionName?: string;
/** 行动内容(行动叙事 narration)。 */
narration?: string;
/** 传播网 id。为 null / 空时不写入开场信息,名称一并省略。 */
cbwId?: string | null;
/** 传播网名称。仅在有传播网 id 时写入。 */
cbwName?: string | null;
}
/**
* 用 taskName / taskContent 拼出开场信息文本;末尾附上任务 id(task_id),供后端识别任务。
* ``taskType`` 能映射时插入「任务类型:打击/塑势/防御」。
* xdfx(行动分析)额外附上「行动标题 / 行动内容 / 行动id(action_id)」——传入 action 时追加这几行。
* 同一行动已取到的 consumer 行动结构会再追加「目的 / 行为体 / 预期行为 / 驱动因素 / 叙事」。
* 目的或行为体为 null 时,再追加「传播网简介」。
* 传播网 id、传播网名称仅在 ``cbwId`` 有值时追加;为 null 时两行都不写。
*/
function buildOpeningText(detail: CopTaskDetail, taskId: string, action?: ActionOpeningInfo): string {
const taskTypeLabel = mapCopTaskTypeLabel(detail.taskType);
return [
detail.taskName ? `任务名称:${detail.taskName}` : "",
detail.taskContent ? `任务内容:${detail.taskContent}` : "",
taskTypeLabel ? `任务类型:${taskTypeLabel}` : "",
taskId ? `任务id(task_id)为${taskId}` : "",
action?.actionName ? `行动标题:${action.actionName}` : "",
action?.narration ? `行动内容:${action.narration}` : "",
action?.actionId ? `行动id(action_id)为${action.actionId}` : "",
...taskActionStructureLines(action?.actionId ? readTaskActionStructureFor(action.actionId) : null),
...cbwModelLines(action?.cbwId, action?.cbwName),
]
.filter(Boolean)
.join("\n");
@@ -314,10 +673,36 @@ export async function resolveTaskDeeplinkTarget(
taskId: string,
rawId?: string,
): Promise<TaskDeeplinkTarget | null> {
const parsed = parseGoPath(goPath);
if (!parsed) return null;
const builtIn = parseGoPath(goPath);
const token = parseGoPathToken(goPath);
if (!token) return null;
// Built-ins retain their task-workspace routes. Any other valid base is a
// custom mapping only when it exists in the globally configured catalogue.
let mapping: BusinessMapping | null = null;
const isCustom = !builtIn;
const parsed: ParsedGoPath = builtIn ?? {
businessCode: "",
goPath: token.goPath,
roundtable: token.roundtable,
};
if (isCustom) {
try {
const mappings = await listBusinessMappings();
mapping = mappings.find(
(row) => row.isCustom && row.businessCode.trim().toLowerCase() === token.goPath,
) ?? null;
} catch (error) {
console.warn("[task-deeplink] custom business mapping failed", error);
}
if (!mapping) {
console.warn("[task-deeplink] custom goPath is not configured", token.goPath);
return null;
}
parsed.businessCode = mapping.businessCode;
}
const taskBase = BUSINESS_TASK_BASE[parsed.businessCode];
if (!taskBase) return null;
if (!isCustom && !taskBase) return null;
// 清掉上一次深链可能残留的任务上下文(单智能体分支会用本次任务重新写入)。
clearTaskContext();
@@ -331,11 +716,19 @@ export async function resolveTaskDeeplinkTarget(
let actionId = "";
if (parsed.businessCode === XDFX_BUSINESS_CODE && rawId) {
actionId = rawId;
const ad = readActionDetail() ?? (await fetchActionDetail(rawId));
const [, ad] = await Promise.all([
fetchTaskActionStructure(rawId),
readActionDetail() ? Promise.resolve(readActionDetail()) : fetchActionDetail(rawId),
]);
await ensureNarrativeSummary(rawId, narrativeCbwName(detail, ad));
actionName = ad?.actionName ?? "";
}
return {
path: `${taskBase}/roundtable`,
// Custom mappings have no host business page. They intentionally open a
// clean surface; hideChrome survives the subsequent in-page navigation.
path: isCustom
? "/page/strategy/qa/multi-agent-roundtable?hideChrome=1"
: `${taskBase}/roundtable`,
state: {
roundtablePrefill: {
taskTitle: detail.taskName,
@@ -350,14 +743,15 @@ export async function resolveTaskDeeplinkTarget(
};
}
let agentId: string | null = null;
try {
const mappings = await listBusinessMappings();
agentId = mappings.find((m) => m.businessCode === parsed.businessCode)?.agentId ?? null;
} catch (error) {
console.warn("[task-deeplink] business mapping failed", error);
agentId = null;
if (!mapping) {
try {
const mappings = await listBusinessMappings();
mapping = mappings.find((row) => row.businessCode === parsed.businessCode) ?? null;
} catch (error) {
console.warn("[task-deeplink] business mapping failed", error);
}
}
const agentId = mapping?.agentId ?? null;
if (!agentId) {
console.warn("[task-deeplink] no agent mapped for", parsed.businessCode);
return null;
@@ -368,13 +762,24 @@ export async function resolveTaskDeeplinkTarget(
// 开最新历史对话 / 新建。rwfx 在工作区里额外过「目的树闸门」;3qfx/xdfx 仅 1:N 列表,
// 新建对话直接带开场信息自动发出(见 task-purpose / AgentChatPage 的 taskGate / taskBind)。
// xdfx(行动分析):聊天记录按「行动id」划分(partitionByAction),且开场信息追加
// 行动标题 / 行动内容 / action_id(行动详情登录解析任务id 时已缓存,优先读缓存避免重复请求)。
// 行动标题 / 行动内容 / action_id / 传播网(行动详情登录解析任务id 时已缓存,优先读缓存避免重复请求)。
const isXdfx = parsed.businessCode === XDFX_BUSINESS_CODE;
const actionId = rawId || taskId;
let actionInfo: ActionOpeningInfo | undefined;
if (isXdfx) {
const ad = rawId ? (readActionDetail() ?? (await fetchActionDetail(rawId))) : null;
actionInfo = { actionId, actionName: ad?.actionName, narration: ad?.narration };
const structureId = rawId || actionId;
const [, ad] = await Promise.all([
structureId ? fetchTaskActionStructure(structureId) : Promise.resolve(null),
rawId ? (readActionDetail() ? Promise.resolve(readActionDetail()) : fetchActionDetail(rawId)) : Promise.resolve(null),
]);
if (structureId) await ensureNarrativeSummary(structureId, narrativeCbwName(detail, ad));
actionInfo = {
actionId,
actionName: ad?.actionName,
narration: ad?.narration,
cbwId: ad?.cbwId,
cbwName: ad?.cbwName,
};
}
persistTaskContext({
taskId,
@@ -384,5 +789,12 @@ export async function resolveTaskDeeplinkTarget(
partitionByAction: isXdfx,
goPath: parsed.goPath,
});
if (isCustom) {
// This route deliberately renders AgentChatPage without its left agent
// list. The query also removes the global header and PageSidebar.
return {
path: `/page/strategy/custom-deeplink/${encodeURIComponent(agentId)}/chats/new?hideChrome=1`,
};
}
return { path: `${taskBase}/agent/${agentId}/chats` };
}
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
TaskEntryInputError,
buildMainAppUrl,
buildTaskEntryDestination,
buildTaskEntrySessionSearch,
parseTaskEntryParams,
} from "./task-entry.ts";
test("parses the dedicated single-agent task entry", () => {
const params = parseTaskEntryParams(
"?username=admin&taskId=108&goPath=XDFX&theme=dark-blue&hideJump=1",
);
assert.deepEqual(params, {
username: "admin",
password: "",
taskId: "108",
goPath: "xdfx",
theme: "dark-blue",
hideJump: true,
});
});
test("rejects missing identity, task, and roundtable routes", () => {
assert.throws(() => parseTaskEntryParams("?taskId=108&goPath=xdfx"), TaskEntryInputError);
assert.throws(() => parseTaskEntryParams("?username=admin&goPath=xdfx"), TaskEntryInputError);
assert.throws(
() => parseTaskEntryParams("?username=admin&taskId=108&goPath=xdfx-A"),
TaskEntryInputError,
);
});
test("forces isolated embed state and an allowlisted task target", () => {
const params = parseTaskEntryParams(
"?username=admin&taskId=108&goPath=xdfx&hideJump=true",
);
const session = new URLSearchParams(buildTaskEntrySessionSearch(params));
assert.equal(session.get("embed"), "1");
assert.equal(session.get("hideChrome"), "1");
assert.equal(session.get("isolate"), "1");
assert.equal(session.get("hideJump"), "1");
const destination = buildTaskEntryDestination(
"/page/strategy/task-management/plan-task/plan-task-action/agent/a-1/chats",
params,
);
assert.equal(
destination,
"/page/strategy/task-management/plan-task/plan-task-action/agent/a-1/chats?embed=1&hideChrome=1&isolate=1&hideJump=1",
);
assert.throws(() => buildTaskEntryDestination("https://example.com", params));
assert.throws(() => buildTaskEntryDestination("/page/workspace/chats/new", params));
});
test("returns to the main app at the same deployment prefix", () => {
assert.equal(
buildMainAppUrl(
"https://example.com/deerflow/task-entry.html?username=admin",
"/page/strategy/task-management/plan-task/plan-task-action/agent/a-1/chats",
),
"https://example.com/deerflow/#/page/strategy/task-management/plan-task/plan-task-action/agent/a-1/chats",
);
});
+82
View File
@@ -0,0 +1,82 @@
export const TASK_ENTRY_SUPPORTED_GOPATHS = ["3qfx", "rwfx", "xdfx"] as const;
export type TaskEntryGoPath = (typeof TASK_ENTRY_SUPPORTED_GOPATHS)[number];
export interface TaskEntryParams {
username: string;
password: string;
taskId: string;
goPath: TaskEntryGoPath;
theme: string;
hideJump: boolean;
}
export class TaskEntryInputError extends Error {
constructor(message: string) {
super(message);
this.name = "TaskEntryInputError";
}
}
function readFlag(value: string | null): boolean {
if (!value) return false;
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}
export function parseTaskEntryParams(search: string): TaskEntryParams {
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
const username = (params.get("username") ?? "").trim();
const password = params.get("password") ?? "";
const taskId = (params.get("taskId") ?? "").trim();
const rawGoPath = (params.get("goPath") ?? "").trim().toLowerCase();
const theme = (params.get("theme") ?? "").trim();
if (!username) throw new TaskEntryInputError("入口缺少 username,无法登录任务问答。");
if (!taskId) throw new TaskEntryInputError("入口缺少 taskId,无法定位任务。");
if (!TASK_ENTRY_SUPPORTED_GOPATHS.includes(rawGoPath as TaskEntryGoPath)) {
throw new TaskEntryInputError("轻量入口仅支持 3qfx、rwfx 或 xdfx 单智能体任务问答。");
}
return {
username,
password,
taskId,
goPath: rawGoPath as TaskEntryGoPath,
theme,
hideJump: readFlag(params.get("hideJump")),
};
}
export function buildTaskEntrySessionSearch(params: TaskEntryParams): string {
const session = new URLSearchParams({
embed: "1",
hideChrome: "1",
isolate: "1",
});
if (params.hideJump) session.set("hideJump", "1");
if (params.theme) session.set("theme", params.theme);
return `?${session.toString()}`;
}
export function buildTaskEntryDestination(path: string, params: TaskEntryParams): string {
if (!path.startsWith("/page/strategy/task-management/") || path.includes("\\")) {
throw new Error("任务深链解析出了不受允许的目标页面。");
}
const url = new URL(path, "https://task-entry.invalid");
if (url.origin !== "https://task-entry.invalid") {
throw new Error("任务深链目标必须是系统内部页面。");
}
url.searchParams.set("embed", "1");
url.searchParams.set("hideChrome", "1");
url.searchParams.set("isolate", "1");
if (params.hideJump) url.searchParams.set("hideJump", "1");
if (params.theme) url.searchParams.set("theme", params.theme);
return `${url.pathname}${url.search}`;
}
export function buildMainAppUrl(currentHref: string, destination: string): string {
const appUrl = new URL(".", currentHref);
appUrl.search = "";
appUrl.hash = destination;
return appUrl.toString();
}
+26 -5
View File
@@ -33,7 +33,22 @@ export interface PurposeTreeNode {
children: PurposeTreeNode[];
}
export type TaskDeeplinkGoPath = "3qfx" | "rwfx" | "xdfx";
/**
* The base (without `-A`) of a task deep-link goPath.
*
* Besides the four built-ins, administrators may now create a short custom
* mapping name. Keeping this a string lets its dedicated conversation history
* partition follow the configured goPath without adding frontend code again.
*/
export type TaskDeeplinkGoPath = string;
function readTaskDeeplinkGoPath(value: unknown): TaskDeeplinkGoPath | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.trim().toLowerCase();
return normalized && normalized.length <= 32 && !normalized.endsWith("-a")
? normalized
: undefined;
}
/** 当前任务上下文(深链进入或「新建对话」时暂存,供闸门面板与绑定使用)。 */
export interface TaskContext {
@@ -51,6 +66,12 @@ export interface TaskContext {
partitionByAction?: boolean;
/** 单智能体深链接类型;用于写入专用 taskId + goPath 聊天记录索引。 */
goPath?: TaskDeeplinkGoPath;
/**
* b5-1 深链 ``?type=`` 业务类型。聊天记录在 taskId + goPath 之上再按该类型
* 细分分区(新对话打 `metadata.taskType` 标签、列表按 sub_type 过滤),
* 不同类型的对话互不可见。仅 b5-1 使用,其它深链不设置。
*/
taskType?: string;
}
const TASK_CONTEXT_STORAGE_KEY = "task.rwfxContext";
@@ -369,10 +390,8 @@ export function readTaskContext(): TaskContext | null {
openingText: typeof parsed.openingText === "string" ? parsed.openingText : "",
actionId: typeof parsed.actionId === "string" ? parsed.actionId : undefined,
partitionByAction: parsed.partitionByAction === true,
goPath:
parsed.goPath === "3qfx" || parsed.goPath === "rwfx" || parsed.goPath === "xdfx"
? parsed.goPath
: undefined,
taskType: typeof parsed.taskType === "string" ? parsed.taskType.trim() : undefined,
goPath: readTaskDeeplinkGoPath(parsed.goPath),
};
} catch {
return null;
@@ -399,6 +418,8 @@ export function taskThreadMetadata(
? {
taskRecordId,
taskGoPath: resolvedGoPath,
// b5-1 ?type=:同一 taskId 下不同业务类型的聊天记录分区。
...(ctx.taskType ? { taskType: ctx.taskType } : {}),
thread_type: "task_deeplink",
}
: {}),
@@ -70,6 +70,7 @@ function persistUserDetailToken(data: UserDetailTokenPayload): void {
*/
export async function resolveUsernameByOnlineTicket(
ticket: string,
isCurrent: () => boolean = () => true,
): Promise<string> {
const onlineTicket = ticket.trim();
if (!onlineTicket) {
@@ -81,13 +82,23 @@ export async function resolveUsernameByOnlineTicket(
if (!CONSUMER_URL) {
throw new Error("未配置 VITE_AIPI_CONSUMER_URL,无法获取用户详情。");
}
if (!isCurrent()) throw new Error("登录请求已被新的登录替代");
persistExternalLoginParams({ ticket: onlineTicket });
const tokenRes = (await requestCrudService.get(
`${AUTH_BASE_URL}/maxkey-mgt-api/sys/getTokenByOnlineTicket`,
{ onlineTicket },
)) as unknown as MaxkeyTokenByTicketResponse;
// 两个接口都只依赖 onlineTicket(MaxKey token 仅供后续请求使用,不参与
// 用户名解析),并行发起,把两轮外部串行往返压缩为一轮。
const [tokenRes, detailRes] = (await Promise.all([
requestCrudService.get(
`${AUTH_BASE_URL}/maxkey-mgt-api/sys/getTokenByOnlineTicket`,
{ onlineTicket },
),
requestCrudService.get(`${CONSUMER_URL}/cop/userDetailToken`, {
ticket: onlineTicket,
}),
])) as unknown as [MaxkeyTokenByTicketResponse, UserDetailTokenResponse];
if (!isCurrent()) throw new Error("登录请求已被新的登录替代");
console.log("[wujie] getTokenByOnlineTicket:", tokenRes);
@@ -98,11 +109,6 @@ export async function resolveUsernameByOnlineTicket(
}
persistMaxkeyOnlineToken(tokenRes.data);
const detailRes = (await requestCrudService.get(
`${CONSUMER_URL}/cop/userDetailToken`,
{ ticket: onlineTicket },
)) as unknown as UserDetailTokenResponse;
console.log("[wujie] userDetailToken:", detailRes);
const userData = detailRes.data;
@@ -175,7 +175,9 @@ export async function loginByYUserId(
yUserId: string,
token?: string | null,
access_token?: string | null,
isCurrent: () => boolean = () => true,
): Promise<void> {
if (!isCurrent()) return;
if (token) {
authStorageSet(TOOLS_TOKEN_STORAGE_KEY, token);
}
@@ -199,6 +201,7 @@ export async function loginByYUserId(
// 注意:接口返回的 accessToken 优先;登录 URL 的 access_token 只在接口值为空时兜底。
// The consumer API may return accessToken. URL access_token is only used as
// a fallback when that API token is empty.
if (!isCurrent()) return;
const result = res.data ?? {};
logYUserLogin("response", {
keys: Object.keys(result),