Merge branch 'ji' into master

按钮管理「登录参数」两套实现冲突解决——两分支各自独立给跳转按钮加了登录态追加能力,
二者正交、保留两套,互不影响:
- master: login_params (JSON 列 login_params_json,从 userInfo 解析,类似轻应用登录参数)
- ji: append_auth + auth_token_param + auth_name_param (token 登录时追加 token1+用户名)

迁移 revision 碰撞修复(两分支都用了 20260625_01):重编号为线性链
  20260620_02 → 20260625_01 (login_params) → 20260625_02 (auth_params) → 20260625_03 (users_approved)
并同步 column_additions_mysql.sql 注释里的 migration 编号。

前后端 task_buttons(model/sql/memory/router/前端 type·api·admin 页)均保留两套字段;
两份 CLAUDE.md 文档合并为同时记录两种机制。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-25 19:36:43 +08:00
commit 58357547c9
44 files changed
+1207 -46

No files matched your search

@@ -11,6 +11,7 @@ import {
MousePointerClick,
Settings,
ShieldAlert,
ShieldCheck,
User,
Wrench,
} from "lucide-react";
@@ -222,6 +223,14 @@ function SecondaryNavSidebar() {
按钮管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/login-whitelist")}
>
<ShieldCheck className="size-4" />
白名单管理
</DropdownMenuItem>
)}
{canManageSensitiveWords() && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/sensitive-words")}
@@ -508,6 +517,12 @@ function LegacyAccordionSidebar({
按钮管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
<ShieldCheck className="size-4" />
白名单管理
</DropdownMenuItem>
)}
{canManageSensitiveWords() && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/sensitive-words")}>
<ShieldAlert className="size-4" />
@@ -138,6 +138,7 @@ export function MessageListItem({
extraRehypePlugins,
onEditResubmit,
onMessageDeleted,
turnTime,
}: {
className?: string;
fitRowToContent?: boolean;
@@ -154,6 +155,13 @@ export function MessageListItem({
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onEditResubmit?: (text: string) => void;
onMessageDeleted?: (deletedIds: string[]) => void;
/**
* Durable ask time of the turn this message belongs to (the preceding
* question's `client_ts`). Used to time-stamp the AI answer, which carries no
* timestamp of its own — without it the answer would fall back to the current
* wall clock at render time and appear to "drift" to now on every reload.
*/
turnTime?: string;
}) {
const isHuman = message.type === 'human';
const messageMarkdown =
@@ -182,6 +190,7 @@ export function MessageListItem({
extraRehypePlugins={extraRehypePlugins}
onEditResubmit={onEditResubmit}
onMessageDeleted={onMessageDeleted}
turnTime={turnTime}
/>
{/*
Human messages carry their own inline copy/edit toolbar inside
@@ -390,6 +399,7 @@ function MessageContent_({
extraRehypePlugins,
onEditResubmit,
onMessageDeleted,
turnTime,
}: {
className?: string;
message: Message;
@@ -400,6 +410,7 @@ function MessageContent_({
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onEditResubmit?: (text: string) => void;
onMessageDeleted?: (deletedIds: string[]) => void;
turnTime?: string;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
@@ -456,9 +467,15 @@ function MessageContent_({
[threadId, resolveArtifact],
);
// Question carries its own `client_ts` (round-trips through the checkpoint).
// The AI answer has no timestamp of its own, so it inherits the turn's ask
// time (its question's `client_ts`, threaded down as `turnTime`). This is
// durable and turn-accurate, and — unlike the old per-id `now` fallback —
// never drifts to the current clock when an unfinished answer is reloaded.
const ownAskTime = message.id ? askTimes[message.id] : undefined;
const messageTime = formatAskedTime(
message.additional_kwargs?.client_ts ??
(message.id ? askTimes[message.id] : undefined),
(isHuman ? ownAskTime : (turnTime ?? ownAskTime)),
);
const rawContent = extractContentFromMessage(message);
@@ -11,7 +11,7 @@ import {
} from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { rememberAskTimes } from "@/core/chat-timestamps";
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
@@ -167,15 +167,24 @@ export function MessageList({
[allMessages, hiddenMessageIds],
);
// Stamp a wall-clock time onto every question/answer the first time it is
// seen, in a single batched commit. Questions sent this session already carry
// an accurate time (see useThreadStream); this also covers assistant answers
// and messages loaded from history. First-write-wins keeps the earliest time.
const askTimes = useChatAskTimes();
// Stamp a wall-clock time onto every *question* the first time it is seen, in
// a single batched commit. Questions sent this session already carry an
// accurate `client_ts` (see useThreadStream) and are skipped; this only backs
// up legacy questions loaded from history that predate `client_ts`.
// First-write-wins keeps the earliest time.
//
// AI answers are deliberately NOT stamped here: they carry no timestamp of
// their own, so a `now` stamp would be the *load* time, not the answer time —
// and for an unfinished/interrupted turn that re-stamps to the current clock
// on every reload. Instead the answer inherits its question's durable time
// via `turnTimes` below.
useEffect(() => {
const now = new Date().toISOString();
const entries: Record<string, string> = {};
for (const message of messages) {
if (message.type !== "human" && message.type !== "ai") continue;
if (message.type !== "human") continue;
if (!message.id || message.id.startsWith("opt-")) continue;
if (typeof message.additional_kwargs?.client_ts === "string") continue;
entries[message.id] = now;
@@ -183,6 +192,24 @@ export function MessageList({
if (Object.keys(entries).length > 0) rememberAskTimes(entries);
}, [messages]);
// Durable ask time per message id, used to time-stamp the AI answer (which has
// no timestamp of its own) with the time of the question that triggered it.
const turnTimes = useMemo(() => {
const map = new Map<string, string>();
let lastQuestionTime: string | undefined;
for (const message of messages) {
if (message.type === "human") {
const ts = message.additional_kwargs?.client_ts;
const fromAskTimes = message.id ? askTimes[message.id] : undefined;
lastQuestionTime =
typeof ts === "string" && ts ? ts : fromAskTimes;
} else if (message.type === "ai" && message.id && lastQuestionTime) {
map.set(message.id, lastQuestionTime);
}
}
return map;
}, [messages, askTimes]);
const [settings] = useLocalSettings();
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
@@ -358,6 +385,7 @@ export function MessageList({
onMessageDeleted={
allowDelete ? handleMessageDeleted : undefined
}
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
/>
);
});
@@ -31,6 +31,44 @@ export function formatDuration(ms: number): string {
return `${mins}m ${secs}s`;
}
/**
* 速度条颜色含义图例 —— 生成结束后常驻在速度条下方一行,向用户解释进度条
* 颜色代表的「模型生成延迟」状态。进度条本身仍是 5 档(极快/流畅/一般/偏慢/
* 缓慢)的连续配色;这里把它归并成用户视角的 3 类延迟 + 「无响应」来说明:
* 绿(#16a34a,极快/流畅) → 延迟正常
* 黄(#eab308,一般) → 延迟微长
* 红(#ef4444,偏慢/缓慢) → 延迟长,可能失败
* 灰(#9ca3af) → 无响应(无进度条/0 tok/s),任务失败,请重试
*/
const SPEED_LEGEND: { color: string; label: string }[] = [
{ color: "#16a34a", label: "延迟正常" },
{ color: "#eab308", label: "延迟微长" },
{ color: "#ef4444", label: "延迟长,可能失败" },
{ color: "#9ca3af", label: "无响应,任务失败,请重试" },
];
export function SpeedLegend({ className }: { className?: string }) {
return (
<span
className={cn(
"text-muted-foreground inline-flex flex-wrap items-center gap-x-2.5 gap-y-0.5",
className,
)}
>
{SPEED_LEGEND.map((item) => (
<span className="inline-flex items-center gap-1" key={item.label}>
<span
aria-hidden
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: item.color }}
/>
{item.label}
</span>
))}
</span>
);
}
export function SpeedPill({ metric }: { metric: MessageMetric }) {
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
const { color, label } = classifySpeed(metric.tokensPerSec);
@@ -132,6 +170,8 @@ export function MessageTokenUsage({
{formatDuration(resolvedMetric.durationMs)}
</span>
)}
{/* 生成结束(非流式中)后常驻一行颜色含义说明。 */}
{!resolvedMetric.isActive && <SpeedLegend className="basis-full" />}
</>
) : null}
</div>