代码提交

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