This commit is contained in:
董宏凯 committed 2026-06-03 21:07:17 +08:00
commit 5026bd1887
19 files changed
+327 -110

No files matched your search

+1 -1
View File
@@ -26,7 +26,7 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# 我的笔记前端
VITE_AIPI_FRONTEND_URL= xxx
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
+1
View File
@@ -25,6 +25,7 @@ VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -400,6 +400,47 @@ export function ArtifactFileDetail({
}
}, [displayContent, filepath]);
const handleOpenWriteFileInNewTab = useCallback(() => {
const source = displayContent ?? "";
if (!source.trim()) {
toast.error("文件内容为空,暂时无法打开");
return;
}
const blobType =
language === "html"
? "text/html;charset=utf-8"
: language === "markdown"
? "text/markdown;charset=utf-8"
: "text/plain;charset=utf-8";
const blob = new Blob([source], { type: blobType });
const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
}, [displayContent, language]);
const handleDownloadWriteFile = useCallback(() => {
const source = displayContent ?? "";
if (!source.trim()) {
toast.error("文件内容为空,暂时无法下载");
return;
}
const blobType =
language === "html"
? "text/html;charset=utf-8"
: language === "markdown"
? "text/markdown;charset=utf-8"
: "text/plain;charset=utf-8";
const blob = new Blob([source], { type: blobType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = getFileName(filepath);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, [displayContent, filepath, language]);
return (
<Artifact className={cn(className)}>
<ArtifactHeader className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 px-4 py-3">
@@ -500,30 +541,32 @@ export function ArtifactFileDetail({
/>
</Tooltip>
)}
{!isWriteFile && (
<ArtifactAction
icon={SquareArrowOutUpRightIcon}
label={t.common.openInNewWindow}
tooltip={t.common.openInNewWindow}
onClick={async () => {
try {
await openArtifactInNewTab({
filepath,
threadId,
isMock,
publicShareCode,
});
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Failed to open artifact",
);
}
}}
/>
)}
<ArtifactAction
icon={SquareArrowOutUpRightIcon}
label={t.common.openInNewWindow}
tooltip={t.common.openInNewWindow}
onClick={async () => {
if (isWriteFile) {
handleOpenWriteFileInNewTab();
return;
}
try {
await openArtifactInNewTab({
filepath,
threadId,
isMock,
publicShareCode,
});
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Failed to open artifact",
);
}
}}
/>
{isCodeFile && (
<ArtifactAction
icon={CopyIcon}
@@ -550,30 +593,32 @@ export function ArtifactFileDetail({
onClick={handleDownloadAsWord}
/>
)}
{!isWriteFile && (
<ArtifactAction
icon={DownloadIcon}
label={t.common.download}
tooltip={t.common.download}
onClick={async () => {
try {
await downloadArtifactFile({
filepath,
threadId,
isMock,
publicShareCode,
});
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Download failed",
);
}
}}
/>
)}
<ArtifactAction
icon={DownloadIcon}
label={t.common.download}
tooltip={t.common.download}
onClick={async () => {
if (isWriteFile) {
handleDownloadWriteFile();
return;
}
try {
await downloadArtifactFile({
filepath,
threadId,
isMock,
publicShareCode,
});
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Download failed",
);
}
}}
/>
<ArtifactAction
className="hover:bg-background"
icon={XIcon}
@@ -905,9 +905,14 @@ function ClarificationCard({
return;
}
if (!allowMultiple) {
const next = { selectedIds: [optionId], custom: "" };
// 单选:仅当「只有这一张待答澄清卡」时才点击即发送。多张澄清卡同时出现时,
// 点击只记录本卡选择,待最后一张的「全部发送」统一提交——否则点了第一张就立刻
// 提交整批,后面的卡片再也无法选择(见 bug:选了第一个,第二个不让选)。
const next = { selectedIds: [optionId], custom: selection.custom };
onSelectionChange(next);
void onSubmitAll(next);
if (!multiPending) {
void onSubmitAll(next);
}
return;
}
const next = selection.selectedIds.includes(optionId)
@@ -947,7 +952,11 @@ function ClarificationCard({
{options.length > 0 ? (
<div className="mt-4 grid gap-2">
<div className="text-muted-foreground text-xs">
{allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
{allowMultiple
? "可多选(再次点击取消选择)"
: multiPending
? "请选择一项"
: "请选择一项(点击即发送)"}
</div>
{options.map((option) => {
const optionId = option.id || option.label;
@@ -956,7 +965,7 @@ function ClarificationCard({
<Button
key={optionId}
type="button"
variant={isSelected && allowMultiple ? "default" : "outline"}
variant={isSelected && (allowMultiple || multiPending) ? "default" : "outline"}
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
disabled={disabled}
onClick={() => handleOptionClick(optionId)}
@@ -976,7 +985,7 @@ function ClarificationCard({
</span>
) : null}
</span>
{isSelected && allowMultiple ? (
{isSelected && (allowMultiple || multiPending) ? (
<CheckIcon className="ml-2 h-4 w-4 shrink-0" />
) : null}
</Button>
@@ -1001,10 +1010,10 @@ function ClarificationCard({
</div>
) : null}
{isPending && (allowMultiple || payload.allow_custom !== false) ? (
{isPending && (allowMultiple || multiPending || payload.allow_custom !== false) ? (
isLastPending ? (
<div className="mt-3 flex items-center justify-end gap-2">
{multiPending && allowMultiple ? (
{multiPending ? (
<span className="text-muted-foreground text-xs">
将一起提交以上 {pendingCount} 个问题的回答
</span>
@@ -1016,10 +1025,10 @@ function ClarificationCard({
onClick={() => void onSubmitAll()}
>
<SendIcon className="h-4 w-4" />
{submitting ? "发送中…" : multiPending && allowMultiple ? "全部发送" : "发送回答"}
{submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"}
</Button>
</div>
) : allowMultiple ? (
) : allowMultiple || multiPending ? (
<div className="text-muted-foreground mt-3 text-right text-xs">
选择后等待最后一题统一提交
</div>
@@ -213,7 +213,7 @@ export function RecentChatList() {
className="block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
{desensitize(titleOfThread(thread))}
{desensitize(titleOfThread(thread, t.pages.newChat))}
</Link>
</SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
@@ -236,7 +236,7 @@ export function RecentChatList() {
onSelect={() =>
handleRenameClick(
thread.thread_id,
titleOfThread(thread),
titleOfThread(thread, t.pages.newChat),
)
}
>
+34 -8
View File
@@ -64,24 +64,50 @@ export async function loginByUsername(
return response.json() as Promise<LoginByUsernameResponse>;
}
// The upstream token issuer and the verifier (token_info_url) run on separate
// servers whose clocks can drift ~3-4s apart. A token verified right after it
// is issued can look "not yet valid / expired" to the verifier, which surfaces
// as a 401 here. That window closes on its own once real time advances past the
// skew, so we silently retry the exchange a couple of times before giving up —
// the LoginPage keeps showing its loading state, so the user never notices.
const TOKEN_LOGIN_MAX_ATTEMPTS = 3; // initial try + 2 retries → covers up to ~6s skew
const TOKEN_LOGIN_RETRY_DELAY_MS = 3000;
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
export async function loginByToken(
token: string,
): Promise<LoginByUsernameResponse> {
// Token-exchange login: the gateway resolves the upstream token to a username
// and signs the matching local account in.
const response = await apiFetch(
`${getBackendBaseURL()}/api/v1/auth/login/token?token=${encodeURIComponent(token)}`,
{
const url = `${getBackendBaseURL()}/api/v1/auth/login/token?token=${encodeURIComponent(token)}`;
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 (!response.ok) {
});
if (response.ok) {
return response.json() as Promise<LoginByUsernameResponse>;
}
const error = await response.json().catch(() => null);
throw new Error(extractErrorMessage(error));
lastError = extractErrorMessage(error);
// Only 401 is plausibly the transient clock-skew case and worth waiting on.
// 403 (disabled), 429 (rate-limited), 500 (misconfigured) won't self-heal,
// so fail fast instead of stalling the user.
const isTransient = response.status === 401;
if (!isTransient || attempt === TOKEN_LOGIN_MAX_ATTEMPTS) {
throw new Error(lastError);
}
await wait(TOKEN_LOGIN_RETRY_DELAY_MS);
}
return response.json() as Promise<LoginByUsernameResponse>;
// Unreachable: the loop either returns or throws above.
throw new Error(lastError);
}
+17 -2
View File
@@ -62,8 +62,23 @@ export function textOfMessage(message: Message) {
return null;
}
export function titleOfThread(thread: AgentThread) {
return thread.values?.title ?? "Untitled";
export function titleOfThread(thread: AgentThread, fallback = "Untitled") {
const title = thread.values?.title;
if (title) return title;
// No title yet (e.g. the very first turn is still streaming, or the backend
// title pass hasn't finished). Derive a sensible display title from the first
// user message so the user never sees a bare "Untitled" placeholder.
const messages = thread.values?.messages;
if (Array.isArray(messages)) {
const firstHuman = messages.find((m) => m.type === "human");
const text = firstHuman ? textOfMessage(firstHuman)?.trim() : null;
if (text) {
return text.length > 50 ? `${text.slice(0, 50).trimEnd()}…` : text;
}
}
return fallback;
}
export function sanitizeThreadTitleForDisplay(title: string | null | undefined) {
@@ -77,16 +77,24 @@ export function WritingSetupChat() {
setThreadPrepared(true);
}, [threadId, threadPrepared]);
// 生成中点提交按钮 = 暂停:取消当前 SSE 流(与主聊天页一致)。
const handleChatSubmit = useCallback(
async (text: string) => {
if (thread.isLoading) {
void thread.stop();
return;
}
const trimmed = text.trim();
if (!trimmed || thread.isLoading) return;
if (!trimmed) return;
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
},
[ensureThreadExists, sendMessage, thread.isLoading, threadId],
[thread, ensureThreadExists, sendMessage, threadId],
);
// 提交按钮状态:生成中显示「停止」方块,可点击暂停对话
const submitStatus = thread.error ? 'error' : thread.isLoading ? 'streaming' : 'ready';
const handleApprove = useCallback(
async (payload: SetupWritingApprovalPayload) => {
applyProposal(payload);
@@ -150,10 +158,9 @@ export function WritingSetupChat() {
<PromptInput onSubmit={({ text }) => void handleChatSubmit(text)}>
<PromptInputTextarea
placeholder="例如:帮我写一篇 2000 字、面向行业专家的新能源汽车出口分析"
disabled={thread.isLoading}
/>
<PromptInputFooter className="justify-end">
<PromptInputSubmit disabled={thread.isLoading} />
<PromptInputSubmit status={submitStatus} />
</PromptInputFooter>
</PromptInput>
</div>
+1 -1
View File
@@ -71,7 +71,7 @@ export default function ChatsPage() {
<Link key={thread.thread_id} href={pathOfThread(thread)}>
<div className="flex flex-col gap-2 border-b p-4">
<div>
<div>{desensitize(titleOfThread(thread))}</div>
<div>{desensitize(titleOfThread(thread, t.pages.newChat))}</div>
</div>
{thread.updated_at && (
<div className="text-muted-foreground text-sm">