新增智能体收藏、定时任务必须生成MD校验、问答时间显示及账号绑定的常用提示词
- 智能体支持从广场收藏到「我的」(新增 agent_favorites 表与收藏/取消收藏接口) - 定时任务新增「必须生成Markdown文件」开关:注入提示词并校验生成的 .md 文件且正文超过100字,否则记为失败(存于 execution_context,无需迁移) - 聊天问答展示时间(年月日 时分):问题在右下角、回答在左下角 - 常用提示词改为与账号绑定(新增 user_custom_prompts 表与 /api/user-prompts 接口),仅在首页输入框上方以居中标签 + 轮播展示,齿轮置于最右侧管理 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
4410acaee8
commit
10e35055e0
45 files changed
+1653
-145
No files matched your search
@@ -9,6 +9,7 @@ import {
|
||||
ShieldIcon,
|
||||
SparklesIcon,
|
||||
SquarePenIcon,
|
||||
StarIcon,
|
||||
TagsIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
useDeleteAgent,
|
||||
useSetAgentFeatured,
|
||||
useSetAgentPrivate,
|
||||
useToggleAgentFavorite,
|
||||
useUpdateAgent,
|
||||
} from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
@@ -64,6 +66,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
const updateAgent = useUpdateAgent();
|
||||
const setFeatured = useSetAgentFeatured();
|
||||
const setPrivate = useSetAgentPrivate();
|
||||
const toggleFavorite = useToggleAgentFavorite();
|
||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -91,6 +94,10 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
// everyone so toggling published is moot.
|
||||
const canEdit = isOwned || isAdmin;
|
||||
const canDelete = isOwned || isAdmin;
|
||||
// Favoriting collects a square agent into "我的"; pointless for one you
|
||||
// already own, so only offer it on built-in / others' agents.
|
||||
const canFavorite = !isOwned;
|
||||
const isFavorite = !!agent.is_favorite;
|
||||
|
||||
useEffect(() => {
|
||||
if (!editOpen) {
|
||||
@@ -197,6 +204,16 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleFavorite() {
|
||||
const next = !isFavorite;
|
||||
try {
|
||||
await toggleFavorite.mutateAsync({ id: agent.id, favorite: next });
|
||||
toast.success(next ? "已收藏到「我的」" : "已取消收藏");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleFeatured() {
|
||||
// One-click on/off — ordering is handled by the dedicated 管理 Agent 列表
|
||||
// dialog. Featured agents get appended to the end on add (Date.now() is a
|
||||
@@ -347,8 +364,25 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
)}
|
||||
|
||||
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-3">
|
||||
{(canEdit || canDelete || isOwned || isAdmin) && (
|
||||
{(canEdit || canDelete || isOwned || isAdmin || canFavorite) && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||
{canFavorite && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"mr-auto h-8 w-8 shrink-0",
|
||||
isFavorite && "text-amber-500 hover:text-amber-600",
|
||||
)}
|
||||
onClick={() => void handleToggleFavorite()}
|
||||
disabled={toggleFavorite.isPending}
|
||||
title={isFavorite ? "取消收藏" : "收藏到「我的」"}
|
||||
>
|
||||
<StarIcon
|
||||
className={cn("h-3.5 w-3.5", isFavorite && "fill-current")}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="icon"
|
||||
|
||||
@@ -89,12 +89,15 @@ export function AgentGallery() {
|
||||
[agents, search],
|
||||
);
|
||||
|
||||
// "我的" shows the user's own agents plus any agent they've favorited (收藏)
|
||||
// from the square — favorited public agents have a different user_id but a
|
||||
// truthy is_favorite flag.
|
||||
const myAgents = useMemo(
|
||||
() =>
|
||||
agents.filter(
|
||||
(a) =>
|
||||
a.user_id !== null &&
|
||||
a.user_id === currentUserId &&
|
||||
((a.user_id !== null && a.user_id === currentUserId) ||
|
||||
a.is_favorite) &&
|
||||
matchesSearch(a),
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -152,17 +152,17 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
() =>
|
||||
adaptBackendReferenceBatches(
|
||||
backendReferenceBatches,
|
||||
fallbackReferenceBatches,
|
||||
skillDisplayMap,
|
||||
),
|
||||
[backendReferenceBatches, fallbackReferenceBatches, skillDisplayMap],
|
||||
[backendReferenceBatches, skillDisplayMap],
|
||||
);
|
||||
const referenceBatches = useMemo(
|
||||
() =>
|
||||
mergeReferenceBatches(
|
||||
adaptedBackendReferenceBatches,
|
||||
fallbackReferenceBatches,
|
||||
),
|
||||
() => {
|
||||
if (adaptedBackendReferenceBatches.length > 0) {
|
||||
return adaptedBackendReferenceBatches;
|
||||
}
|
||||
return fallbackReferenceBatches;
|
||||
},
|
||||
[adaptedBackendReferenceBatches, fallbackReferenceBatches],
|
||||
);
|
||||
useEffect(() => {
|
||||
@@ -188,6 +188,10 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
fallbackSourceCounts: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
finalBatchCount: referenceBatches.length,
|
||||
finalSourceCounts: referenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
backendBatches: debugReferenceBatches(backendReferenceBatches),
|
||||
adaptedBackendBatches: debugReferenceBatches(adaptedBackendReferenceBatches),
|
||||
fallbackBatches: debugReferenceBatches(fallbackReferenceBatches),
|
||||
finalBatches: debugReferenceBatches(referenceBatches),
|
||||
});
|
||||
}, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]);
|
||||
const latestReferenceBatch =
|
||||
@@ -199,6 +203,17 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
latestReferenceBatch;
|
||||
const referenceSources = activeReferenceBatch?.sources ?? [];
|
||||
const hasCitationReferences = referenceSources.length > 0;
|
||||
useEffect(() => {
|
||||
if (!ragModeEnabled || !activeReferenceBatch) {
|
||||
return;
|
||||
}
|
||||
logReferenceDebug("frontend-active-reference-batch-payload", threadId, {
|
||||
activeBatchId: activeReferenceBatch.id,
|
||||
assistantMessageId: activeReferenceBatch.assistant_message_id,
|
||||
sourceCount: activeReferenceBatch.sources.length,
|
||||
sources: activeReferenceBatch.sources.map(debugReferenceSource),
|
||||
});
|
||||
}, [activeReferenceBatch, ragModeEnabled, threadId]);
|
||||
useEffect(() => {
|
||||
if (hasCitationReferences) {
|
||||
setReferencesOpen(true);
|
||||
@@ -503,36 +518,60 @@ function ReferencePanel({
|
||||
);
|
||||
}
|
||||
|
||||
function debugReferenceBatches(batches: BackendReferenceBatch[]) {
|
||||
return batches.map((batch) => ({
|
||||
id: batch.id,
|
||||
assistantMessageId: batch.assistant_message_id,
|
||||
sourceCount: batch.sources?.length ?? 0,
|
||||
sources: (batch.sources ?? []).map(debugReferenceSource),
|
||||
}));
|
||||
}
|
||||
|
||||
function debugReferenceSource(source: BackendReferenceSource) {
|
||||
const raw =
|
||||
source.raw && typeof source.raw === "object" && !Array.isArray(source.raw)
|
||||
? (source.raw as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
index: source.index,
|
||||
id: source.id,
|
||||
skillName: source.skillName,
|
||||
mode: source.mode,
|
||||
title: source.title,
|
||||
source: source.source,
|
||||
author: source.author,
|
||||
time: source.time,
|
||||
url: source.url,
|
||||
snippet: source.snippet,
|
||||
content: source.content,
|
||||
rawIdCandidates: {
|
||||
recUuid: raw.recUuid,
|
||||
rec_uuid: raw.rec_uuid,
|
||||
uuid: raw.uuid,
|
||||
docId: raw.docId,
|
||||
doc_id: raw.doc_id,
|
||||
id: raw.id,
|
||||
url: raw.url,
|
||||
},
|
||||
raw: source.raw,
|
||||
};
|
||||
}
|
||||
|
||||
function adaptBackendReferenceBatches(
|
||||
backendBatches: BackendReferenceBatch[],
|
||||
configuredBatches: BackendReferenceBatch[],
|
||||
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
|
||||
): BackendReferenceBatch[] {
|
||||
if (backendBatches.length === 0) {
|
||||
return backendBatches;
|
||||
}
|
||||
return backendBatches.map((backendBatch, batchIndex) => {
|
||||
const configuredBatch =
|
||||
configuredBatches.find(
|
||||
(batch) =>
|
||||
batch.id === backendBatch.id ||
|
||||
batch.assistant_message_id === backendBatch.assistant_message_id,
|
||||
) ?? configuredBatches[batchIndex];
|
||||
const configuredSources = configuredBatch?.sources ?? [];
|
||||
const configuredByIndex = new Map(
|
||||
configuredSources.map((source) => [source.index, source]),
|
||||
);
|
||||
const configuredByKey = new Map(
|
||||
configuredSources.map((source) => [referenceSourceKey(source), source]),
|
||||
);
|
||||
return backendBatches.map((backendBatch) => {
|
||||
return {
|
||||
...backendBatch,
|
||||
sources: backendBatch.sources.map((backendSource, sourceIndex) => {
|
||||
const configuredSource =
|
||||
configuredByIndex.get(backendSource.index) ??
|
||||
configuredByKey.get(referenceSourceKey(backendSource)) ??
|
||||
configuredSources[sourceIndex] ??
|
||||
adaptBackendSourceWithSkillDisplay(backendSource, skillDisplayMap);
|
||||
sources: backendBatch.sources.map((backendSource) => {
|
||||
const configuredSource = adaptBackendSourceWithSkillDisplay(
|
||||
backendSource,
|
||||
skillDisplayMap,
|
||||
);
|
||||
if (!configuredSource) {
|
||||
return backendSource;
|
||||
}
|
||||
@@ -551,35 +590,6 @@ function adaptBackendReferenceBatches(
|
||||
});
|
||||
}
|
||||
|
||||
function mergeReferenceBatches(
|
||||
backendBatches: BackendReferenceBatch[],
|
||||
fallbackBatches: BackendReferenceBatch[],
|
||||
): BackendReferenceBatch[] {
|
||||
if (backendBatches.length === 0) {
|
||||
return fallbackBatches;
|
||||
}
|
||||
if (fallbackBatches.length === 0) {
|
||||
return backendBatches;
|
||||
}
|
||||
const merged = [...backendBatches];
|
||||
const knownIds = new Set(
|
||||
backendBatches.flatMap((batch) =>
|
||||
[batch.id, batch.assistant_message_id].filter(Boolean),
|
||||
),
|
||||
);
|
||||
for (const fallbackBatch of fallbackBatches) {
|
||||
if (
|
||||
knownIds.has(fallbackBatch.id) ||
|
||||
(fallbackBatch.assistant_message_id &&
|
||||
knownIds.has(fallbackBatch.assistant_message_id))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
merged.push(fallbackBatch);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function adaptBackendSourceWithSkillDisplay(
|
||||
source: BackendReferenceSource,
|
||||
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
|
||||
@@ -657,37 +667,6 @@ function buildPayloadAtPath(path: string, items: Record<string, unknown>[]) {
|
||||
return root;
|
||||
}
|
||||
|
||||
function referenceSourceKey(source: BackendReferenceSource): string {
|
||||
const raw = source.raw ?? {};
|
||||
const rawRecord =
|
||||
raw && typeof raw === "object" && !Array.isArray(raw)
|
||||
? (raw as Record<string, unknown>)
|
||||
: {};
|
||||
const id =
|
||||
source.id ||
|
||||
source.url ||
|
||||
stringifyKeyValue(rawRecord.recUuid) ||
|
||||
stringifyKeyValue(rawRecord.uuid) ||
|
||||
stringifyKeyValue(rawRecord.docId) ||
|
||||
stringifyKeyValue(rawRecord.id);
|
||||
if (id) {
|
||||
return id.trim().toLowerCase();
|
||||
}
|
||||
return [source.title, source.source, source.time, source.content, source.snippet]
|
||||
.filter(Boolean)
|
||||
.join("|")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function stringifyKeyValue(value: unknown): string | undefined {
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
const text = String(value).trim();
|
||||
return text || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findPreviousHumanMessageId(
|
||||
messages: Message[],
|
||||
beforeMessageId?: string,
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
PencilIcon,
|
||||
Settings2Icon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useCreateCustomPrompt,
|
||||
useCustomPrompts,
|
||||
useDeleteCustomPrompt,
|
||||
useUpdateCustomPrompt,
|
||||
} from "@/core/custom-prompts";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Bar above the chat input showing the user's saved prompts as centered,
|
||||
* clickable chips. When there are too many to fit, they become a horizontal
|
||||
* carousel (arrows appear). A gear pinned to the far right opens the manage
|
||||
* dialog. Prompts are bound to the account (server-backed), and this coexists
|
||||
* with the admin-configured global prompt prefix.
|
||||
*/
|
||||
export function CustomPromptBar({
|
||||
onInsert,
|
||||
className,
|
||||
}: {
|
||||
onInsert: (content: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const prompts = useCustomPrompts();
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canLeft, setCanLeft] = useState(false);
|
||||
const [canRight, setCanRight] = useState(false);
|
||||
|
||||
const updateArrows = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
setCanLeft(el.scrollLeft > 4);
|
||||
setCanRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updateArrows();
|
||||
window.addEventListener("resize", updateArrows);
|
||||
return () => window.removeEventListener("resize", updateArrows);
|
||||
}, [updateArrows, prompts]);
|
||||
|
||||
const scrollByDir = (dir: 1 | -1) => {
|
||||
scrollRef.current?.scrollBy({ left: dir * 220, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const overflowing = canLeft || canRight;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex items-center gap-1", className)}>
|
||||
{prompts.length > 0 && (
|
||||
<>
|
||||
{overflowing && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground h-7 w-7 shrink-0 rounded-full"
|
||||
disabled={!canLeft}
|
||||
onClick={() => scrollByDir(-1)}
|
||||
aria-label="上一组"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={updateArrows}
|
||||
className="flex-1 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
<div className="mx-auto flex w-max items-center gap-1.5 px-0.5 py-0.5">
|
||||
{prompts.map((prompt) => (
|
||||
<button
|
||||
key={prompt.id}
|
||||
type="button"
|
||||
onClick={() => onInsert(prompt.content)}
|
||||
title={prompt.content}
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 text-foreground/80 hover:bg-muted hover:text-foreground hover:border-border max-w-[200px] shrink-0 truncate rounded-full border px-3 py-1 text-xs font-medium shadow-sm transition-colors",
|
||||
"dark:border-slate-600/70 dark:bg-slate-700/40 dark:text-slate-200 dark:hover:bg-slate-700",
|
||||
)}
|
||||
>
|
||||
{prompt.title || prompt.content || "未命名提示词"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{overflowing && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground h-7 w-7 shrink-0 rounded-full"
|
||||
disabled={!canRight}
|
||||
onClick={() => scrollByDir(1)}
|
||||
aria-label="下一组"
|
||||
>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{prompts.length === 0 && <div className="flex-1" />}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full"
|
||||
title="管理常用提示词"
|
||||
aria-label="管理常用提示词"
|
||||
onClick={() => setManageOpen(true)}
|
||||
>
|
||||
<Settings2Icon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ManagePromptsDialog open={manageOpen} onOpenChange={setManageOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ManagePromptsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const prompts = useCustomPrompts();
|
||||
const createPrompt = useCreateCustomPrompt();
|
||||
const updatePrompt = useUpdateCustomPrompt();
|
||||
const deletePrompt = useDeleteCustomPrompt();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
const saving = createPrompt.isPending || updatePrompt.isPending;
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingId(null);
|
||||
setTitle("");
|
||||
setContent("");
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmedTitle = title.trim();
|
||||
if (!content.trim()) {
|
||||
toast.error("提示词内容不能为空");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (editingId) {
|
||||
await updatePrompt.mutateAsync({
|
||||
id: editingId,
|
||||
input: { title: trimmedTitle, content },
|
||||
});
|
||||
toast.success("提示词已更新");
|
||||
} else {
|
||||
await createPrompt.mutateAsync({ title: trimmedTitle, content });
|
||||
toast.success("提示词已添加");
|
||||
}
|
||||
resetForm();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
const target = prompts.find((p) => p.id === id);
|
||||
if (!target) return;
|
||||
setEditingId(id);
|
||||
setTitle(target.title);
|
||||
setContent(target.content);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deletePrompt.mutateAsync(id);
|
||||
if (editingId === id) resetForm();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) resetForm();
|
||||
onOpenChange(next);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>管理常用提示词</DialogTitle>
|
||||
<DialogDescription>
|
||||
保存常用的提示词,保存后会以小标签形式显示在输入框上方,点击即可填入。提示词与你的账号绑定,可跨设备使用。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="名称(可选,例如:周报总结)"
|
||||
/>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="提示词内容"
|
||||
className="min-h-28"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
{editingId && (
|
||||
<Button variant="outline" size="sm" onClick={resetForm} disabled={saving}>
|
||||
取消编辑
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={handleSubmit} disabled={saving}>
|
||||
{editingId ? "保存修改" : "添加提示词"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{prompts.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
暂无常用提示词
|
||||
</p>
|
||||
) : (
|
||||
prompts.map((prompt) => (
|
||||
<div
|
||||
key={prompt.id}
|
||||
className="flex items-start justify-between gap-2 rounded-md border p-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{prompt.title || "未命名提示词"}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs whitespace-pre-wrap">
|
||||
{prompt.content}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleEdit(prompt.id)}
|
||||
title="编辑"
|
||||
>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-8 w-8"
|
||||
onClick={() => void handleDelete(prompt.id)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -87,13 +87,14 @@ import {
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
useLocalSettings,
|
||||
} from "@/core/settings";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
useLocalSettings,
|
||||
} from "@/core/settings";
|
||||
|
||||
|
||||
import { CustomPromptBar } from "./custom-prompt-menu";
|
||||
import { useThread } from "./messages/context";
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { Tooltip } from "./tooltip";
|
||||
@@ -304,14 +305,14 @@ export function InputBox({
|
||||
});
|
||||
}, [context, memoryInjectionEnabled, onContextChange]);
|
||||
|
||||
const ragModeLocked = isThreadRagModeLocked(threadId);
|
||||
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
|
||||
const handleRagModeToggle = useCallback(() => {
|
||||
if (ragModeLocked) {
|
||||
return;
|
||||
}
|
||||
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
|
||||
}, [ragModeEnabled, ragModeLocked, setLocalSettings]);
|
||||
const ragModeLocked = isThreadRagModeLocked(threadId);
|
||||
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
|
||||
const handleRagModeToggle = useCallback(() => {
|
||||
if (ragModeLocked) {
|
||||
return;
|
||||
}
|
||||
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
|
||||
}, [ragModeEnabled, ragModeLocked, setLocalSettings]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
@@ -324,12 +325,12 @@ export function InputBox({
|
||||
}
|
||||
setFollowups([]);
|
||||
setFollowupsHidden(false);
|
||||
setFollowupsLoading(false);
|
||||
if (ragModeEnabled) {
|
||||
lockThreadRagMode(threadId);
|
||||
}
|
||||
|
||||
// Guard against submitting before the initial model auto-selection
|
||||
setFollowupsLoading(false);
|
||||
if (ragModeEnabled) {
|
||||
lockThreadRagMode(threadId);
|
||||
}
|
||||
|
||||
// Guard against submitting before the initial model auto-selection
|
||||
// effect has flushed thread settings to storage/state.
|
||||
if (resolvedModelName && context.model_name !== resolvedModelName) {
|
||||
onContextChange?.({
|
||||
@@ -351,13 +352,13 @@ export function InputBox({
|
||||
onContextChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
resolvedModelName,
|
||||
ragModeEnabled,
|
||||
selectedModel?.supports_thinking,
|
||||
status,
|
||||
threadId,
|
||||
],
|
||||
);
|
||||
resolvedModelName,
|
||||
ragModeEnabled,
|
||||
selectedModel?.supports_thinking,
|
||||
status,
|
||||
threadId,
|
||||
],
|
||||
);
|
||||
|
||||
const requestFormSubmit = useCallback(() => {
|
||||
const form = promptRootRef.current?.querySelector("form");
|
||||
@@ -559,6 +560,21 @@ export function InputBox({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{isNewThread && (
|
||||
<CustomPromptBar
|
||||
className="px-1"
|
||||
onInsert={(content) => {
|
||||
const current = textInput.value?.trim() ?? "";
|
||||
textInput.setInput(current ? `${current}\n${content}` : content);
|
||||
setTimeout(() => {
|
||||
document
|
||||
.querySelector<HTMLTextAreaElement>("textarea[name='message']")
|
||||
?.focus();
|
||||
}, 0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className={cn(isNewThread && "gemini-gradient-border gemini-shadow")}>
|
||||
<PromptInput
|
||||
className={cn(
|
||||
@@ -651,13 +667,13 @@ export function InputBox({
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
type="button"
|
||||
aria-pressed={ragModeEnabled}
|
||||
disabled={ragModeLocked || disabled}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
ragModeLocked && "cursor-not-allowed opacity-80",
|
||||
ragModeEnabled
|
||||
type="button"
|
||||
aria-pressed={ragModeEnabled}
|
||||
disabled={ragModeLocked || disabled}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
ragModeLocked && "cursor-not-allowed opacity-80",
|
||||
ragModeEnabled
|
||||
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
)}
|
||||
@@ -1045,6 +1061,7 @@ export function InputBox({
|
||||
)}
|
||||
</PromptInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { FileIcon, Loader2Icon } from "lucide-react";
|
||||
import { ClockIcon, FileIcon, Loader2Icon } from "lucide-react";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { Task, TaskTrigger } from "@/components/ai-elements/task";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
||||
import { useChatAskTimes } from "@/core/chat-timestamps";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
@@ -186,6 +187,7 @@ function MessageContent_({
|
||||
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
||||
[baseRehypePlugins, extraRehypePlugins],
|
||||
);
|
||||
const askTimes = useChatAskTimes();
|
||||
const isHuman = message.type === "human";
|
||||
const resolveArtifact = useArtifactResolver(threadId);
|
||||
const components = useMemo(
|
||||
@@ -217,6 +219,11 @@ function MessageContent_({
|
||||
[threadId, resolveArtifact],
|
||||
);
|
||||
|
||||
const messageTime = formatAskedTime(
|
||||
message.additional_kwargs?.client_ts ??
|
||||
(message.id ? askTimes[message.id] : undefined),
|
||||
);
|
||||
|
||||
const rawContent = extractContentFromMessage(message);
|
||||
const reasoningContent = extractReasoningContentFromMessage(message);
|
||||
|
||||
@@ -296,6 +303,7 @@ function MessageContent_({
|
||||
{messageResponse}
|
||||
</AIElementMessageContent>
|
||||
)}
|
||||
<MessageTimeStamp time={messageTime} align="right" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -308,6 +316,7 @@ function MessageContent_({
|
||||
content={contentToDisplay}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
renderSkillDisplayBlocks={false}
|
||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
|
||||
components={components}
|
||||
/>
|
||||
@@ -317,10 +326,57 @@ function MessageContent_({
|
||||
isLoading={isLoading}
|
||||
message={message}
|
||||
/>
|
||||
{!isLoading && contentToDisplay ? (
|
||||
<MessageTimeStamp time={messageTime} align="left" />
|
||||
) : null}
|
||||
</AIElementMessageContent>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a wall-clock message time as ``YYYY-MM-DD HH:mm`` for display, plus a
|
||||
* full localized string for the hover title. Returns ``null`` when no/invalid
|
||||
* timestamp is present.
|
||||
*/
|
||||
function formatAskedTime(
|
||||
clientTs: unknown,
|
||||
): { label: string; full: string } | null {
|
||||
if (typeof clientTs !== "string" || !clientTs) return null;
|
||||
const date = new Date(clientTs);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const label =
|
||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
||||
` ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
return { label, full: date.toLocaleString() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Small date+time label shown under a message. ``align`` controls the corner:
|
||||
* ``right`` for the user's own question, ``left`` for the model's answer.
|
||||
*/
|
||||
function MessageTimeStamp({
|
||||
time,
|
||||
align = "right",
|
||||
}: {
|
||||
time: { label: string; full: string } | null;
|
||||
align?: "left" | "right";
|
||||
}) {
|
||||
if (!time) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-muted-foreground/60 flex items-center gap-1 text-[11px] leading-none tabular-nums",
|
||||
align === "right" ? "justify-end pr-0.5" : "justify-start pl-0.5",
|
||||
)}
|
||||
title={time.full}
|
||||
>
|
||||
<ClockIcon className="size-3 shrink-0" />
|
||||
<span>{time.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension and check helpers
|
||||
*/
|
||||
@@ -493,24 +549,34 @@ function AssistantDisplayContent({
|
||||
content,
|
||||
displaySources,
|
||||
isLoading,
|
||||
renderSkillDisplayBlocks,
|
||||
rehypePlugins,
|
||||
components,
|
||||
}: {
|
||||
content: string;
|
||||
displaySources: RuntimeDisplaySource[];
|
||||
isLoading: boolean;
|
||||
renderSkillDisplayBlocks: boolean;
|
||||
rehypePlugins: MarkdownContentProps["rehypePlugins"];
|
||||
components: MarkdownContentProps["components"];
|
||||
}) {
|
||||
const { parts, placedKeys } = useMemo(
|
||||
() => splitSkillDisplayPlaceholders(content, displaySources),
|
||||
[content, displaySources],
|
||||
() =>
|
||||
splitSkillDisplayPlaceholders(
|
||||
content,
|
||||
displaySources,
|
||||
renderSkillDisplayBlocks,
|
||||
),
|
||||
[content, displaySources, renderSkillDisplayBlocks],
|
||||
);
|
||||
const fallbackBlocks = useMemo(() => {
|
||||
if (!renderSkillDisplayBlocks) {
|
||||
return [];
|
||||
}
|
||||
return groupDisplaySources(displaySources).filter(
|
||||
(group) => group.mode !== "citation" && !placedKeys.has(group.key),
|
||||
);
|
||||
}, [displaySources, placedKeys]);
|
||||
}, [displaySources, placedKeys, renderSkillDisplayBlocks]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -555,6 +621,7 @@ type DisplayPart =
|
||||
function splitSkillDisplayPlaceholders(
|
||||
content: string,
|
||||
displaySources: RuntimeDisplaySource[],
|
||||
renderSkillDisplayBlocks: boolean,
|
||||
): { parts: DisplayPart[]; placedKeys: Set<string> } {
|
||||
const pattern = /<skill-display\s+([^>]*?)\/>/g;
|
||||
const parts: DisplayPart[] = [];
|
||||
@@ -569,13 +636,15 @@ function splitSkillDisplayPlaceholders(
|
||||
const attrs = parseDisplayAttrs(match[1] ?? "");
|
||||
const group = findDisplayGroup(displaySources, attrs.tool, attrs.mode);
|
||||
if (group) {
|
||||
placedKeys.add(group.key);
|
||||
}
|
||||
if (group && renderSkillDisplayBlocks) {
|
||||
parts.push({
|
||||
type: "display",
|
||||
key: group.key,
|
||||
mode: group.mode,
|
||||
sources: group.sources,
|
||||
});
|
||||
placedKeys.add(group.key);
|
||||
}
|
||||
cursor = start + match[0].length;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
@@ -9,6 +9,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 { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
@@ -77,6 +78,23 @@ export function MessageList({
|
||||
);
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const messages = thread.messages;
|
||||
|
||||
// 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.
|
||||
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.id || message.id.startsWith("opt-")) continue;
|
||||
if (typeof message.additional_kwargs?.client_ts === "string") continue;
|
||||
entries[message.id] = now;
|
||||
}
|
||||
if (Object.keys(entries).length > 0) rememberAskTimes(entries);
|
||||
}, [messages]);
|
||||
|
||||
const [settings] = useLocalSettings();
|
||||
const ragModeEnabled =
|
||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
|
||||
@@ -35,6 +35,7 @@ const fieldLabels: Array<[keyof SkillDisplayConfig, string]> = [
|
||||
["summaryField", "摘要字段"],
|
||||
["contentField", "正文内容字段"],
|
||||
["sourceField", "来源字段"],
|
||||
["authorField", "作者字段"],
|
||||
["typeField", "类型字段"],
|
||||
["timeField", "时间字段"],
|
||||
["scoreField", "相关度字段"],
|
||||
|
||||
@@ -99,7 +99,7 @@ function SourceDetailDialog({
|
||||
}) {
|
||||
const content = source?.content || source?.snippet;
|
||||
const meta = source
|
||||
? [source.source, source.type, source.time].filter(Boolean)
|
||||
? [source.source, source.author ? `作者 ${source.author}` : null, source.type, source.time].filter(Boolean)
|
||||
: [];
|
||||
|
||||
return (
|
||||
|
||||
@@ -66,12 +66,13 @@ export function SourceCard({
|
||||
: null;
|
||||
const meta = [
|
||||
source.source,
|
||||
source.author ? `作者 ${source.author}` : null,
|
||||
source.type && source.type !== "result" ? source.type : null,
|
||||
source.time,
|
||||
score ? `相关度 ${score}` : null,
|
||||
].filter(Boolean);
|
||||
const isCitation = source.mode === "citation";
|
||||
const showReferenceNumber = referenceTarget || isCitation;
|
||||
const showReferenceNumber = isCitation;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
Reference in new issue
Block a user