Merge remote-tracking branch 'origin/master' into ji
# Conflicts: # frontend-web/src/components/workspace/messages/message-list-item.tsx # offline-backend-20260512/backend/app/gateway/app.py # offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/html_page.py # offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/service.py
This commit is contained in:
commit
a1d460e220
133 files changed
+12413
-1239
No files matched your search
@@ -2,26 +2,28 @@
|
||||
|
||||
import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
|
||||
|
||||
import { getAuthorizationHeaderValue } from "../auth";
|
||||
import { getLangGraphBaseURL } from "../config";
|
||||
|
||||
import { sanitizeRunStreamOptions } from "./stream-mode";
|
||||
import { getAuthorizationHeaderValue } from "../auth";
|
||||
import { getLangGraphBaseURL } from "../config";
|
||||
|
||||
import { withCsrfHeaders } from "./csrf";
|
||||
import { sanitizeRunStreamOptions } from "./stream-mode";
|
||||
|
||||
function createCompatibleClient(isMock?: boolean): LangGraphClient {
|
||||
const client = new LangGraphClient({
|
||||
apiUrl: getLangGraphBaseURL(isMock),
|
||||
onRequest(_url, init) {
|
||||
const headers = new Headers(init.headers ?? {});
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !headers.has("Authorization")) {
|
||||
headers.set("Authorization", authHeader);
|
||||
}
|
||||
return {
|
||||
...init,
|
||||
headers,
|
||||
};
|
||||
},
|
||||
});
|
||||
apiUrl: getLangGraphBaseURL(isMock),
|
||||
onRequest(_url, init) {
|
||||
const headers = withCsrfHeaders(init.headers);
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !headers.has("Authorization")) {
|
||||
headers.set("Authorization", authHeader);
|
||||
}
|
||||
return {
|
||||
...init,
|
||||
credentials: init.credentials ?? "include",
|
||||
headers,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const originalRunStream = client.runs.stream.bind(client.runs);
|
||||
client.runs.stream = ((threadId, assistantId, payload) =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
|
||||
import { getAuthorizationHeaderValue } from "../auth";
|
||||
import { getCanvasLangGraphBaseURL } from "../config";
|
||||
|
||||
import { withCsrfHeaders } from "./csrf";
|
||||
import { sanitizeRunStreamOptions } from "./stream-mode";
|
||||
|
||||
/**
|
||||
@@ -19,12 +20,16 @@ function createCanvasClient(): LangGraphClient {
|
||||
const client = new LangGraphClient({
|
||||
apiUrl: getCanvasLangGraphBaseURL(),
|
||||
onRequest(_url, init) {
|
||||
const headers = new Headers(init.headers ?? {});
|
||||
const headers = withCsrfHeaders(init.headers);
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !headers.has("Authorization")) {
|
||||
headers.set("Authorization", authHeader);
|
||||
}
|
||||
return { ...init, headers };
|
||||
return {
|
||||
...init,
|
||||
credentials: init.credentials ?? "include",
|
||||
headers,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
const CSRF_COOKIE_NAME = "csrf_token";
|
||||
const CSRF_HEADER_NAME = "X-CSRF-Token";
|
||||
|
||||
function readCookie(name: string): string | undefined {
|
||||
if (typeof document === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const cookies = document.cookie.split(";");
|
||||
for (const cookie of cookies) {
|
||||
const [rawName, ...rest] = cookie.trim().split("=");
|
||||
if (rawName !== name) {
|
||||
continue;
|
||||
}
|
||||
const rawValue = rest.join("=");
|
||||
if (!rawValue) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(rawValue);
|
||||
} catch {
|
||||
return rawValue;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getCsrfToken(): string | undefined {
|
||||
const token = readCookie(CSRF_COOKIE_NAME)?.trim();
|
||||
return token ? token : undefined;
|
||||
}
|
||||
|
||||
export function withCsrfHeaders(headers?: HeadersInit): Headers {
|
||||
const next = new Headers(headers ?? {});
|
||||
const csrfToken = getCsrfToken();
|
||||
if (csrfToken && !next.has(CSRF_HEADER_NAME)) {
|
||||
next.set(CSRF_HEADER_NAME, csrfToken);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { getAuthorizationHeaderValue } from "@/core/auth";
|
||||
|
||||
function withAuthHeaders(headers?: HeadersInit): Headers {
|
||||
const next = new Headers(headers ?? {});
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !next.has("Authorization")) {
|
||||
next.set("Authorization", authHeader);
|
||||
}
|
||||
return next;
|
||||
import { getAuthorizationHeaderValue } from "@/core/auth";
|
||||
import { withCsrfHeaders } from "./csrf";
|
||||
|
||||
function withAuthHeaders(headers?: HeadersInit): Headers {
|
||||
const next = withCsrfHeaders(headers);
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !next.has("Authorization")) {
|
||||
next.set("Authorization", authHeader);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers: withAuthHeaders(init?.headers),
|
||||
});
|
||||
}
|
||||
export function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
|
||||
return fetch(input, {
|
||||
...init,
|
||||
credentials: init?.credentials ?? "include",
|
||||
headers: withAuthHeaders(init?.headers),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ export const EXTERNAL_LOGIN_STORAGE_KEYS = {
|
||||
ticket: "login.ticket",
|
||||
userId: "login.userId",
|
||||
env: "login.env",
|
||||
/** MaxKey getTokenByOnlineTicket 返回的 token */
|
||||
maxkeyOnlineToken: "login.maxkeyOnlineToken",
|
||||
/** consumer /cop/userDetailToken 返回的 data 整包(JSON) */
|
||||
userDetailToken: "login.userDetailToken",
|
||||
} as const;
|
||||
|
||||
export type ExternalLoginParams = {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { requestCrudService } from "@/service/request-axios-service";
|
||||
|
||||
import {
|
||||
EXTERNAL_LOGIN_STORAGE_KEYS,
|
||||
persistExternalLoginParams,
|
||||
} from "./external-login";
|
||||
|
||||
const AUTH_BASE_URL = (import.meta.env.VITE_AUTH_BASE_URL || "").replace(
|
||||
/\/$/,
|
||||
"",
|
||||
);
|
||||
const CONSUMER_URL = (import.meta.env.VITE_AIPI_CONSUMER_URL || "").replace(
|
||||
/\/$/,
|
||||
"",
|
||||
);
|
||||
|
||||
/** MaxKey onlineTicket 换发的 token(后续请求可能用到) */
|
||||
export const MAXKEY_ONLINE_TOKEN_STORAGE_KEY =
|
||||
EXTERNAL_LOGIN_STORAGE_KEYS.maxkeyOnlineToken;
|
||||
|
||||
/** `/cop/userDetailToken` 返回的 data 整包(JWT 解析后的用户信息) */
|
||||
export const USER_DETAIL_TOKEN_STORAGE_KEY =
|
||||
EXTERNAL_LOGIN_STORAGE_KEYS.userDetailToken;
|
||||
|
||||
export type MaxkeyTokenByTicketResponse = {
|
||||
code: number;
|
||||
data: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type UserDetailTokenPayload = {
|
||||
sub?: string;
|
||||
user_id?: string;
|
||||
exp?: number;
|
||||
expired?: boolean;
|
||||
iat?: number;
|
||||
institution?: string;
|
||||
iss?: string;
|
||||
locale?: string;
|
||||
kid?: string;
|
||||
jti?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type UserDetailTokenResponse = {
|
||||
code?: number;
|
||||
data?: UserDetailTokenPayload;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
function persistMaxkeyOnlineToken(token: string): void {
|
||||
try {
|
||||
localStorage.setItem(MAXKEY_ONLINE_TOKEN_STORAGE_KEY, token);
|
||||
} catch {
|
||||
// 静默失败,不阻断登录
|
||||
}
|
||||
}
|
||||
|
||||
function persistUserDetailToken(data: UserDetailTokenPayload): void {
|
||||
try {
|
||||
localStorage.setItem(USER_DETAIL_TOKEN_STORAGE_KEY, JSON.stringify(data));
|
||||
if (data.user_id) {
|
||||
localStorage.setItem(EXTERNAL_LOGIN_STORAGE_KEYS.userId, String(data.user_id));
|
||||
}
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 无界 / 外部跳转:用 onlineTicket 换 MaxKey token,并拉取用户详情。
|
||||
* - token 存入 localStorage(`login.maxkeyOnlineToken`)
|
||||
* - userDetailToken 的 data 存入 localStorage(`login.userDetailToken`)
|
||||
* @returns 用于 DeerFlow `loginByUsername` 的用户名(取自 data.sub)
|
||||
*/
|
||||
export async function resolveUsernameByOnlineTicket(
|
||||
ticket: string,
|
||||
): Promise<string> {
|
||||
const onlineTicket = ticket.trim();
|
||||
if (!onlineTicket) {
|
||||
throw new Error("缺少 onlineTicket,无法完成无界登录。");
|
||||
}
|
||||
if (!AUTH_BASE_URL) {
|
||||
throw new Error("未配置 VITE_AUTH_BASE_URL,无法换取 MaxKey token。");
|
||||
}
|
||||
if (!CONSUMER_URL) {
|
||||
throw new Error("未配置 VITE_AIPI_CONSUMER_URL,无法获取用户详情。");
|
||||
}
|
||||
|
||||
persistExternalLoginParams({ ticket: onlineTicket });
|
||||
|
||||
const tokenRes = (await requestCrudService.get(
|
||||
`${AUTH_BASE_URL}/maxkey-mgt-api/sys/getTokenByOnlineTicket`,
|
||||
{ onlineTicket },
|
||||
)) as unknown as MaxkeyTokenByTicketResponse;
|
||||
|
||||
console.log("[wujie] getTokenByOnlineTicket:", tokenRes);
|
||||
|
||||
if (tokenRes.code !== 200 || !tokenRes.data) {
|
||||
throw new Error(
|
||||
tokenRes.message || "getTokenByOnlineTicket 未返回有效 token。",
|
||||
);
|
||||
}
|
||||
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;
|
||||
if (!userData) {
|
||||
throw new Error("userDetailToken 未返回用户 data。");
|
||||
}
|
||||
persistUserDetailToken(userData);
|
||||
|
||||
const username = (userData.sub ?? "").trim();
|
||||
if (!username) {
|
||||
throw new Error("userDetailToken 中缺少 sub(用户名)。");
|
||||
}
|
||||
return username;
|
||||
}
|
||||
|
||||
export function readMaxkeyOnlineToken(): string {
|
||||
try {
|
||||
return localStorage.getItem(MAXKEY_ONLINE_TOKEN_STORAGE_KEY) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function readUserDetailToken(): UserDetailTokenPayload | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(USER_DETAIL_TOKEN_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as UserDetailTokenPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export function getLangGraphBaseURL(isMock?: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 笔记本外部系统(Studio)API 基础地址,通过 VITE_NOTEBOOK_API_BASE_URL 配置。 */
|
||||
/** 笔记本外部系统后端 API 地址,通过 VITE_AIPI_BACKENP_URL 配置。 */
|
||||
export function getNotebookApiBaseURL(): string {
|
||||
if (env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL) {
|
||||
return env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL.replace(/\/+$/, "");
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { resolveThemeFromQuery } from "@/core/auth/external-login";
|
||||
|
||||
const DEFAULT_USERNAME = "guest";
|
||||
|
||||
export type EmbedChatParams = {
|
||||
/** Parent correlation id — required for status polling without thread_id. */
|
||||
sessionId: string;
|
||||
message: string;
|
||||
username: string;
|
||||
theme: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve iframe embed params from URL query only (plain iframe, not wujie).
|
||||
*
|
||||
* Parent must generate ``sessionId`` and pass it on the iframe URL so it can
|
||||
* poll ``GET /api/public/embed/sessions/{sessionId}/status`` without reading
|
||||
* the child frame.
|
||||
*/
|
||||
export function resolveEmbedChatParams(search: string): EmbedChatParams {
|
||||
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
||||
|
||||
const sessionId =
|
||||
params.get("sessionId")?.trim() ||
|
||||
params.get("session_id")?.trim() ||
|
||||
"";
|
||||
|
||||
const message =
|
||||
params.get("message")?.trim() ||
|
||||
params.get("question")?.trim() ||
|
||||
params.get("text")?.trim() ||
|
||||
"";
|
||||
|
||||
const username =
|
||||
params.get("username")?.trim() ||
|
||||
params.get("userName")?.trim() ||
|
||||
DEFAULT_USERNAME;
|
||||
|
||||
const theme = params.get("theme")?.trim() || "";
|
||||
|
||||
return { sessionId, message, username, theme };
|
||||
}
|
||||
|
||||
export function resolveEmbedTheme(themeParam: string): "dark" | "light" {
|
||||
return resolveThemeFromQuery(themeParam || null);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export type EmbedSessionStatus = {
|
||||
session_id: string;
|
||||
registered: boolean;
|
||||
thread_id: string | null;
|
||||
finished: boolean;
|
||||
thread_status: string;
|
||||
has_active_run: boolean;
|
||||
latest_run_status: string | null;
|
||||
};
|
||||
|
||||
export type EmbedSessionResult = EmbedSessionStatus & {
|
||||
messages: Record<string, unknown>[];
|
||||
generated_text: string;
|
||||
generated_files: string[];
|
||||
};
|
||||
|
||||
async function parseEmbedError(response: Response): Promise<string> {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (typeof body.detail === "string") {
|
||||
return body.detail;
|
||||
}
|
||||
return `HTTP ${response.status}`;
|
||||
}
|
||||
|
||||
/** Register session_id → thread_id (iframe calls this; parent never needs thread_id). */
|
||||
export async function registerEmbedSession(
|
||||
sessionId: string,
|
||||
threadId: string,
|
||||
): Promise<{ session_id: string; thread_id: string }> {
|
||||
const base = getBackendBaseURL();
|
||||
const response = await fetch(`${base}/api/public/embed/sessions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "omit",
|
||||
body: JSON.stringify({ session_id: sessionId, thread_id: threadId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseEmbedError(response));
|
||||
}
|
||||
return response.json() as Promise<{ session_id: string; thread_id: string }>;
|
||||
}
|
||||
|
||||
/** Poll whether embed Q&A has finished — parent uses sessionId only. */
|
||||
export async function getEmbedSessionStatus(
|
||||
sessionId: string,
|
||||
): Promise<EmbedSessionStatus> {
|
||||
const base = getBackendBaseURL();
|
||||
const response = await fetch(
|
||||
`${base}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/status`,
|
||||
{ credentials: "omit" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseEmbedError(response));
|
||||
}
|
||||
return response.json() as Promise<EmbedSessionStatus>;
|
||||
}
|
||||
|
||||
/** Fetch full messages, generated text, and artifact paths — parent uses sessionId only. */
|
||||
export async function getEmbedSessionResult(
|
||||
sessionId: string,
|
||||
): Promise<EmbedSessionResult> {
|
||||
const base = getBackendBaseURL();
|
||||
const response = await fetch(
|
||||
`${base}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/result`,
|
||||
{ credentials: "omit" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseEmbedError(response));
|
||||
}
|
||||
return response.json() as Promise<EmbedSessionResult>;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
* 注入参数,子应用在 `window.$wujie.props` 上拿到**同一个对象**(无界在子应用
|
||||
* bootstrap 之前就已注入,因此首帧即可读)。
|
||||
*
|
||||
* 本期范围:只「读出来 + 存起来 + 打印」,**不接入对方鉴权**。读出的
|
||||
* ticket 在 LoginPage 会走 MaxKey + consumer 双接口换用户名并登录;读出的
|
||||
* ticket/userId/env 复用 external-login 的 localStorage 存储(与 URL 跳转登录
|
||||
* 同一套 key),theme 交给调用方(LoginPage)映射到 ThemeContext。
|
||||
*/
|
||||
@@ -16,7 +16,7 @@ import { persistExternalLoginParams } from "@/core/auth/external-login";
|
||||
|
||||
/** 无界父级注入的 props 形状。除已知字段外允许透传其余未知字段便于排查。 */
|
||||
export interface WujieProps {
|
||||
/** 第三方鉴权 ticket(本期仅存储待用,不消费)。 */
|
||||
/** 第三方 onlineTicket(LoginPage 用于 MaxKey + userDetailToken 登录)。 */
|
||||
ticket?: string;
|
||||
/** 外部用户 id。 */
|
||||
userId?: string;
|
||||
@@ -24,6 +24,13 @@ export interface WujieProps {
|
||||
env?: string;
|
||||
/** 主题色标识(映射到深/浅色)。 */
|
||||
theme?: string;
|
||||
/** 嵌入问答:首条用户问题(与 question / text 同义)。 */
|
||||
message?: string;
|
||||
question?: string;
|
||||
text?: string;
|
||||
/** 嵌入问答:DeerFlow 本地用户名登录。 */
|
||||
username?: string;
|
||||
userName?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:cla
|
||||
|
||||
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
|
||||
|
||||
interface AssistantSetupWritingApprovalGroup extends GenericMessageGroup<"assistant:setup-writing-approval"> {}
|
||||
|
||||
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
|
||||
|
||||
type MessageGroup =
|
||||
@@ -29,6 +31,7 @@ type MessageGroup =
|
||||
| AssistantPresentFilesGroup
|
||||
| AssistantClarificationGroup
|
||||
| AssistantSetupAgentApprovalGroup
|
||||
| AssistantSetupWritingApprovalGroup
|
||||
| AssistantSubagentGroup;
|
||||
|
||||
export function groupMessages<T>(
|
||||
@@ -50,7 +53,8 @@ export function groupMessages<T>(
|
||||
last.type !== "human" &&
|
||||
last.type !== "assistant" &&
|
||||
last.type !== "assistant:clarification" &&
|
||||
last.type !== "assistant:setup-agent-approval"
|
||||
last.type !== "assistant:setup-agent-approval" &&
|
||||
last.type !== "assistant:setup-writing-approval"
|
||||
) {
|
||||
return last;
|
||||
}
|
||||
@@ -88,6 +92,13 @@ export function groupMessages<T>(
|
||||
type: "assistant:setup-agent-approval",
|
||||
messages: [message],
|
||||
});
|
||||
} else if (isSetupWritingApprovalToolMessage(message)) {
|
||||
lastOpenGroup()?.messages.push(message);
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:setup-writing-approval",
|
||||
messages: [message],
|
||||
});
|
||||
} else {
|
||||
const open = lastOpenGroup();
|
||||
if (open) {
|
||||
@@ -354,6 +365,15 @@ export function isSetupAgentApprovalToolMessage(message: Message) {
|
||||
);
|
||||
}
|
||||
|
||||
export function isSetupWritingApprovalToolMessage(message: Message) {
|
||||
return (
|
||||
message.type === "tool" &&
|
||||
message.name === "setup_writing" &&
|
||||
typeof message.additional_kwargs?.setup_writing_approval === "object" &&
|
||||
message.additional_kwargs.setup_writing_approval !== null
|
||||
);
|
||||
}
|
||||
|
||||
export function extractPresentFilesFromMessage(message: Message) {
|
||||
if (message.type !== "ai" || !hasPresentFiles(message)) {
|
||||
return [];
|
||||
|
||||
@@ -7,6 +7,8 @@ const CITE_RE = /\[\[cite:(\d+)\]\]/g;
|
||||
|
||||
export function rehypeCitations(sources: Array<{ id: number; title: string }>) {
|
||||
const sourceMap = new Map(sources.map((s) => [s.id, s.title]));
|
||||
const citeCounter = new Map<number, number>();
|
||||
let citeIndex = 0;
|
||||
|
||||
return (tree: Root) => {
|
||||
visit(tree, "text", (node, index, parent) => {
|
||||
@@ -14,8 +16,6 @@ export function rehypeCitations(sources: Array<{ id: number; title: string }>) {
|
||||
CITE_RE.lastIndex = 0;
|
||||
|
||||
const newNodes: ElementContent[] = [];
|
||||
const citeCounter = new Map<number, number>();
|
||||
let citeIndex = 0;
|
||||
let last = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ function base(): string {
|
||||
|
||||
export async function fetchStudioToken(email: string): Promise<string> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
|
||||
const res = await fetch(
|
||||
`${apiBase}/api/getTokenByEmail?email=${encodeURIComponent(email)}`,
|
||||
@@ -22,7 +22,7 @@ export async function fetchStudioToken(email: string): Promise<string> {
|
||||
|
||||
export async function fetchStudioNotebooks(token: string): Promise<StudioNotebook[]> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
|
||||
const res = await fetch(`${apiBase}/api/v1/searchspaces`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -36,7 +36,7 @@ export async function createSearchSpace(
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
|
||||
const res = await fetch(`${apiBase}/api/v1/searchspaces`, {
|
||||
method: "POST",
|
||||
@@ -46,7 +46,47 @@ export async function createSearchSpace(
|
||||
},
|
||||
body: JSON.stringify({ name, description: "", citations_enabled: true }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`创建失败:${res.status}`);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(body?.detail || `创建失败:${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadSourceText(
|
||||
notebookId: number,
|
||||
content: string,
|
||||
title: string,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
const res = await fetch(`${apiBase}/api/v1/documents/textupload`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ search_space_id: notebookId, content, title: title || "粘贴的文字" }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`上传失败:${res.status}`);
|
||||
}
|
||||
|
||||
export async function deleteSearchSpace(id: number, token: string): Promise<void> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`删除失败:${res.status}`);
|
||||
}
|
||||
|
||||
export async function renameSearchSpace(id: number, name: string, token: string): Promise<void> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`重命名失败:${res.status}`);
|
||||
}
|
||||
|
||||
export interface ChunkRead {
|
||||
@@ -69,7 +109,7 @@ export async function fetchDocumentByChunk(
|
||||
token: string,
|
||||
): Promise<DocumentWithChunks> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
|
||||
const res = await fetch(`${apiBase}/api/v1/documents/by-chunk/${chunkId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -78,12 +118,34 @@ export async function fetchDocumentByChunk(
|
||||
return res.json() as Promise<DocumentWithChunks>;
|
||||
}
|
||||
|
||||
export async function uploadSourceFiles(
|
||||
notebookId: number,
|
||||
files: File[],
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("search_space_id", String(notebookId));
|
||||
for (const file of files) {
|
||||
formData.append("files", file);
|
||||
}
|
||||
|
||||
const res = await fetch(`${apiBase}/api/v1/documents/fileupload`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw new Error(`上传失败:${res.status}`);
|
||||
}
|
||||
|
||||
export async function fetchNotebookSources(
|
||||
notebookId: number,
|
||||
token: string,
|
||||
): Promise<SourceItem[]> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
|
||||
const res = await fetch(
|
||||
`${apiBase}/api/v1/source/search-space/${notebookId}?limit=100`,
|
||||
|
||||
@@ -12,6 +12,7 @@ export function useStudioNotebooks(): {
|
||||
notebooks: StudioNotebook[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
token: string | undefined;
|
||||
} {
|
||||
const email = getStoredAuth()?.email ?? "";
|
||||
|
||||
@@ -35,6 +36,7 @@ export function useStudioNotebooks(): {
|
||||
notebooks: (notebooksQuery.data ?? []).filter((nb) => nb.is_owner),
|
||||
isLoading: tokenQuery.isLoading || notebooksQuery.isLoading,
|
||||
isError: tokenQuery.isError || notebooksQuery.isError,
|
||||
token: tokenQuery.data,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,10 +91,15 @@ export function useNotebookChat(
|
||||
|
||||
const send = async (text: string, selectedSourceIds: number[]) => {
|
||||
if (thread.isLoading || !token) return;
|
||||
// API 需要 document.id,而不是 source.id
|
||||
const toDocIds = (ids: number[]) =>
|
||||
ids
|
||||
.map((id) => sources.find((s) => s.id === id)?.document?.id)
|
||||
.filter((id): id is number => id !== undefined);
|
||||
const sourceIds =
|
||||
selectedSourceIds.length > 0
|
||||
? selectedSourceIds
|
||||
: sources.map((s) => s.id).filter(Boolean);
|
||||
? toDocIds(selectedSourceIds)
|
||||
: sources.map((s) => s.document?.id).filter((id): id is number => id !== undefined);
|
||||
|
||||
await thread.submit(
|
||||
{ messages: [{ type: "human", content: text }] },
|
||||
|
||||
@@ -350,9 +350,12 @@ export function useThreadStream({
|
||||
// agent thread URL — otherwise fall back to the global new chat.
|
||||
const hash = typeof window !== "undefined" ? window.location.hash : "";
|
||||
const agentMatch = hash.match(/#\/page\/workspace\/agents\/([^/]+)\//);
|
||||
const target = agentMatch
|
||||
? `/page/workspace/agents/${agentMatch[1]}/chats/new`
|
||||
: "/page/workspace/chats/new";
|
||||
const iframeMatch = hash.match(/#\/page\/workspace\/chats_iframe\//);
|
||||
const target = agentMatch
|
||||
? `/page/workspace/agents/${agentMatch[1]}/chats/new`
|
||||
: iframeMatch
|
||||
? "/page/workspace/chats_iframe/new"
|
||||
: "/page/workspace/chats/new";
|
||||
// First navigate to a safe URL so the post-reload page lands on a
|
||||
// valid route. Then full-reload so TanStack Query cache, context
|
||||
// providers and any other state captured under the previous user's
|
||||
|
||||
@@ -80,3 +80,15 @@ export function titleOfThread(thread: AgentThread, fallback = "Untitled") {
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function sanitizeThreadTitleForDisplay(title: string | null | undefined) {
|
||||
if (!title) return "Untitled";
|
||||
|
||||
const cleaned = title
|
||||
.replace(/<\/?[^>\n]+>/g, " ")
|
||||
.replace(/<[^>\n]*$/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
return cleaned || "Untitled";
|
||||
}
|
||||
Reference in new issue
Block a user