定时任务: - 管理员可查看/编辑/删除/触发全部用户的定时任务(新增 /api/scheduled-tasks/admin/* 接口与管理页) - 卡死任务恢复:单次执行超 30 分钟超时重试(最多 3 次),耗尽后标记 failed;看门狗回收服务重启遗留的「执行中」任务 会话分享: - 新增「只读链接」分享模式,生成公开免登录的只读对话快照页(/share/:code) - 公开接口 /api/public/shares/* 提供对话快照与图片附件 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
138 lines
4.6 KiB
TypeScript
138 lines
4.6 KiB
TypeScript
import type { Message } from "@langchain/langgraph-sdk";
|
|
|
|
import { apiFetch } from "@/core/api";
|
|
import { getBackendBaseURL } from "@/core/config";
|
|
|
|
/**
|
|
* Share modes:
|
|
* - `import` — recipient (a logged-in user) copies the conversation into
|
|
* their own account and can keep chatting in the copy.
|
|
* - `view` — a public, read-only static page anyone can open without
|
|
* logging in; viewers cannot send new messages.
|
|
*/
|
|
export type ThreadShareMode = "import" | "view";
|
|
|
|
export interface ThreadShare {
|
|
share_code: string;
|
|
owner_user_id: string;
|
|
thread_id: string;
|
|
revoked: boolean;
|
|
mode: ThreadShareMode;
|
|
created_at: string | null;
|
|
}
|
|
|
|
/** Read-only conversation snapshot returned by the public view-share endpoint. */
|
|
export interface PublicShareSnapshot {
|
|
share_code: string;
|
|
title: string;
|
|
mode: string;
|
|
messages: Message[];
|
|
artifacts: string[];
|
|
created_at: string | null;
|
|
}
|
|
|
|
export interface SharePreview {
|
|
share_code: string;
|
|
shared_by: string;
|
|
title: string;
|
|
}
|
|
|
|
export interface ImportResult {
|
|
imported: number;
|
|
skipped: number;
|
|
}
|
|
|
|
const baseUrl = () => `${getBackendBaseURL()}/api/thread-shares`;
|
|
|
|
async function parseError(response: Response, fallback: string): Promise<Error> {
|
|
const data = (await response.json().catch(() => ({}))) as { detail?: unknown };
|
|
const detail = data.detail;
|
|
const message =
|
|
typeof detail === "string" && detail
|
|
? detail
|
|
: `${fallback} (HTTP ${response.status})`;
|
|
return new Error(message);
|
|
}
|
|
|
|
/** Create (or reuse) a share code for one conversation in the given mode. */
|
|
export async function createShare(
|
|
threadId: string,
|
|
mode: ThreadShareMode = "import",
|
|
): Promise<ThreadShare> {
|
|
const response = await apiFetch(baseUrl(), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ thread_id: threadId, mode }),
|
|
});
|
|
if (!response.ok) throw await parseError(response, "创建分享失败");
|
|
return response.json() as Promise<ThreadShare>;
|
|
}
|
|
|
|
/** List the current user's own share links. */
|
|
export async function listShares(): Promise<ThreadShare[]> {
|
|
const response = await apiFetch(baseUrl());
|
|
if (!response.ok) throw await parseError(response, "加载分享列表失败");
|
|
const json = (await response.json()) as { shares: ThreadShare[] };
|
|
return json.shares;
|
|
}
|
|
|
|
/** Revoke a share link so it can no longer be previewed or imported. */
|
|
export async function revokeShare(shareCode: string): Promise<void> {
|
|
const response = await apiFetch(`${baseUrl()}/${encodeURIComponent(shareCode)}`, {
|
|
method: "DELETE",
|
|
});
|
|
if (!response.ok) throw await parseError(response, "撤销分享失败");
|
|
}
|
|
|
|
/** Preview a share link before importing it. */
|
|
export async function previewShare(shareCode: string): Promise<SharePreview> {
|
|
const response = await apiFetch(
|
|
`${baseUrl()}/${encodeURIComponent(shareCode)}/preview`,
|
|
);
|
|
if (!response.ok) throw await parseError(response, "无法读取该分享");
|
|
return response.json() as Promise<SharePreview>;
|
|
}
|
|
|
|
/** Import every conversation behind a share link into the current account. */
|
|
export async function importShare(shareCode: string): Promise<ImportResult> {
|
|
const response = await apiFetch(
|
|
`${baseUrl()}/${encodeURIComponent(shareCode)}/import`,
|
|
{ method: "POST" },
|
|
);
|
|
if (!response.ok) throw await parseError(response, "导入失败");
|
|
return response.json() as Promise<ImportResult>;
|
|
}
|
|
|
|
/**
|
|
* Fetch the public read-only snapshot of a `view`-mode share.
|
|
*
|
|
* Uses a plain `fetch` (no auth header) — the `/api/public/` endpoint is
|
|
* intentionally world-readable so anyone with the link can open it.
|
|
*/
|
|
export async function getPublicShareSnapshot(
|
|
shareCode: string,
|
|
): Promise<PublicShareSnapshot> {
|
|
const response = await fetch(
|
|
`${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(shareCode)}`,
|
|
);
|
|
if (!response.ok) throw await parseError(response, "无法打开该分享");
|
|
return response.json() as Promise<PublicShareSnapshot>;
|
|
}
|
|
|
|
/** Build the shareable public URL for a `view`-mode share code. */
|
|
export function buildPublicShareUrl(shareCode: string): string {
|
|
// The app uses HashRouter, so the public viewer lives at `#/share/<code>`.
|
|
return `${window.location.origin}${window.location.pathname}#/share/${shareCode}`;
|
|
}
|
|
|
|
/**
|
|
* Extract a share code from arbitrary pasted text — accepts either a raw code
|
|
* or a URL/link that contains one. Share codes are 6-12 chars from the
|
|
* unambiguous alphabet (no 0/1/O/I/L).
|
|
*/
|
|
export function extractShareCode(input: string): string {
|
|
const trimmed = input.trim();
|
|
const matches = trimmed.toUpperCase().match(/[A-HJ-NP-Z2-9]{6,12}/g);
|
|
return matches && matches.length > 0 ? matches[matches.length - 1] : trimmed;
|
|
}
|