代码提交

This commit is contained in:
董宏凯 committed 2026-09-06 20:49:35 +08:00
1 parent bf42a48fcc
commit 75f288b241
17 files changed
+65 -69

No files matched your search

@@ -223,22 +223,19 @@ const LAYOUT_OPTIONS = [
* 界面模式(全量 / 简洁)下拉切换。换肤旁并排,图标 + 下拉: * 界面模式(全量 / 简洁)下拉切换。换肤旁并排,图标 + 下拉:
* - 简洁模式 ChatPage 顶栏(全局顶栏被隐藏)。 * - 简洁模式 ChatPage 顶栏(全局顶栏被隐藏)。
* - 全量模式 Header 右上角。 * - 全量模式 Header 右上角。
* 用户未显式设置过界面模式时(mode === undefined),当前态按调用方所在的实际界面 * 当前态为有效界面模式(用户自选 > 管理员系统默认 > 内置简洁默认)——
* 兜底——`fallback` 指明这一回退值(Header 传 "full",ChatPage 传 "compact")。 * useLayoutMode 总会返回具体值,不再有 undefined 兜底。
*/ */
export function LayoutModeSwitcher({ export function LayoutModeSwitcher({
tone, tone,
fallback = "compact",
navigateTo = "/page/workspace/chats/new", navigateTo = "/page/workspace/chats/new",
}: { }: {
tone: AppearanceControlTone; tone: AppearanceControlTone;
fallback?: "full" | "compact";
navigateTo?: string | null; navigateTo?: string | null;
}) { }) {
const [mode, setLayoutMode] = useLayoutMode(); const [mode, setLayoutMode] = useLayoutMode();
const navigate = useNavigate(); const navigate = useNavigate();
const current = mode ?? fallback; const TriggerIcon = mode === "full" ? PanelLeftIcon : LayoutIcon;
const TriggerIcon = current === "full" ? PanelLeftIcon : LayoutIcon;
if (env.FORCE_COLLAPSED_SIDEBAR || isHideChromeActive()) return null; if (env.FORCE_COLLAPSED_SIDEBAR || isHideChromeActive()) return null;
@@ -262,7 +259,7 @@ export function LayoutModeSwitcher({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{LAYOUT_OPTIONS.map((option) => { {LAYOUT_OPTIONS.map((option) => {
const Icon = option.icon; const Icon = option.icon;
const active = current === option.id; const active = mode === option.id;
return ( return (
<DropdownMenuItem <DropdownMenuItem
key={option.id} key={option.id}
@@ -188,7 +188,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const globalDefault = appearance?.default_theme ?? "light"; const globalDefault = appearance?.default_theme ?? "light";
const globalSkillView = appearance?.default_skill_view ?? "list"; const globalSkillView = appearance?.default_skill_view ?? "list";
const globalAgentView = appearance?.default_agent_view ?? "card"; const globalAgentView = appearance?.default_agent_view ?? "card";
const globalLayoutMode = appearance?.default_layout_mode ?? "full"; const globalLayoutMode = appearance?.default_layout_mode ?? "compact";
const globalDefaultLabel = useMemo(() => { const globalDefaultLabel = useMemo(() => {
const customTheme = (appearance?.custom_themes ?? []).find( const customTheme = (appearance?.custom_themes ?? []).find(
(preset) => preset.id === globalDefault, (preset) => preset.id === globalDefault,
@@ -201,7 +201,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
if (globalDefault === "lightyellow") return "浅黄色"; if (globalDefault === "lightyellow") return "浅黄色";
return "浅蓝"; return "浅蓝";
}, [appearance?.custom_themes, globalDefault]); }, [appearance?.custom_themes, globalDefault]);
// 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则会话级回退)。 // 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则内置默认简洁模式)。
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode(); const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
const cleanupWords = const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? []; citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
@@ -164,19 +164,15 @@ export function useViewMode(
/** /**
* Effective interface layout mode (full chrome vs compact). A user's own choice * Effective interface layout mode (full chrome vs compact). A user's own choice
* (persisted in local settings) wins; otherwise the admin-configured system * (persisted in local settings) wins; otherwise the admin-configured system
* default applies; otherwise `undefined` (callers fall back to the session-level * default applies; otherwise the built-in default is compact (简洁模式).
* `sidebarEnabled` flag set by portal / yUserId login). `setMode` persists the * `setMode` persists the user's override locally.
* user's override locally.
*/ */
export function useLayoutMode(): [ export function useLayoutMode(): [LayoutMode, (mode: LayoutMode) => void] {
LayoutMode | undefined,
(mode: LayoutMode) => void,
] {
const [settings, setSettings] = useLocalSettings(); const [settings, setSettings] = useLocalSettings();
const { data: appearance } = useAppearanceSettings(); const { data: appearance } = useAppearanceSettings();
const userOverride = settings.display.layoutMode; const userOverride = settings.display.layoutMode;
const adminDefault = appearance?.default_layout_mode; const adminDefault = appearance?.default_layout_mode;
const mode = userOverride ?? adminDefault; const mode: LayoutMode = userOverride ?? adminDefault ?? "compact";
const setMode = (next: LayoutMode) => const setMode = (next: LayoutMode) =>
setSettings("display", { layoutMode: next }); setSettings("display", { layoutMode: next });
return [mode, setMode]; return [mode, setMode];
+4 -11
View File
@@ -37,7 +37,6 @@ import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
import { CompactModeGuide } from "@/components/workspace/compact-mode-guide"; import { CompactModeGuide } from "@/components/workspace/compact-mode-guide";
import { Welcome } from "@/components/workspace/welcome"; import { Welcome } from "@/components/workspace/welcome";
import { CompactWelcome } from "@/components/workspace/compact-welcome"; import { CompactWelcome } from "@/components/workspace/compact-welcome";
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
import { import {
buildFullChatNavigationPath, buildFullChatNavigationPath,
isChatsIframeWorkspacePath, isChatsIframeWorkspacePath,
@@ -129,7 +128,7 @@ export default function ChatPage() {
const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } = const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } =
useSidebar(); useSidebar();
const { t } = useI18n(); const { t } = useI18n();
// 有效界面模式:用户自选 > 管理员系统默认 > (下方再回退会话级 sidebarEnabled) // 有效界面模式:用户自选 > 管理员系统默认 > 内置默认「简洁模式」
const [effectiveLayoutMode] = useLayoutMode(); const [effectiveLayoutMode] = useLayoutMode();
const [showFollowups, setShowFollowups] = useState(false); const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false); const [hasStartedConversation, setHasStartedConversation] = useState(false);
@@ -662,16 +661,10 @@ export default function ChatPage() {
const isLandingState = isNewThread && !hasStartedConversation; const isLandingState = isNewThread && !hasStartedConversation;
// 紧凑模式(正常 App,有外层页面侧栏)落地态:用 CM 参考图样式重画; // 紧凑模式(正常 App,有外层页面侧栏)落地态:用 CM 参考图样式重画;
// 判定与 PageRoutes 的 compactWorkspaceSidebar(pageSidebarAllowed) 保持一致 —— // 判定与 PageRoutes 的 compactWorkspaceSidebar(pageSidebarAllowed) 保持一致 ——
// 优先看外观设置的「全量/简洁」,未设置过才回退到会话级 sidebarEnabled。否则用户显式 // 用 useLayoutMode 的有效界面模式(用户自选 > 管理员系统默认 > 内置简洁默认),
// 选「全量」时 PageRoutes 已挂出顶栏+侧栏,这里却还按旧 sidebarEnabled 判定,导致深蓝 // 与 PageRoutes 的 chrome 判定同源。非紧凑(嵌入/standalone)保持原样。
// 主题底图(workspace-chat-bg)不生效。非紧凑(嵌入/standalone)保持原样。
const layoutMode = effectiveLayoutMode;
const chromeByPreference = const chromeByPreference =
fullChatMode || layoutMode === "full" fullChatMode || effectiveLayoutMode === "full" ? true : false;
? true
: layoutMode === "compact"
? false
: readSidebarEnabled();
const compactChrome = const compactChrome =
chromeByPreference && !isChatsIframeWorkspacePath(pathname); chromeByPreference && !isChatsIframeWorkspacePath(pathname);
const compactLanding = isLandingState && compactChrome; const compactLanding = isLandingState && compactChrome;
+3 -11
View File
@@ -5,7 +5,6 @@ import { PageSidebar } from "@/components/page-sidebar";
import { PageSidebarDark } from "@/components/page-sidebar-dark"; import { PageSidebarDark } from "@/components/page-sidebar-dark";
import { CompactToolbox } from "@/components/workspace/compact-toolbox"; import { CompactToolbox } from "@/components/workspace/compact-toolbox";
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route"; import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
import { isWujieEnv } from "@/core/embed/wujie"; import { isWujieEnv } from "@/core/embed/wujie";
import { import {
isEmbedActive, isEmbedActive,
@@ -34,7 +33,6 @@ import { RoundtableJobNotifier } from "@/roundtable-planning/components/Roundtab
function PageLayout() { function PageLayout() {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const forceCollapsedSidebar = env.FORCE_COLLAPSED_SIDEBAR; const forceCollapsedSidebar = env.FORCE_COLLAPSED_SIDEBAR;
const sidebarEnabled = useMemo(() => readSidebarEnabled(), []);
const location = useLocation(); const location = useLocation();
const { theme } = useTheme(); const { theme } = useTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
@@ -45,17 +43,11 @@ function PageLayout() {
const thirdLevel = useActiveThirdLevel(); const thirdLevel = useActiveThirdLevel();
const lightExpanded = lightNarrowSidebar && thirdLevel.length > 0; const lightExpanded = lightNarrowSidebar && thirdLevel.length > 0;
// 界面模式(外观设置):用户显式选「全量/简洁」时以其为准;否则跟随管理员系统默认 // 界面模式(外观设置):用户显式选「全量/简洁」时以其为准;否则跟随管理员系统默认
// (default_layout_mode);两者都没有才回退到会话级 sidebarEnabled(外部门户/yUserId // (default_layout_mode);两者都没有时使用内置默认「简洁模式」(useLayoutMode 兜底,
// 登录时置位)这一旧默认。这样无论是否走门户登录,「全量↔简洁」都能真正控制顶栏+侧边栏。 // 不再回退到会话级 sidebarEnabled 旧默认)。「全量↔简洁」始终能真正控制顶栏+侧边栏。
const [layoutMode] = useLayoutMode(); const [layoutMode] = useLayoutMode();
const chromeByPreference = const chromeByPreference =
forceCollapsedSidebar forceCollapsedSidebar ? false : layoutMode === "compact" ? false : true;
? false
: layoutMode === "full"
? true
: layoutMode === "compact"
? false
: sidebarEnabled;
// iframe 嵌入模式(外部系统 `&embed=1`,按标签页 sessionStorage):去掉全局导航栏 + 侧边栏, // iframe 嵌入模式(外部系统 `&embed=1`,按标签页 sessionStorage):去掉全局导航栏 + 侧边栏,
// 只留问答/会商工作区本体。按 location 重算以跟随页内导航;非嵌入标签页恒为 false,不受影响。 // 只留问答/会商工作区本体。按 location 重算以跟随页内导航;非嵌入标签页恒为 false,不受影响。
const embedActive = useMemo( const embedActive = useMemo(
@@ -170,10 +170,7 @@ const Header = ({ collapsed }: HeaderProps) => {
<CompactShortcutButtons tone={headerTone} /> <CompactShortcutButtons tone={headerTone} />
<ThemeSwitcher tone={headerTone} /> <ThemeSwitcher tone={headerTone} />
<LayoutModeSwitcher <LayoutModeSwitcher tone={headerTone} />
tone={headerTone}
fallback="full"
/>
<Button <Button
type="button" type="button"
@@ -4,23 +4,35 @@ from __future__ import annotations
import re import re
_NODE_RUN_ID_RE = re.compile(r"^(?P<node_id>.+)-attempt(?P<attempt>\d+)$") _NODE_RUN_ID_RE = re.compile(r"^(?P<scope>.+)-attempt(?P<attempt>\d+)$")
def make_node_run_id(node_id: str, attempt: int) -> str: def make_node_run_id(run_id: str, node_id: str, attempt: int) -> str:
"""Return ``${node_id}-attempt{attempt}``. Random UUIDs are forbidden.""" """Return ``${run_id}-${node_id}-attempt{attempt}``. Random UUIDs are forbidden.
The run dimension is part of the id because the node-runs table primary
key is ``node_run_id`` alone: without it, two runs reusing the same plan
template would collide on ``${node_id}-attempt{n}``.
"""
if not run_id or run_id.strip() != run_id:
raise ValueError("run_id must be a non-empty trimmed string")
if not node_id or node_id.strip() != node_id: if not node_id or node_id.strip() != node_id:
raise ValueError("node_id must be a non-empty trimmed string") raise ValueError("node_id must be a non-empty trimmed string")
if attempt < 1: if attempt < 1:
raise ValueError("attempt must be >= 1") raise ValueError("attempt must be >= 1")
return f"{node_id}-attempt{attempt}" return f"{run_id}-{node_id}-attempt{attempt}"
def parse_node_run_id(node_run_id: str) -> tuple[str, int]: def parse_node_run_id(node_run_id: str) -> tuple[str, int]:
"""Split a canonical node_run_id back into ``(node_id, attempt)``.""" """Split a canonical node_run_id tail back into ``(..., attempt)``.
The returned first element is the run-scoped node portion
(``${run_id}-${node_id}``); callers that need the bare node id should
strip the known run prefix themselves.
"""
match = _NODE_RUN_ID_RE.match(node_run_id or "") match = _NODE_RUN_ID_RE.match(node_run_id or "")
if match is None: if match is None:
raise ValueError(f"invalid node_run_id: {node_run_id!r}") raise ValueError(f"invalid node_run_id: {node_run_id!r}")
return match.group("node_id"), int(match.group("attempt")) return match.group("scope"), int(match.group("attempt"))
@@ -365,7 +365,7 @@ class TaskLedger:
) )
attempt = int(node.get("attempt") or 0) + 1 attempt = int(node.get("attempt") or 0) + 1
replacement = { replacement = {
"node_run_id": make_node_run_id(node_id, attempt), "node_run_id": make_node_run_id(run_id, node_id, attempt),
"session_id": run["session_id"], "session_id": run["session_id"],
"run_id": run_id, "run_id": run_id,
"node_id": node_id, "node_id": node_id,
@@ -449,7 +449,7 @@ class TaskLedger:
nxt = assignment.attempt + 1 nxt = assignment.attempt + 1
await self._store.save_node_run( await self._store.save_node_run(
{ {
"node_run_id": make_node_run_id(assignment.node_id, nxt), "node_run_id": make_node_run_id(assignment.run_id, assignment.node_id, nxt),
"session_id": assignment.session_id, "session_id": assignment.session_id,
"run_id": assignment.run_id, "run_id": assignment.run_id,
"node_id": assignment.node_id, "node_id": assignment.node_id,
@@ -518,7 +518,7 @@ class TaskLedger:
nxt = attempt + 1 nxt = attempt + 1
await self._store.save_node_run( await self._store.save_node_run(
{ {
"node_run_id": make_node_run_id(node_id, nxt), "node_run_id": make_node_run_id(str(run["id"]), node_id, nxt),
"session_id": run["session_id"], "session_id": run["session_id"],
"run_id": run["id"], "run_id": run["id"],
"node_id": node_id, "node_id": node_id,
@@ -540,7 +540,7 @@ class TaskLedger:
session_id=str(run["session_id"]), session_id=str(run["session_id"]),
run_id=str(run["id"]), run_id=str(run["id"]),
event_type="node.attempt.created", event_type="node.attempt.created",
data={"node_id": node_id, "node_run_id": make_node_run_id(node_id, nxt), "attempt": nxt, "reason": "targeted_rework"}, data={"node_id": node_id, "node_run_id": make_node_run_id(str(run["id"]), node_id, nxt), "attempt": nxt, "reason": "targeted_rework"},
) )
return True return True
@@ -127,7 +127,7 @@ async def _maybe_open_next_attempt(
if attempt >= _max_attempts(node, spec): if attempt >= _max_attempts(node, spec):
return return
nxt = attempt + 1 nxt = attempt + 1
node_run_id = make_node_run_id(str(node["node_id"]), nxt) node_run_id = make_node_run_id(str(run["id"]), str(node["node_id"]), nxt)
await store.save_node_run( await store.save_node_run(
{ {
"node_run_id": node_run_id, "node_run_id": node_run_id,
@@ -236,8 +236,8 @@ class AppearanceSettings(BaseModel):
description="Default agent list display mode for users who haven't picked their own (card / list)", description="Default agent list display mode for users who haven't picked their own (card / list)",
) )
default_layout_mode: Literal["full", "compact"] = Field( default_layout_mode: Literal["full", "compact"] = Field(
default="full", default="compact",
description="Default interface layout for users who haven't picked their own — full (sidebar + header) / compact (hidden)", description="Default interface layout for users who haven't picked their own — compact (hidden) / full (sidebar + header)",
) )
compact_shortcut_buttons: list[CompactShortcutButton] = Field( compact_shortcut_buttons: list[CompactShortcutButton] = Field(
default_factory=_default_compact_shortcut_buttons, default_factory=_default_compact_shortcut_buttons,
@@ -55,8 +55,14 @@ def loads_dict(value: Any) -> dict[str, Any]:
return {} return {}
def make_node_run_id(node_id: str, attempt: int) -> str: def make_node_run_id(run_id: str, node_id: str, attempt: int) -> str:
return f"{node_id}-attempt{attempt}" """Return ``${run_id}-${node_id}-attempt{attempt}``.
The node-runs table primary key is ``node_run_id`` alone, so the id must
carry the run dimension: two runs sharing a plan template would otherwise
collide on the same deterministic ``${node_id}-attempt{n}`` string.
"""
return f"{run_id}-{node_id}-attempt{attempt}"
def is_terminal_run(status: str) -> bool: def is_terminal_run(status: str) -> bool:
@@ -494,7 +494,7 @@ class MemoryReportCollaborationStore(ReportCollaborationStore):
node_id = str(node.get("id") or "") node_id = str(node.get("id") or "")
if not node_id: if not node_id:
continue continue
node_run_id = make_node_run_id(node_id, 1) node_run_id = make_node_run_id(run_id, node_id, 1)
role_key = str(node.get("role_key") or "") role_key = str(node.get("role_key") or "")
self._node_runs[node_run_id] = { self._node_runs[node_run_id] = {
"node_run_id": node_run_id, "node_run_id": node_run_id,
@@ -884,7 +884,7 @@ class SqlReportCollaborationStore(ReportCollaborationStore):
snapshot = (role_snapshots or {}).get(role_key) snapshot = (role_snapshots or {}).get(role_key)
db.add( db.add(
ReportCollaborationNodeRunRow( ReportCollaborationNodeRunRow(
node_run_id=make_node_run_id(node_id, 1), node_run_id=make_node_run_id(run_id, node_id, 1),
session_id=session_id, session_id=session_id,
run_id=run_id, run_id=run_id,
node_id=node_id, node_id=node_id,
@@ -101,10 +101,12 @@ def test_event_envelope_accepts_snake_or_camel_aliases() -> None:
def test_node_run_id_is_deterministic() -> None: def test_node_run_id_is_deterministic() -> None:
assert make_node_run_id("research_brand", 2) == "research_brand-attempt2" assert make_node_run_id("rcr_x", "research_brand", 2) == "rcr_x-research_brand-attempt2"
assert parse_node_run_id("research_brand-attempt2") == ("research_brand", 2) assert parse_node_run_id("rcr_x-research_brand-attempt2") == ("rcr_x-research_brand", 2)
with pytest.raises(ValueError): with pytest.raises(ValueError):
make_node_run_id("research_brand", 0) make_node_run_id("rcr_x", "research_brand", 0)
with pytest.raises(ValueError):
make_node_run_id("", "research_brand", 1)
with pytest.raises(ValueError): with pytest.raises(ValueError):
parse_node_run_id("random-uuid") parse_node_run_id("random-uuid")
@@ -175,7 +175,7 @@ async def test_recovery_preserves_completed_nodes() -> None:
write["status"] = "running" write["status"] = "running"
await store.save_node_run(write) await store.save_node_run(write)
report = await recover_orphaned_attempts(store, run_id) report = await recover_orphaned_attempts(store, run_id)
assert "research-attempt1" in report.preserved_completed assert f"{run_id}-research-attempt1" in report.preserved_completed
assert write["node_run_id"] in report.interrupted_node_run_ids assert write["node_run_id"] in report.interrupted_node_run_ids
latest = {item["node_id"]: item for item in await store.list_node_runs(run_id)} latest = {item["node_id"]: item for item in await store.list_node_runs(run_id)}
assert latest["research"]["status"] == "completed" assert latest["research"]["status"] == "completed"
@@ -71,11 +71,12 @@ def test_completed_reopen_creates_new_attempt_id() -> None:
assert can_transition_node("reopened", "queued") assert can_transition_node("reopened", "queued")
with pytest.raises(IllegalNodeTransition): with pytest.raises(IllegalNodeTransition):
require_transition("completed", "queued") require_transition("completed", "queued")
first = make_node_run_id("writer", 1) first = make_node_run_id("rcr_x", "writer", 1)
second = make_node_run_id("writer", 2) second = make_node_run_id("rcr_x", "writer", 2)
assert first == "writer-attempt1" assert first == "rcr_x-writer-attempt1"
assert second == "writer-attempt2" assert second == "rcr_x-writer-attempt2"
assert first != second assert first != second
assert make_node_run_id("rcr_y", "writer", 1) != first
def test_planned_is_bootstrap_source() -> None: def test_planned_is_bootstrap_source() -> None:
@@ -152,7 +152,7 @@ async def test_waves_researcher_before_writer_and_text_does_not_complete() -> No
assert advice[0]["data"]["suggested_next_node_ids"] == ["write"] assert advice[0]["data"]["suggested_next_node_ids"] == ["write"]
agents = await store.list_agent_runs(run_id) agents = await store.list_agent_runs(run_id)
phases = {item["node_run_id"]: item["phase_id"] for item in agents} phases = {item["node_run_id"]: item["phase_id"] for item in agents}
assert phases["research-attempt1"] != phases["write-attempt1"] assert phases[f"{run_id}-research-attempt1"] != phases[f"{run_id}-write-attempt1"]
@pytest.mark.asyncio @pytest.mark.asyncio