feat: 标签管理功能 + 问答前缀对用户隐藏

标签管理:
- 新增 tags/tag_assignments 持久化层、迁移、/api/tags 路由
- 标签分智能体/技能/定时任务三类, 仅同类型可互打, 仅 admin 可增删改与打标签
- 三类资源列表支持按名称模糊检索与按标签筛选(走后端查询参数)
- 前端新增标签管理页、TagPickerDialog(点击即时绑定)、彩色标签 chip

问答前缀:
- 前缀不再拼入消息 content, 改存 additional_kwargs 由 PromptPrefixMiddleware
  在调模型前临时注入, 用户界面看不到前缀但模型仍收到
- 修正 thread_existed 闸门导致首轮前缀被跳过的问题

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.7 committed 2026-05-15 16:19:08 +08:00
1 parent aeba15eb16
commit 646cb8caeb
47 files changed
+2180 -109

No files matched your search

+11 -10
View File
@@ -93,7 +93,9 @@ export default function ChatPage() {
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
// Only the opening turn of a brand-new thread may carry the prefix.
const shouldApplyPrefix =
isNewThread &&
!hasStartedConversation &&
promptPrefixOn &&
promptPrefixSettings?.enabled === true;
@@ -101,19 +103,17 @@ export default function ChatPage() {
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
const messageToSend = shouldApplyPrefix
? {
...message,
text: `${promptPrefixSettings?.prompt_prefix ?? ""}\n\n${message.text}`,
}
: message;
// The prefix is intentionally NOT concatenated into the message text:
// doing so would make the user see it. We only flag the request via
// context; the backend tags the message and PromptPrefixMiddleware
// injects the prefix into the LLM request only.
void sendMessage(
threadId,
messageToSend,
message,
shouldApplyPrefix ? { prompt_prefix_enabled: true } : undefined,
);
},
[sendMessage, threadId, hasStartedConversation, promptPrefixOn, promptPrefixSettings],
[sendMessage, threadId, isNewThread, hasStartedConversation, promptPrefixOn, promptPrefixSettings],
);
const handleClarificationSubmit = useCallback(
@@ -129,13 +129,14 @@ export default function ChatPage() {
const promptPrefixProp = useMemo(() => {
if (!promptPrefixSettings?.enabled) return undefined;
if (hasStartedConversation) return undefined;
// The prefix toggle is offered only on the opening turn of a new thread.
if (!isNewThread || hasStartedConversation) return undefined;
return {
label: promptPrefixSettings.switch_label || "前缀",
on: promptPrefixOn,
onToggle: () => setPromptPrefixOn((v) => !v),
};
}, [promptPrefixSettings, hasStartedConversation, promptPrefixOn]);
}, [promptPrefixSettings, isNewThread, hasStartedConversation, promptPrefixOn]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
+45 -1
View File
@@ -15,6 +15,7 @@ import {
Play,
Plus,
RefreshCw,
TagsIcon,
Trash2,
} from "lucide-react";
import { useRouteNavigate as useNavigate } from "@/core/route-base";
@@ -35,7 +36,11 @@ import {
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useAgents } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import {
downloadScheduledTaskRun,
type PublishedScheduledTask,
@@ -283,7 +288,10 @@ const emptyForm = {
export default function ScheduledTasksPage() {
const navigate = useNavigate();
const { data: tasks = [], isLoading } = useScheduledTasks();
const [search, setSearch] = useState("");
const [tagId, setTagId] = useState<string | null>(null);
// Name + tag filtering is resolved server-side via the /api/scheduled-tasks query.
const { data: tasks = [], isLoading } = useScheduledTasks({ search, tagId });
const { data: deliveryProfile } = useScheduledTaskDeliveryProfile();
const { agents } = useAgents();
const actions = useScheduledTaskActions();
@@ -299,6 +307,8 @@ export default function ScheduledTasksPage() {
const [runsTimeFilter, setRunsTimeFilter] = useState<"all" | "today" | "7d" | "30d">("all");
const [subscribeTarget, setSubscribeTarget] = useState<PublishedScheduledTask | null>(null);
const [subscribeNotify, setSubscribeNotify] = useState(false);
const [tagPickerTask, setTagPickerTask] = useState<ScheduledTask | null>(null);
const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
const sortedTasks = useMemo(
() =>
@@ -466,6 +476,14 @@ export default function ScheduledTasksPage() {
<TabsTrigger value="plaza" className="flex-1">广场</TabsTrigger>
</TabsList>
</Tabs>
{activeTab === "mine" && (
<TagSearchBar
targetType="scheduled_task"
onSearchChange={setSearch}
tagId={tagId}
onTagIdChange={setTagId}
/>
)}
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="size-full">
@@ -527,7 +545,23 @@ export default function ScheduledTasksPage() {
</div>
)}
</div>
{(task.tags?.length ?? 0) > 0 && (
<TagChipList tags={task.tags!} className="mt-3" />
)}
<div className="mt-3 flex flex-wrap gap-2">
{isAdmin && (
<Button
size="icon"
variant="outline"
title="编辑标签"
onClick={(event) => {
event.stopPropagation();
setTagPickerTask(task);
}}
>
<TagsIcon className="size-4" />
</Button>
)}
{isOwner ? (
<>
<Button
@@ -934,6 +968,16 @@ export default function ScheduledTasksPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{isAdmin && tagPickerTask && (
<TagPickerDialog
open={!!tagPickerTask}
onOpenChange={(open) => !open && setTagPickerTask(null)}
targetType="scheduled_task"
targetId={tagPickerTask.task_id}
currentTags={tagPickerTask.tags ?? []}
/>
)}
</WorkspaceContainer>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { TagManagementPage } from "@/components/workspace/admin/tag-management-page";
export default function TagsPage() {
return <TagManagementPage />;
}
@@ -25,6 +25,7 @@ import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import SkillEvolutionPage from "./SkillEvolutionPage";
import SkillsPage from "./SkillsPage";
import UsersPage from "./UsersPage";
import TagsPage from "./TagsPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
export function WorkspaceLayout({
@@ -183,6 +184,18 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="admin/tags"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<TagsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/recommended-questions"
element={