sync: 鍚屾灞€鍩熺綉鏈€鏂颁唬鐮?(source master @ b69568e4)

This commit is contained in:
gozeros committed 2026-10-21 14:51:31 +08:00
1 parent 4f7f07504a
commit 8bc8b0e082
1651 files changed
+278599 -11416

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import type { LoginByUsernameResponse } from "./index";
export interface AuthenticationOptions {
password_login_enabled: boolean;
registration_enabled: boolean;
sso_enabled: boolean;
single_session_enabled?: boolean;
needs_setup?: boolean;
}
function accountError(body: unknown, fallback: string): string {
const detail = (body as { detail?: unknown } | null)?.detail ?? body;
if (typeof detail === "string") return detail;
if (Array.isArray(detail)) {
return detail.map((item) => item?.msg).filter(Boolean).join(";") || fallback;
}
if (detail && typeof detail === "object") {
const value = detail as { message?: string; msg?: string };
return value.message || value.msg || fallback;
}
return fallback;
}
/** Bound unauthenticated requests so an unreachable gateway offers a retry. */
async function accountRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 20_000);
try {
const response = await 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;
} catch (error) {
if (controller.signal.aborted) throw new Error("连接超时,请检查网络后重试");
if (error instanceof TypeError) throw new Error("无法连接服务器,请检查网络后重试");
throw error;
} finally {
clearTimeout(timer);
}
}
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",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ username, password }),
});
}
export function registerAccount(username: string, password: string): Promise<LoginByUsernameResponse> {
return accountRequest("/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
}
export function initializeAdministrator(email: string, password: string): Promise<LoginByUsernameResponse> {
return accountRequest<LoginByUsernameResponse>("/initialize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
}
@@ -0,0 +1,60 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export interface AuthenticationSettings {
password_login_enabled: boolean;
registration_enabled: boolean;
sso_enabled: boolean;
single_session_enabled: boolean;
}
export interface PasswordSession {
access_token?: string;
token_type?: string;
expires_in?: number;
}
export interface AccountPasswordStatus {
id: string;
has_password: boolean;
requires_current_password: boolean;
uses_default_password: boolean;
}
async function readResponse<T>(response: Response): Promise<T> {
const data = await response.json().catch(() => null);
if (!response.ok) {
const detail = data?.detail;
const message = typeof detail === "string"
? detail
: Array.isArray(detail)
? detail.map((item: { msg?: string }) => item.msg).filter(Boolean).join(";")
: detail?.message;
throw new Error(message || `请求失败(${response.status})`);
}
return data as T;
}
export async function getAuthenticationSettings(): Promise<AuthenticationSettings> {
return readResponse(await apiFetch(`${getBackendBaseURL()}/api/system-settings/authentication`));
}
export async function saveAuthenticationSettings(settings: AuthenticationSettings): Promise<AuthenticationSettings> {
return readResponse(await apiFetch(`${getBackendBaseURL()}/api/system-settings/authentication`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings),
}));
}
export async function getAccountPasswordStatus(): Promise<AccountPasswordStatus> {
return readResponse(await apiFetch(`${getBackendBaseURL()}/api/v1/auth/me`));
}
export async function changeAccountPassword(currentPassword: string, newPassword: string): Promise<PasswordSession> {
return readResponse(await apiFetch(`${getBackendBaseURL()}/api/v1/auth/change-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
}));
}
+9 -7
View File
@@ -14,11 +14,16 @@ export interface UserListItem {
/** 登录白名单放行标记:开关开启后仅 approved 用户(或 admin)可登录。 */
approved: boolean;
/**
* 账号级「登录口令」(明文,仅管理员可见)。非空 → 该账号用户名直登必须带匹配的
* `?password=`;为空/null → 走原有共享口令门/免口令逻辑。管理员据此拼出登录链接
* `…/login/<用户名>?password=xxxx` 发给用户。
* 仅兼容尚未迁移的历史口令。新密码使用散列存储,不回传明文或散列,
* 是否已设置凭据以 has_login_password 为准。
*/
login_password: string | null;
/** 已设置密码/口令,散列凭据不会返回给客户端。 */
has_login_password: boolean;
/** 管理员重置自己的密码时,服务器同步刷新当前会话。 */
access_token?: string;
token_type?: string;
expires_in?: number;
}
export async function listAllUsers(): Promise<UserListItem[]> {
@@ -42,10 +47,7 @@ export async function setUserApproval(userId: string, approved: boolean): Promis
}
/**
* 设置 / 清除某个账号的「登录口令」(明文)。Admin only。
*
* 传非空字符串 → 设置;传 `null` 或空字符串 → 清除。设置后该账号用户名直登需带匹配的
* `?password=`。返回更新后的用户项(含明文 `login_password`)。
* 管理员设置 / 重置统一密码与登录口令。服务器不再允许清除以回退免密登录。
*/
export async function setUserLoginPassword(userId: string, password: string | null): Promise<UserListItem> {
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users/${encodeURIComponent(userId)}/login-password`, {
+1 -1
View File
@@ -29,7 +29,7 @@ export function useSetUserApproval() {
});
}
/** 设置 / 清除某账号的「登录口令」(明文),成功后刷新用户列表缓存。 */
/** 设置 / 重置账号的统一密码与登录口令;操作日志不记录密码。 */
export function useSetUserLoginPassword() {
const queryClient = useQueryClient();
return useMutation({
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import test from "node:test";
import { accountPageSearch, mergeLoginSearch, shouldStartSso, shouldUseStoredTicket } from "./login-entry";
const options = { password_login_enabled: true, registration_enabled: true, sso_enabled: true };
test("password entry never starts a guest login; explicit SSO still works", () => {
assert.equal(shouldStartSso(options, false, false), false);
assert.equal(shouldStartSso(options, true, false), true);
assert.equal(shouldStartSso({ ...options, password_login_enabled: false }, false, false), true);
assert.equal(shouldStartSso({ ...options, password_login_enabled: false }, true, false), 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);
assert.equal(shouldStartSso(options, true, false, true), false);
assert.equal(shouldStartSso(legacy, false, false, false), true);
});
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");
assert.equal(params.get("password"), "");
assert.equal(params.get("ticket"), "t");
assert.equal(params.get("theme"), "dark-blue");
assert.equal(params.get("taskId"), "42");
assert.equal(mergeLoginSearch("", "?authToken=outer").get("authToken"), "outer");
});
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);
assert.equal(shouldUseStoredTicket({ ...options, password_login_enabled: false, sso_enabled: false }, false), false);
assert.equal(shouldUseStoredTicket(null, false), false);
});
test("missing configuration, disabled SSO and registration never start SSO", () => {
assert.equal(shouldStartSso(null, true, false), false);
assert.equal(shouldStartSso({ ...options, sso_enabled: false }, true, false), false);
assert.equal(shouldStartSso(options, true, true), false);
});
test("switching forms retains the task destination and presentation without credentials", () => {
const search = accountPageSearch("?taskId=42&goPath=agentfx&embed=1&theme=dark-blue&password=secret&authToken=token&ticket=ticket&userId=old&yUserId=old&token=token&access_token=token&routeKey=task");
const params = new URLSearchParams(search);
assert.deepEqual([...params.keys()], ["taskId", "goPath", "embed", "theme"]);
assert.equal(params.get("taskId"), "42");
assert.equal(params.get("theme"), "dark-blue");
});
test("switching forms preserves a resolved route-key destination and empty search", () => {
assert.equal(accountPageSearch(""), "");
const params = new URLSearchParams(accountPageSearch("routeKey=task&embed=1", "/page/workspace/chats/new"));
assert.equal(params.get("redirect"), "/page/workspace/chats/new");
assert.equal(params.has("routeKey"), false);
assert.equal(params.get("embed"), "1");
});
+42
View File
@@ -0,0 +1,42 @@
import type { AuthenticationOptions } from "./account-api";
/** A missing options response must never fall back to a legacy guest login. */
export function shouldStartSso(
options: AuthenticationOptions | null,
explicitSso: boolean,
registering: boolean,
sessionExpired = false,
): boolean {
return Boolean(options?.sso_enabled && !registering && !sessionExpired &&
(explicitSso || !options.password_login_enabled));
}
/** External callers put login parameters before or after the hash route. */
export function mergeLoginSearch(routeSearch: string, outerSearch: string): URLSearchParams {
const params = new URLSearchParams(outerSearch);
// A route parameter wins even when explicitly empty: do not revive an old
// outer credential when the current route has replaced it.
new URLSearchParams(routeSearch).forEach((value, key) => params.set(key, value));
return params;
}
export const LOGIN_CREDENTIAL_PARAMS = [
"ticket", "userId", "yUserId", "token", "access_token", "password", "authToken", "routeKey",
] as const;
/** Cached tickets belong only to the legacy, bare entry with login disabled. */
export function shouldUseStoredTicket(
options: AuthenticationOptions | null,
explicitSso: boolean,
): boolean {
return Boolean(options?.sso_enabled && !options.password_login_enabled && !explicitSso);
}
/** Keep the destination/presentation when switching forms; discard credentials. */
export function accountPageSearch(search: string, routeKeyTarget?: string): string {
const params = new URLSearchParams(search);
if (routeKeyTarget) params.set("redirect", routeKeyTarget);
for (const key of LOGIN_CREDENTIAL_PARAMS) params.delete(key);
const result = params.toString();
return result ? `?${result}` : "";
}
@@ -0,0 +1,134 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { getBackendBaseURL } from "@/core/config";
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
import {
AUTH_STATE_CHANGED_EVENT,
clearStoredAuth,
getStoredAuth,
setStoredAuth,
} from "./index";
import { checkAccountAccess, isSameAccountSession, type AccountAccess } from "./session-validation";
type GateState = (Extract<AccountAccess, { status: "allowed" }> & { warning?: string })
| Extract<AccountAccess, { status: "unauthorized" }>
| { status: "checking" } | { status: "error"; message: string };
/** Keep protected pages unmounted until the configured authentication check succeeds. */
export function RequireAccount({ children, allowLegacyAnonymous = false }: {
children: ReactNode;
allowLegacyAnonymous?: boolean;
}) {
const { pathname, search } = useLocation();
const [attempt, setAttempt] = useState(0);
const [state, setState] = useState<GateState>({ status: "checking" });
const writingVerifiedAccount = useRef(false);
const requestInFlight = useRef(false);
useEffect(() => {
const refresh = () => {
if (!writingVerifiedAccount.current) setAttempt((value) => value + 1);
};
const onStorage = (event: StorageEvent) => {
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);
};
}, []);
useEffect(() => {
const controller = new AbortController();
const requestedAuth = getStoredAuth();
let disposed = false;
requestInFlight.current = true;
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.
setState((previous) => previous.status === "allowed"
&& JSON.stringify(previous.auth) === JSON.stringify(requestedAuth)
? previous : { status: "checking" });
void checkAccountAccess({
auth: requestedAuth,
baseURL: getBackendBaseURL(),
signal: controller.signal,
allowLegacyAnonymous,
}).then((result) => {
if (disposed) return;
if (!isSameAccountSession(getStoredAuth(), requestedAuth)) {
setAttempt((value) => value + 1);
return;
}
writingVerifiedAccount.current = true;
try {
if (result.status === "unauthorized") {
if (requestedAuth) clearStoredAuth();
} else if (result.auth && JSON.stringify(result.auth) !== JSON.stringify(getStoredAuth())) {
setStoredAuth(result.auth);
}
} finally {
writingVerifiedAccount.current = false;
}
setState(result);
}).catch((error: unknown) => {
if (disposed) return;
const message = controller.signal.aborted ? "验证登录超时,请检查网络后重试"
: error instanceof TypeError ? "无法连接服务器,请检查网络后重试"
: error instanceof Error ? error.message : "暂时无法验证登录状态,请稍后重试";
setState((previous) => previous.status === "allowed"
&& JSON.stringify(previous.auth) === JSON.stringify(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);
};
}, [attempt, allowLegacyAnonymous]);
if (state.status === "unauthorized") {
const params = new URLSearchParams({ redirect: `${pathname}${search}` });
if (state.reason) params.set("reason", state.reason);
return <Navigate to={`/login?${params.toString()}`} replace />;
}
if (state.status === "error") {
return <div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background p-6 text-center">
<p role="alert" className="text-sm text-muted-foreground">{state.message}</p>
<button type="button" className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground"
onClick={() => setAttempt((value) => value + 1)}>重新验证</button>
</div>;
}
if (state.status === "allowed" && JSON.stringify(state.auth) === JSON.stringify(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">
<span>{state.warning}</span>
<button type="button" className="shrink-0 text-primary underline" onClick={() => setAttempt((value) => value + 1)}>重新验证</button>
</div>}
</div>;
}
return <div role="status" className="flex min-h-screen items-center justify-center bg-background text-sm text-muted-foreground">
正在验证登录状态…
</div>;
}
@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import test from "node:test";
import { checkAccountAccess, isSameAccountSession } from "./session-validation";
const auth = {
access_token: "browser-token", token_type: "bearer", expires_in: 3600,
user_id: "user-1", email: "claimed@example.com", system_role: "admin", needs_setup: true, created: false,
};
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) {
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 { 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("a cached token never grants access before /me validates it", async () => {
const { fetcher, calls } = server(true, 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");
});
test("verified server permissions replace claimed local permissions", async () => {
const { fetcher } = server(true);
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" }));
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 });
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")));
});
test("outages and invalid policy responses fail closed without treating the session as invalid", async () => {
const { fetcher } = server(true, 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 () => { 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);
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), {
status: "unauthorized", reason: "session-expired",
});
assert.ok(calls.some(({ url }) => url.endsWith("/me")));
});
test("late failures cannot clear a renewed token or a different account", () => {
assert.equal(isSameAccountSession({ ...auth }, auth), true);
assert.equal(isSameAccountSession({ ...auth, access_token: "renewed" }, auth), false);
assert.equal(isSameAccountSession({ ...auth, user_id: "another-user" }, auth), false);
assert.equal(isSameAccountSession(null, auth), false);
});
@@ -0,0 +1,71 @@
import type { LoginByUsernameResponse } from "./index";
type StoredAccount = LoginByUsernameResponse | null;
export type AccountAccess =
| { status: "allowed"; auth: StoredAccount }
| { status: "unauthorized"; reason?: "session-expired" };
/** Compare credentials before applying a response from an earlier request. */
export function isSameAccountSession(current: StoredAccount, requested: StoredAccount): boolean {
return current?.access_token === requested?.access_token && current?.user_id === requested?.user_id;
}
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. */
export async function checkAccountAccess({
auth,
baseURL,
signal,
allowLegacyAnonymous = false,
fetcher = fetch,
}: {
auth: StoredAccount;
baseURL: string;
signal?: AbortSignal;
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("无法读取登录设置,请稍后重试");
}
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",
headers: { Authorization: `Bearer ${auth.access_token}` },
signal,
});
if (response.status === 401) return { status: "unauthorized", reason: "session-expired" };
if (!response.ok) throw new Error("暂时无法验证登录状态,请稍后重试");
const user = await response.json() as Record<string, unknown> | null;
if (!user || typeof user.id !== "string" || typeof user.email !== "string"
|| typeof user.system_role !== "string" || typeof user.needs_setup !== "boolean") {
throw new Error("服务器返回的登录信息不完整,请稍后重试");
}
if (user.id !== auth.user_id) return { status: "unauthorized", reason: "session-expired" };
return {
status: "allowed",
auth: { ...auth, email: user.email, system_role: user.system_role, needs_setup: user.needs_setup },
};
}
+6 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -125,5 +125,9 @@ export function AuthSyncWatcher({
};
}, [disabled, location.pathname, navigate, queryClient]);
return children ? <Fragment key={revision}>{children}</Fragment> : null;
// A keyed Fragment is transformed by the development tagger, which injects
// data-lov-id. React.Fragment only accepts key and children, so use a
// display:contents element to preserve the remount behavior without adding
// a layout box.
return children ? <div className="contents" key={revision}>{children}</div> : null;
}
+3 -1
View File
@@ -58,6 +58,7 @@ const BUSINESS_TASK_BASE: Record<string, string> = {
export interface ParsedGoPath {
businessCode: string;
goPath: "3qfx" | "rwfx" | "xdfx";
/** true = 多智能体会商(``-A`` 后缀);false = 单智能体问答。 */
roundtable: boolean;
}
@@ -70,7 +71,7 @@ export function parseGoPath(goPath: string): ParsedGoPath | null {
const base = roundtable ? raw.slice(0, -2) : raw;
const businessCode = GOPATH_BUSINESS_CODE[base];
if (!businessCode) return null;
return { businessCode, roundtable };
return { businessCode, goPath: base as ParsedGoPath["goPath"], roundtable };
}
function consumerBaseUrl(): string {
@@ -381,6 +382,7 @@ export async function resolveTaskDeeplinkTarget(
openingText: buildOpeningText(detail, taskId, actionInfo),
actionId,
partitionByAction: isXdfx,
goPath: parsed.goPath,
});
return { path: `${taskBase}/agent/${agentId}/chats` };
}
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import test from "node:test";
import { taskThreadMetadata } from "./task-purpose";
test("3qfx and rwfx histories use the resolved task id", () => {
assert.deepEqual(
taskThreadMetadata(
{
taskId: "108",
agentId: "agent-1",
openingText: "",
},
"rwfx",
),
{
taskId: "108",
taskRecordId: "108",
taskGoPath: "rwfx",
thread_type: "task_deeplink",
},
);
});
test("xdfx history uses the original action id while preserving task id", () => {
assert.deepEqual(
taskThreadMetadata(
{
taskId: "108",
agentId: "agent-2",
openingText: "",
actionId: "action-9",
partitionByAction: true,
},
"xdfx",
),
{
taskId: "108",
actionId: "action-9",
taskRecordId: "action-9",
taskGoPath: "xdfx",
thread_type: "task_deeplink",
},
);
});
+36 -10
View File
@@ -5,8 +5,8 @@
* - **目的树数据**:B 系统里的策划结果,走 ``getTaskPurposeDetail`` / ``delete``(地址、测试
* 开关、测试假数据都来自 [[task-deeplink-config]] / 后端 config.yaml),与聊天对话无关。
* 新建对话(含首次进入)前用它做「已有数据→覆盖确认」闸门。
* - **对话(thread)**:聊天会话;通过 thread 的 ``metadata.taskId`` 标签把多个对话挂到同一个
* 任务下(1:N),列表用 useThreadsPage({ metadata:{ taskId } }) 过滤。详见 [[task-deeplink]]。
* - **对话(thread)**:聊天会话;真实消息仍是 LangGraph thread,3qfx/rwfx/xdfx 的历史列表
* 通过 ``taskRecordId + taskGoPath`` 专用索引读取,不再过滤通用会话表。详见 [[task-deeplink]]。
*/
import {
@@ -33,7 +33,9 @@ export interface PurposeTreeNode {
children: PurposeTreeNode[];
}
/** 当前 rwfx 任务上下文(深链进入或「新建对话」时暂存,供闸门面板与绑定使用)。 */
export type TaskDeeplinkGoPath = "3qfx" | "rwfx" | "xdfx";
/** 当前任务上下文(深链进入或「新建对话」时暂存,供闸门面板与绑定使用)。 */
export interface TaskContext {
taskId: string;
agentId: string;
@@ -47,6 +49,8 @@ export interface TaskContext {
* (仍保留 taskId 标签以维持任务深链对话「全局共享、不分用户」的后端语义)。
*/
partitionByAction?: boolean;
/** 单智能体深链接类型;用于写入专用 taskId + goPath 聊天记录索引。 */
goPath?: TaskDeeplinkGoPath;
}
const TASK_CONTEXT_STORAGE_KEY = "task.rwfxContext";
@@ -365,6 +369,10 @@ 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,
};
} catch {
return null;
@@ -372,19 +380,37 @@ export function readTaskContext(): TaskContext | null {
}
/**
* 任务深链对话的 thread metadata 标签(既用于「新建对话」打标签,也用于对话列表过滤)。
* - 普通业务(rwfx/3qfx):`{ taskId }` —— 按任务划分(1:N)。
* - xdfx(partitionByAction):`{ taskId, actionId }` —— 额外按「行动id」划分聊天记录;
* 仍带 taskId 以保留后端「任务深链对话全局共享」的语义(后端据 metadata.taskId 跳过分用户过滤)。
* 任务深链对话的 thread metadata 标签。``taskId`` 保留真实任务 id 和全局共享语义;
* ``taskRecordId + taskGoPath`` 驱动独立聊天记录索引。xdfx 的 URL ``taskId`` 实为
* 行动 id,因此其 ``taskRecordId`` 取 actionId,避免同一任务的不同行动混在一起。
* 无有效 taskId 时返回 undefined(调用方据此不打标签)。
*/
export function taskThreadMetadata(
ctx: TaskContext | null | undefined,
goPath?: TaskDeeplinkGoPath,
): Record<string, unknown> | undefined {
if (!ctx?.taskId) return undefined;
return ctx.partitionByAction && ctx.actionId
? { taskId: ctx.taskId, actionId: ctx.actionId }
: { taskId: ctx.taskId };
const resolvedGoPath = goPath ?? ctx.goPath;
const taskRecordId = taskRecordIdForContext(ctx);
return {
taskId: ctx.taskId,
...(ctx.partitionByAction && ctx.actionId ? { actionId: ctx.actionId } : {}),
...(resolvedGoPath
? {
taskRecordId,
taskGoPath: resolvedGoPath,
thread_type: "task_deeplink",
}
: {}),
};
}
/** Original deep-link ``?taskId=`` used by the dedicated history partition. */
export function taskRecordIdForContext(
ctx: TaskContext | null | undefined,
): string {
if (!ctx) return "";
return ctx.partitionByAction && ctx.actionId ? ctx.actionId : ctx.taskId;
}
export function clearTaskContext(): void {