代码提交
This commit is contained in:
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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+8
-2
@@ -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:
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user