From 6710527e8f7150a6c4997d566a380454e33f455e Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sat, 5 Sep 2026 12:34:36 +0800 Subject: [PATCH 01/12] feat: make project scaffold project-wide --- .../20260905-project-plugin-scope-7c4e9a21.md | 116 ++++++++++++++++++ electron/coding-plugins/effective-resolver.ts | 17 ++- shared/coding-plugins.ts | 9 ++ src/pages/Plugins/PluginDetails.tsx | 9 +- src/pages/Plugins/index.tsx | 15 ++- src/pages/Plugins/plugin-workspace-model.ts | 11 +- .../coding-plugin-effective-resolver.test.ts | 77 ++++++++++++ tests/unit/plugin-workspace-model.test.ts | 4 +- tests/unit/plugins-page.test.tsx | 6 +- 9 files changed, 252 insertions(+), 12 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260905-project-plugin-scope-7c4e9a21.md diff --git a/.project-docs/30-worklog/tasks/20260905-project-plugin-scope-7c4e9a21.md b/.project-docs/30-worklog/tasks/20260905-project-plugin-scope-7c4e9a21.md new file mode 100644 index 0000000..30e2395 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260905-project-plugin-scope-7c4e9a21.md @@ -0,0 +1,116 @@ +# Task: Make project-scoped Plugins active without partner assignment + +## Identity + +- Task ID: 20260905-project-plugin-scope-7c4e9a21 +- Mode: Feature +- Branch: codex/20260905-project-plugin-scope-7c4e9a21-project-plugin-scope +- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260905-project-plugin-scope-7c4e9a21 +- Base commit: d642d7607c26dee01ef65b4e70dd756465dea16a +- Owner: codex-root +- Status: Ready for Integration + +## Scope + +- Reproduce whether the code-owned bundled `makelore.project-scaffold` Skill is + currently excluded from a parent Agent when the Plugin is acquired and enabled for + the project but not explicitly assigned to that Agent. +- Change only Project Scaffold activation semantics so project enablement is sufficient + for parent-Agent materialization; preserve Account Library acquisition, child-worker + emptiness, frozen worker generations, and every other Plugin's assignment behavior. +- Remove Project Scaffold's partner-assignment action/status from the unified Plugins + workspace and add focused runtime plus Renderer regressions. +- Record the accepted user decision as an Integration promotion candidate; this Feature + task does not edit canonical architecture, domain, or ADR files. + +## Intent And Constraints + +- Concurrent Task Gate: Passed. The exact owner/worktree/branch/base identity was + verified through `task_context.py status --json`; related Plugin download/status + tasks are Ready for Integration and have no active writer overlapping this scope. +- Planning Gate: Passed after loading the project entry docs, current state, decision + index, ADR-008, architecture/data flow/module map, business rules, success criteria, + glossary, evidence, reflection, commitments, stale items, and relevant peer records. +- Project Context Loaded: + - Positioning: MakeLore Code is a Main-owned Pi runtime whose Plugin state is projected + through existing Marketplace/project/Agent authorities rather than Renderer-owned + lifecycle state. + - Current focus: official bundled Project Scaffold delivery and unified `/plugins` + presentation are already integrated; the remaining question is activation scope. + - Applicable decision: ADR-008 previously required acquisition, project enablement, + and Agent assignment. The user's explicit 2026-09-05 decision supersedes only the + assignment requirement for Project Scaffold. + - Architecture boundaries: effective resolution remains Main-owned; Renderer only + projects actions. Parent workers may receive the Skill, child workers remain empty, + and active workers keep frozen resources until replacement/settlement. + - Current state: `makelore.project-scaffold` ships in the signed client and has no + device download path; Account acquisition and project enablement remain distinct. + - Known risk: hiding the UI assignment action without changing the effective resolver + would leave the Skill unusable; broadening all Plugin Skills would silently alter + unrelated assignment semantics. + - Relevant commitment: final packaged Project Scaffold activation still requires a + rebuilt client and installed-client smoke; workspace tests do not prove that step. + - Relevant peers: the bundled-status integration and device-action-gap tasks only + correct delivery presentation/package evidence; neither changes activation scope. +- TDD boundary: first assert the public effective-resolver and Plugins-page behavior at + acquired + project-enabled + unassigned state, then implement the narrowest shared + activation-scope rule that makes those assertions pass. +- Do not modify the occupied client root, Server, Marketplace contracts, Package Store, + billing, hosted execution, local Device Packages, or unrelated Plugin assignments. + +## Outcome + +- Confirmed the defect at the Main-owned effective-resolver seam: an acquired, + project-enabled Project Scaffold Plugin with no Agent assignment was rejected as + `skill_unassigned`, so removing only the Renderer action would not have activated it. +- Added one narrow code-owned project-wide activation invariant for + `makelore.project-scaffold`. Its full Skill set now materializes for every parent Agent + after Account acquisition and project enablement; disabled projects remain blocked and + child Agents remain empty. +- Updated the unified Plugins workspace so Project Scaffold no longer offers partner + assignment. Cards and details now describe its scope as `随项目启用` / `生效范围`. +- Preserved existing assignment behavior for Game Resource, Data Service, Marketplace, + and local Plugins. No manifest schema, server contract, Package Store, billing, or + worker-lifecycle authority was added. + +## Verification + +- TDD RED: the focused resolver/model/page slice produced 3 expected failures and 42 + passes: Project Scaffold returned `skill_unassigned`, the assignment command remained, + and the project-wide scope copy was absent. +- Focused GREEN: 3 files / 45 tests passed. +- Adjacent Plugin/runtime regression: 12 files / 87 tests passed, including effective + resolution, composition, lifecycle, manifest/routes, Pi resource/worker opening, + Project Scaffold, and unified Plugins projection/controller/query/page behavior. +- `corepack pnpm run typecheck`: passed. +- Scoped ESLint: passed. Full `corepack pnpm run lint:check`: 0 errors and 5 unchanged + warnings in untouched Home/Makelore files. +- Full regular unit suite: 225 files passed and 1 unrelated real-process timing test + failed (`pi-agent-server-process-real`, 2630 ms against a 2000 ms threshold); 1888 tests + passed and 2 skipped. The exact failed file then passed in isolation, 6/6. +- Isolated pressure suite: 1/1 passed. +- `corepack pnpm run build:vite`: Renderer, Main, Preload, and utility builds passed with + existing Browserslist/import/chunk warnings. +- `git diff --check`: passed. + +## Follow-ups + +- The Integration task must promote the Project Scaffold activation rule into ADR-008, + module map, data flow, and business rules, then rebuild/install the client and smoke-test + an acquired + project-enabled + unassigned parent Agent. Workspace tests do not prove + the installed-client generation was replaced. + +## Promotion Candidates + +- Update ADR-008, module map, data flow, and business rules during Integration so + `makelore.project-scaffold` is documented as account-acquired + project-enabled and + automatically available to parent Agents, with no partner-assignment state. + - Evidence: focused resolver RED reproduced `skill_unassigned`; the corrected focused + and adjacent suites pass while Game Resource retains `open_agent_assignment`. + - Future impact: Plugin activation-scope changes must update the code-owned scope + predicate and both Main/Renderer regressions; Project Scaffold assignments already + stored in project data become inert but may remain preserved. + - Semantic conflict: supersedes only ADR-008's assignment requirement for Project + Scaffold, not the general assignment model for other Plugins. + - Human confirmation required: No; the user explicitly chose project-level activation + on 2026-09-05. diff --git a/electron/coding-plugins/effective-resolver.ts b/electron/coding-plugins/effective-resolver.ts index 24c96ef..51d3424 100644 --- a/electron/coding-plugins/effective-resolver.ts +++ b/electron/coding-plugins/effective-resolver.ts @@ -3,6 +3,7 @@ import type { CodingPluginDefinition, CodingPluginToolDefinition, } from '../../shared/coding-plugins'; +import { isProjectWideCodingPluginId } from '../../shared/coding-plugins'; import { CORE_CODING_SKILL_IDS, type CodingSkillId, @@ -240,6 +241,15 @@ function marketplaceSkillConflicts(records: readonly DefinitionRecord[]): Readon return blocked; } +function selectedSkillsForDefinition( + definition: CodingPluginDefinition, + assignedSkillIds: readonly string[], +): CodingPluginDefinition['skills'] { + return isProjectWideCodingPluginId(definition.id) + ? definition.skills + : definition.skills.filter(({ id }) => assignedSkillIds.includes(id)); +} + /** * Resolve a worker snapshot from separated Marketplace, Package Store, * project, assignment, and policy state. This module deliberately performs @@ -303,7 +313,7 @@ export class EffectivePluginResolver { const enabled = new Set(await this.enabledPluginIds(input.projectPath)); const requiresLibrary = userDefinitions.some(({ definition, installed }) => ( installed && enabled.has(definition.id) - && definition.skills.some(({ id }) => assigned.includes(id)) + && selectedSkillsForDefinition(definition, assigned).length > 0 )); const library = input.library !== undefined ? input.library @@ -314,7 +324,8 @@ export class EffectivePluginResolver { const serverDefinitions = definitions.filter(({ definition }) => definition.requiresBackend); const needsPolicyRefresh = policyState.status !== 'current' && serverDefinitions.some(({ definition, installed }) => ( - installed && enabled.has(definition.id) && definition.skills.some(({ id }) => assigned.includes(id)) + installed && enabled.has(definition.id) + && selectedSkillsForDefinition(definition, assigned).length > 0 )); if (needsPolicyRefresh && this.options.policyClient) { await this.options.policyClient.refresh(); @@ -330,7 +341,7 @@ export class EffectivePluginResolver { )); continue; } - const selectedSkills = definition.skills.filter(({ id }) => assigned.includes(id)); + const selectedSkills = selectedSkillsForDefinition(definition, assigned); if (selectedSkills.length === 0) { unavailableReasons.push(unavailable(definition.id, 'skill_unassigned', 'Plugin Skill is not assigned')); continue; diff --git a/shared/coding-plugins.ts b/shared/coding-plugins.ts index 6df0a59..7b0b7e4 100644 --- a/shared/coding-plugins.ts +++ b/shared/coding-plugins.ts @@ -106,6 +106,15 @@ export const GAME_RESOURCE_BUNDLED_RELEASE_ID = '00000000-0000-4000-8000-0000000 export const PROJECT_SCAFFOLD_PLUGIN_ID = 'makelore.project-scaffold' as const; export const PROJECT_SCAFFOLD_BUNDLED_RELEASE_ID = '00000000-0000-4000-8000-000000000303' as const; +/** + * Project-wide Plugins materialize all of their Skills for every parent Agent + * once the Plugin is enabled for that project. They never require a separate + * Agent Skill assignment. + */ +export function isProjectWideCodingPluginId(pluginId: string): boolean { + return pluginId === PROJECT_SCAFFOLD_PLUGIN_ID; +} + export const CODE_OWNED_OPTIONAL_BUNDLED_RELEASES = Object.freeze({ [GAME_RESOURCE_PLUGIN_ID]: Object.freeze({ releaseId: GAME_RESOURCE_BUNDLED_RELEASE_ID, diff --git a/src/pages/Plugins/PluginDetails.tsx b/src/pages/Plugins/PluginDetails.tsx index 477d2a8..054e7f2 100644 --- a/src/pages/Plugins/PluginDetails.tsx +++ b/src/pages/Plugins/PluginDetails.tsx @@ -14,6 +14,7 @@ import { import type { DataServiceInstanceState } from '../../../shared/data-service'; import { isBundledOfficialPlugin, + isProjectWideOfficialPlugin, type PluginWorkspaceCommand, type PluginWorkspaceItem, } from './plugin-workspace-model'; @@ -402,8 +403,12 @@ export function PluginDetails(props: PluginDetailsProps) {
-

伙伴分配

- {props.item.source === 'local' +

+ {isProjectWideOfficialPlugin(props.item) ? '生效范围' : '伙伴分配'} +

+ {isProjectWideOfficialPlugin(props.item) + ?

启用当前项目后,插件会自动提供给该项目的主伙伴,无需单独分配;子伙伴不会继承。

+ : props.item.source === 'local' ?

本机包全局生效,不作为项目 Agent Skill 分配项;符合条件的新建或空闲主伙伴及其 worker 会按既有生命周期加载。

: props.item.assignedAgentNames.length ?
{props.item.assignedAgentNames.map((name) => {name})}
diff --git a/src/pages/Plugins/index.tsx b/src/pages/Plugins/index.tsx index ec742fc..1b7eccc 100644 --- a/src/pages/Plugins/index.tsx +++ b/src/pages/Plugins/index.tsx @@ -21,7 +21,11 @@ import type { PluginWorkspaceProjection, PluginWorkspaceState, } from './plugin-workspace-model'; -import { buildPluginWorkspaceProjection, isBundledOfficialPlugin } from './plugin-workspace-model'; +import { + buildPluginWorkspaceProjection, + isBundledOfficialPlugin, + isProjectWideOfficialPlugin, +} from './plugin-workspace-model'; import { resolvePluginWorkspaceSearch, serializePluginWorkspaceSearch } from './plugin-workspace-query'; const DELIVERY_TEXT = { @@ -82,10 +86,15 @@ function cardSource(item: PluginWorkspaceItem): string { function agentText(item: PluginWorkspaceItem): string { if (item.source === 'local') return '本机全局生效'; + if (isProjectWideOfficialPlugin(item)) return '随项目启用'; const count = item.assignedAgentIds.length; return count ? `已分配 ${count} 位伙伴` : '尚未分配伙伴'; } +function agentLabel(item: PluginWorkspaceItem): string { + return isProjectWideOfficialPlugin(item) ? '生效范围' : '伙伴'; +} + function PluginCard({ item, grouped = false, @@ -112,7 +121,7 @@ function PluginCard({
当前项目
{PROJECT_TEXT[item.projectState]}
本机状态
{item.source === 'local' ? (item.localEnabled ? '本机全局已启用' : '本机全局已停用') : item.official?.installation?.version ? `官方包 ${item.official.installation.version}` : isBundledOfficialPlugin(item) ? '随应用提供' : '未下载官方包'}
-
伙伴
{agentText(item)}
+
{agentLabel(item)}
{agentText(item)}
计费
{BILLING_TEXT[item.billing]}
{item.deviceReason ? ( @@ -182,7 +191,7 @@ export function PluginsView(props: PluginsViewProps) {

MakeLore Code

插件

- MakeLore 运营发布官方插件;本机 Skill 和 Pi extension 仅通过对话安装。获取、项目启用与伙伴分配彼此独立,本页集中展示账号、设备、当前项目与伙伴状态。 + MakeLore 运营发布官方插件;本机 Skill 和 Pi extension 仅通过对话安装。获取与项目启用彼此独立;需要定向生效的插件还可分配伙伴。

+ + +
+ + + + setAddress(event.target.value)} + aria-label="网页地址" + className="h-7 min-w-0 flex-1 rounded-lg border border-border/90 bg-surface-input px-2.5 text-[11px] font-medium outline-none focus:border-brand/40 focus:ring-2 focus:ring-brand/15" + placeholder="输入 URL" + spellCheck={false} + /> +
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+ {!browserActive ? ( +
+ + +

开始浏览

+

输入 URL,或等待智能体打开调试页面

+
+ ) : null} +
+ +
+
+
+ {(['console', 'network'] as const).map((tab) => ( + + ))} +
+ +
+ + {diagnosticsOpen ? ( +
+ {activeTab === 'console' ? ( +
+ {diagnostics.console.length === 0 ? ( +

暂无 Console 记录

+ ) : diagnostics.console.map((entry) => ( +
+ {formatTime(entry.timestamp)} + {entry.level} + {entry.text || '(empty)'} +
+ ))} +
+ ) : ( +
+ {diagnostics.network.length === 0 ? ( +

暂无 Network 请求

+ ) : ( + + + + + + + + + + + {diagnostics.network.map((entry) => ( + + + + + + + ))} + +
方法状态地址耗时
{entry.method} + {entry.errorText ? '失败' : entry.status ?? '…'} + {entry.url} + {entry.durationMs === undefined ? '…' : `${Math.round(entry.durationMs)}ms`} +
+ )} +
+ )} +
+ ) : null} +
+ + ); +} diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index 35bdd7b..52b1a01 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -35,6 +35,7 @@ import type { CodingConversationMetadata, } from '@/types/coding-project'; import { CodingComposer } from './CodingComposer'; +import { AgentBrowserPanel } from './AgentBrowserPanel'; import { CodingChangesSummary } from './CodingChangesSummary'; import { CodingConversationSidebar } from './CodingConversationSidebar'; import { CodingConversationHeader } from './CodingConversationHeader'; @@ -147,6 +148,7 @@ export function CodingChatPanel({ const [attachmentsByDraftKey, setAttachmentsByDraftKey] = useState< Record >({}); + const [agentBrowserOpen, setAgentBrowserOpen] = useState(false); const appliedNavigationDraftRef = useRef(null); const automaticCreationKeyRef = useRef(null); const selectedConversationContextRef = useRef(null); @@ -241,6 +243,10 @@ export function CodingChatPanel({ void loadWorkspace().catch(() => undefined); }, [loadWorkspace]); + useEffect(() => { + setAgentBrowserOpen(false); + }, [activeProject?.id]); + useEffect(() => () => disconnectEvents(), [disconnectEvents]); useEffect(() => subscribeHostEvent('lifecycle:sleep', () => { @@ -642,6 +648,9 @@ export function CodingChatPanel({ onRecover={async () => { if (targetConversationId) await recoverConversation(targetConversationId); }} + browserOpen={agentBrowserOpen} + browserAvailable={Boolean(activeProject)} + onToggleBrowser={() => setAgentBrowserOpen((current) => !current)} /> {(workspaceError || conversationMetadataError || connectionError) && ( @@ -761,6 +770,12 @@ export function CodingChatPanel({ }} /> + +
); } diff --git a/src/pages/Chat/CodingConversationHeader.tsx b/src/pages/Chat/CodingConversationHeader.tsx index 522d399..17af72d 100644 --- a/src/pages/Chat/CodingConversationHeader.tsx +++ b/src/pages/Chat/CodingConversationHeader.tsx @@ -3,6 +3,8 @@ import { createPortal } from 'react-dom'; import { LoaderCircle, Pencil, + PanelRightClose, + PanelRightOpen, RotateCcw, Square, } from 'lucide-react'; @@ -55,12 +57,18 @@ export function CodingConversationHeader({ onRename, onAbort, onRecover, + browserOpen = false, + browserAvailable = false, + onToggleBrowser, }: { conversation: CodingConversationMetadata | null; snapshot: ConversationSnapshot | null; onRename(title: string): Promise; onAbort(): Promise; onRecover(): Promise; + browserOpen?: boolean; + browserAvailable?: boolean; + onToggleBrowser?(): void; }) { const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); const [busyAction, setBusyAction] = useState(null); @@ -121,6 +129,23 @@ export function CodingConversationHeader({ + {onToggleBrowser ? ( + + ) : null} + {running && runStatus !== 'aborting' && ( diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 6637de7..4e2aa95 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -53,7 +53,10 @@ import { isCanonicalCodingProjectId } from '@/lib/coding-projects'; import type { CodingProjectSummary } from '@/types/coding-project'; import { useProjectConfigStore } from '@/stores/project-config'; import type { ProjectType } from '../../../shared/project-config'; -import type { ProjectIdentityChoice } from '../../../shared/coding-project-contracts'; +import { + isCodingProjectConversationReady, + type ProjectIdentityChoice, +} from '../../../shared/coding-project-contracts'; import { toast } from 'sonner'; type OpenDialogResult = { @@ -438,7 +441,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi return; } await setActiveProject(project.id); - navigate(result.config.initialized + navigate(isCodingProjectConversationReady(result.config) ? '/chat' : projectConfigPath); }; diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index 52b1a01..85919c1 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -41,7 +41,12 @@ import { CodingConversationSidebar } from './CodingConversationSidebar'; import { CodingConversationHeader } from './CodingConversationHeader'; import { CodingConversationTimeline } from './CodingConversationTimeline'; import { CodingInteractionPanel } from './CodingInteractionPanel'; +import { CodingProjectSetupEmptyState } from './CodingProjectSetupEmptyState'; import { createLocalConversationSnapshot } from './coding-chat-snapshot'; +import { + isCodingProjectAgentReady, + isCodingProjectConversationReady, +} from '../../../shared/coding-project-contracts'; export interface CodingChatPanelProps { navigationDraft?: string; @@ -158,8 +163,9 @@ export function CodingChatPanel({ const submissionFlightsRef = useRef(new Set()); const agents = useMemo(() => ( - config?.agents.filter((agent) => agent.enabled && !agent.archivedAt) ?? [] + config?.agents.filter(isCodingProjectAgentReady) ?? [] ), [config]); + const conversationReady = Boolean(config && isCodingProjectConversationReady(config)); const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) ?? null; const selectedConversation = conversations.find((conversation) => ( conversation.id === selectedConversationId @@ -615,6 +621,10 @@ export function CodingChatPanel({ ); } + if (activeProject && config && !conversationReady && workspaceLoadState !== 'loading') { + return ; + } + return (
+
+
+
+

创建项目智能体后开始对话

+

+ 当前项目还没有可用的项目智能体。完成名称、模型和职责设置后,就可以发送消息。 +

+ +
+
+ ); +} diff --git a/src/pages/ProjectConfiguration/index.tsx b/src/pages/ProjectConfiguration/index.tsx index 1e86629..5459d14 100644 --- a/src/pages/ProjectConfiguration/index.tsx +++ b/src/pages/ProjectConfiguration/index.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { ArrowLeft, Blocks, Check, Cpu, FileText, Files, FolderOpen, Plus, Trash2, Upload, X } from 'lucide-react'; +import { ArrowLeft, Blocks, Bot, Check, Cpu, FileText, Files, FolderOpen, Plus, Trash2, Upload, X } from 'lucide-react'; import { toast } from 'sonner'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -20,10 +20,16 @@ import { useCodingWorkspaceStore } from '@/stores/coding-workspace'; import { useProjectConfigStore } from '@/stores/project-config'; import { useProviderStore } from '@/stores/providers'; import type { CodingProjectAgent, CodingProjectConfig } from '@/types/coding-project'; -import type { ProjectIdentityChoice } from '../../../shared/coding-project-contracts'; +import { + hasReadyCodingProjectAgent, + isCodingProjectAgentReady, + isCodingProjectConversationReady, + type ProjectIdentityChoice, +} from '../../../shared/coding-project-contracts'; const EMPTY_FILES: string[] = []; type DrawerMode = 'models' | 'knowledge' | 'skills' | null; +type ExitDestination = '/chat' | '/module-select' | '/models'; type ProjectIdentityKind = ProjectIdentityChoice['kind']; type SkillStructureEntry = { path: string; type: 'file' | 'directory' }; type SkillInfo = { @@ -93,7 +99,8 @@ function SkillDetail({ skill }: { skill: SkillInfo }) { } function AgentCard({ agent, onOpen }: { agent: CodingProjectAgent; onOpen: () => void }) { - return ; + const ready = isCodingProjectAgentReady(agent); + return ; } function ModelCard({ model }: { model: CodingModelOption }) { @@ -111,10 +118,20 @@ function ModelCard({ model }: { model: CodingModelOption }) { ; } -function ModelList({ models }: { models: CodingModelOption[] }) { +function ModelList({ + models, + onOpenModelSettings, +}: { + models: CodingModelOption[]; + onOpenModelSettings: () => void; +}) { if (models.length === 0) { return
- 当前没有可用模型,请先在模型设置中完成配置。智能体的默认模型在智能体配置中设置。 +

当前没有可用模型。先完成模型配置,再从左侧项目入口回到这里创建智能体。

+
; } return
@@ -153,6 +170,7 @@ export function ProjectConfiguration() { const [identityError, setIdentityError] = useState(null); const [independentCopyDialogOpen, setIndependentCopyDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [pendingExitDestination, setPendingExitDestination] = useState(null); const [partnerDialogOpen, setPartnerDialogOpen] = useState(false); const [archiveAgentId, setArchiveAgentId] = useState(null); const knowledgeInputRef = useRef(null); @@ -197,11 +215,47 @@ export function ProjectConfiguration() { const activeAgent = draft?.agents.find((agent) => agent.id === activeAgentId) ?? null; const activeSkill = skills.find((skill) => skill.id === activeSkillId) ?? null; const playSaveConfirmation = () => undefined; - const handleBack = () => navigate(-1); if (!activeProject) return

一念成光,万物可创。

; if (!draft) return
正在读取项目配置…
; + const draftHasReadyAgent = hasReadyCodingProjectAgent(draft); + const storedConversationReady = Boolean(stored && isCodingProjectConversationReady(stored)); + const hasUnsavedChanges = Boolean(stored && JSON.stringify(stored) !== JSON.stringify(draft)); + const backDestination: ExitDestination = storedConversationReady ? '/chat' : '/module-select'; + const backLabel = storedConversationReady ? '返回对话' : '稍后设置'; + const pendingAgent = draft.agents.find((agent) => ( + agent.enabled && !agent.archivedAt && !isCodingProjectAgentReady(agent) + )); + const setupActionLabel = modelOptions.length === 0 + ? '先配置模型' + : pendingAgent + ? '完成智能体配置' + : '创建项目智能体'; + const archiveAgent = draft.agents.find((agent) => agent.id === archiveAgentId); + const archiveRemovesLastReadyAgent = Boolean( + archiveAgent + && isCodingProjectAgentReady(archiveAgent) + && !draft.agents.some((agent) => agent.id !== archiveAgentId && isCodingProjectAgentReady(agent)), + ); + const requestNavigation = (destination: ExitDestination) => { + if (hasUnsavedChanges) { + setPendingExitDestination(destination); + return; + } + navigate(destination); + }; + const handleBack = () => requestNavigation(backDestination); + const openModelSettings = () => requestNavigation('/models'); + const openAgentCreation = () => { + if (modelOptions.length === 0) { + openModelSettings(); + return; + } + setActiveAgentId(pendingAgent?.id ?? null); + setPartnerDialogOpen(true); + }; + const updateAgent = (next: CodingProjectAgent) => setDraft((current) => current ? { ...current, agents: current.agents.map((item) => item.id === next.id ? { ...next, updatedAt: new Date().toISOString() } : item) } : current); const handlePartnerDialogOpenChange = (open: boolean) => { setPartnerDialogOpen(open); @@ -221,7 +275,22 @@ export function ProjectConfiguration() { skillIds: input.skillIds, responsibility: { mission: input.responsibility, owns: [], boundaries: [], collaborators: [], principles: [] }, }; - setDraft((current) => current ? { ...current, agents: [...current.agents, agent] } : current); + const nextDraft = { ...draft, agents: [...draft.agents, agent] }; + if (!draftHasReadyAgent) { + setSaving(true); + try { + const saved = await save(activeProject.id, { ...nextDraft, initialized: true }); + setDraft(structuredClone(saved)); + await reloadWorkspace(); + playSaveConfirmation(); + toast.success('项目智能体已创建'); + navigate('/chat'); + } finally { + setSaving(false); + } + return; + } + setDraft(nextDraft); }; const handleUpdatePartner = async (input: AgentCreationInput) => { if (!activeAgentId) return; @@ -258,12 +327,26 @@ export function ProjectConfiguration() { toast.error('请补全智能体名称、头像、默认模型和职责说明;名称需在当前项目内唯一。'); return; } + const conversationReady = hasReadyCodingProjectAgent(draft); + if (!conversationReady && !storedConversationReady) { + openAgentCreation(); + return; + } setSaving(true); try { - await save(activeProject.id, { ...draft, initialized: true }); + const saved = await save(activeProject.id, { ...draft, initialized: conversationReady }); + setDraft(structuredClone(saved)); await reloadWorkspace(); playSaveConfirmation(); - toast.success(draft.initialized ? '项目配置已更新' : '项目初始化完成'); + if (!conversationReady) { + toast.success('项目已保存,创建智能体后即可开始对话'); + navigate('/module-select'); + } else if (!storedConversationReady) { + toast.success('项目初始化完成'); + navigate('/chat'); + } else { + toast.success('项目配置已更新'); + } } catch (error) { toast.error(error instanceof Error ? error.message : String(error)); } finally { @@ -336,11 +419,36 @@ export function ProjectConfiguration() { return
-

项目配置

+ {!draftHasReadyAgent ? ( +
+
+ +
+
+

{draft.agents.length === 0 ? '项目已创建,还差 1 步' : '项目需要一个可用智能体'}

+ 必需 +
+

+ {modelOptions.length === 0 + ? '先配置一个可用模型,再创建项目智能体即可开始对话。' + : '创建一个项目智能体并完成名称、默认模型和职责设置,即可开始对话。'} +

+
+
+ +
+ ) : null}
{draft.projectId ? (
@@ -456,7 +564,31 @@ export function ProjectConfiguration() { )}
} title="可用模型" subtitle={`${modelOptions.length} 个可用模型`} onClick={() => setDrawerMode('models')} />} title="可用技能" subtitle={`${skills.length} 项可用技能`} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} />} title="知识文件" subtitle={`${knowledge.length} 个知识文件`} onClick={() => setDrawerMode('knowledge')} />
-

项目智能体

智能体配置仅属于当前项目;每个智能体可以拥有多条独立对话。

{draft.agents.filter((agent) => !agent.archivedAt).map((agent) => { setActiveAgentId(agent.id); setPartnerDialogOpen(true); }} />)}{draft.agents.every((agent) => agent.archivedAt) ?
当前项目还没有智能体,点击右上角的 + 创建。
: null}
{draft.agents.some((agent) => agent.archivedAt) ?

已归档

{draft.agents.filter((agent) => agent.archivedAt).map((agent) => )}
: null}
+
+
+
+
+

项目智能体

+ {!draftHasReadyAgent ? 必需 : null} +
+

智能体配置仅属于当前项目;每个智能体可以拥有多条独立对话。

+
+ {draftHasReadyAgent ? ( + + ) : null} +
+
+ {draft.agents.filter((agent) => !agent.archivedAt).map((agent) => { setActiveAgentId(agent.id); setPartnerDialogOpen(true); }} />)} + {draft.agents.every((agent) => agent.archivedAt) ? ( +
+ 创建或恢复一个项目智能体后,才能进入项目对话。 +
+ ) : null} +
+ {draft.agents.some((agent) => agent.archivedAt) ?

已归档

{draft.agents.filter((agent) => agent.archivedAt).map((agent) => )}
: null} +
@@ -469,7 +601,23 @@ export function ProjectConfiguration() { buttonSize="sm" /> ) : null} - + {!draftHasReadyAgent && !storedConversationReady ? ( + + ) : ( + + )}
@@ -484,24 +632,39 @@ export function ProjectConfiguration() { onUpdate={handleUpdatePartner} onArchive={activeAgent && !activeAgent.builtIn ? () => setArchiveAgentId(activeAgent.id) : undefined} title={activeAgent ? `${activeAgent.name || '未命名智能体'} · 编辑智能体` : '创建项目智能体'} - submitLabel={activeAgent ? '保存智能体' : '创建智能体'} + submitLabel={activeAgent ? '保存智能体' : draftHasReadyAgent ? '创建智能体' : '创建并进入对话'} /> { if (!open) { setDrawerMode(null); setActiveSkillId(null); } }}> - {drawerMode === 'models' ? setDrawerMode(null)}> : null} + {drawerMode === 'models' ? setDrawerMode(null)}> : null} {drawerMode === 'knowledge' ? setDrawerMode(null)}> { void handleKnowledge(event.target.files?.[0]); event.currentTarget.value = ''; }} />
{knowledge.length ? knowledge.map((file) =>
{file}
) :
当前还没有知识文件。
}
: null} {drawerMode === 'skills' && activeSkill ? setActiveSkillId(null)} closeLabel="返回技能列表" closeIcon="back"> : null} {drawerMode === 'skills' && !activeSkill ? setDrawerMode(null)}>
{skills.length > 0 ? skills.map((skill) => ) :
当前没有可查看的技能。
}
: null}
agent.id === archiveAgentId)?.name || '未命名智能体'}”?`} - message="归档后将停止提供新的使用入口,但会保留智能体配置和全部对话记录;你可以之后在智能体列表底部恢复。" + title={`归档智能体“${archiveAgent?.name || '未命名智能体'}”?`} + message={archiveRemovesLastReadyAgent + ? '这是当前最后一个可用智能体。归档并保存后,项目将进入待配置状态,无法开始新对话;智能体配置和全部对话记录仍会保留。' + : '归档后将停止提供新的使用入口,但会保留智能体配置和全部对话记录;你可以之后在智能体列表底部恢复。'} confirmLabel="确认归档" cancelLabel="取消" onCancel={() => setArchiveAgentId(null)} onConfirm={handleArchiveAgent} onError={(error) => toast.error(error instanceof Error ? error.message : String(error))} /> + setPendingExitDestination(null)} + onConfirm={() => { + const destination = pendingExitDestination; + setPendingExitDestination(null); + if (destination) navigate(destination); + }} + /> { await expect(page.getByTestId('project-configuration-actions')).toBeVisible(); const actionBarBottomGap = await page.getByTestId('project-configuration-actions').evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom); expect(actionBarBottomGap).toBeLessThan(40); - await expect(page.getByRole('button', { name: '创建智能体' })).toBeVisible(); + const setupCallout = page.getByTestId('project-agent-setup-callout'); + await expect(setupCallout).toContainText('项目已创建,还差 1 步'); + await expect(setupCallout).toContainText('必需'); + await expect(page.getByTestId('project-agent-setup-primary')).toHaveText('先配置模型'); + await expect(page.getByTestId('project-configuration-back-button')).toHaveAccessibleName('稍后设置'); for (const [resourceId, title] of [ ['resource-card-models', '可用模型'], @@ -48,6 +52,7 @@ test.describe('Project configuration skills', () => { if (resourceId === 'resource-card-models') { await expect(drawer.getByRole('combobox')).toHaveCount(0); await expect(drawer.locator('[data-testid="project-model-list"], [data-testid="project-model-empty-state"]')).toHaveCount(1); + await expect(drawer.getByRole('button', { name: '前往模型设置' })).toBeVisible(); } const closeButton = drawer.getByRole('button', { name: '关闭' }); const closeGeometry = await closeButton.evaluate((button) => { @@ -73,6 +78,15 @@ test.describe('Project configuration skills', () => { const now = new Date().toISOString(); await mkdir(path.join(parentPath, '.makelore'), { recursive: true }); + await writeFile(path.join(parentPath, '.makelore', 'project.json'), JSON.stringify({ + ...createCodingProjectConfigV2(now, 'interactive_ai_app'), + initialized: true, + agents: [], + }, null, 2)); + await page.evaluate(() => { window.location.hash = '#/chat'; }); + await expect(page.getByTestId('project-configuration-page')).toBeVisible(); + await expect(page.getByTestId('project-agent-setup-callout')).toBeVisible(); + await writeFile(path.join(parentPath, '.makelore', 'project.json'), JSON.stringify({ ...createCodingProjectConfigV2(now, 'interactive_ai_app'), initialized: true, @@ -96,6 +110,11 @@ test.describe('Project configuration skills', () => { }, null, 2)); await page.reload(); await expect(page.getByTestId('project-configuration-page')).toBeVisible(); + await expect(page.getByTestId('project-configuration-back-button')).toHaveAccessibleName('返回对话'); + await page.getByTestId('project-configuration-back-button').click(); + await expect(page.getByTestId('chat-operation-page')).toBeVisible(); + await page.evaluate(() => { window.location.hash = '#/project-config'; }); + await expect(page.getByTestId('project-configuration-page')).toBeVisible(); await page.getByTestId('resource-card-skills').click(); await expect(page.getByTestId('superpowers-card')).toHaveCount(0); @@ -163,6 +182,7 @@ test.describe('Project configuration skills', () => { await expect(avatarDialog).toHaveCount(0); await expect(maintenanceDialog.getByAltText('当前上传头像')).toBeVisible(); await expect(page.getByText('完成配置')).toHaveCount(0); + await maintenanceDialog.getByRole('button', { name: '取消' }).click(); } finally { await closeElectronApp(app); await rm(parentPath, { recursive: true, force: true }); diff --git a/tests/unit/app-module-provider-gate.test.tsx b/tests/unit/app-module-provider-gate.test.tsx index 24684c1..e1f14ad 100644 --- a/tests/unit/app-module-provider-gate.test.tsx +++ b/tests/unit/app-module-provider-gate.test.tsx @@ -6,6 +6,10 @@ import { useAuthStore } from '@/stores/auth'; import { useProviderStore } from '@/stores/providers'; import { useSettingsStore } from '@/stores/settings'; import { useUserSyncStore } from '@/stores/user-sync'; +import { codingWorkspaceStore } from '@/stores/coding-workspace'; +import { useProjectConfigStore } from '@/stores/project-config'; +import { createCodingProjectConfigV2 } from '@electron/coding-projects/project-config'; +import type { CodingProjectAgent } from '@/types/coding-project'; vi.mock('@/components/layout/MainLayout', () => ({ MainLayout: () => ( @@ -39,11 +43,41 @@ vi.mock('@/pages/Settings', () => ({ Settings: () =>
Global settings
, })); +vi.mock('@/pages/ProjectConfiguration', () => ({ + ProjectConfiguration: () =>
Project configuration
, +})); + +const readyAgent: CodingProjectAgent = { + id: 'agent-1', + avatarId: 'avatar-01', + roleName: '项目智能体', + name: 'Builder', + builtIn: false, + enabled: true, + model: { accountId: 'account-1', modelId: 'model-1', thinkingLevel: 'off' }, + modelResolution: 'resolved', + skillIds: [], + responsibility: { + mission: '完成项目工作', + owns: [], + boundaries: [], + collaborators: [], + principles: [], + }, + prompt: '', + archivedAt: null, + pinned: false, + createdAt: '2026-09-06T00:00:00.000Z', + updatedAt: '2026-09-06T00:00:00.000Z', +}; + describe('App programming provider initialization gate', () => { const initProviders = vi.fn(); beforeEach(() => { vi.clearAllMocks(); + codingWorkspaceStore.setState(codingWorkspaceStore.getInitialState(), true); + useProjectConfigStore.setState(useProjectConfigStore.getInitialState(), true); useSettingsStore.setState({ setupComplete: true, init: vi.fn(), @@ -95,6 +129,56 @@ describe('App programming provider initialization gate', () => { await waitFor(() => expect(initProviders).toHaveBeenCalledTimes(1)); }); + it('redirects a legacy initialized project without a usable Agent to configuration', async () => { + const project = { + id: 'local-project', + path: '/tmp/local-project', + name: 'local-project', + createdAt: '', + updatedAt: '', + lastOpenedAt: '', + }; + const config = { ...createCodingProjectConfigV2(), initialized: true, agents: [] }; + codingWorkspaceStore.setState({ + activeProjectId: project.id, + activeProject: project, + load: vi.fn().mockResolvedValue(undefined), + }); + useProjectConfigStore.setState({ + load: vi.fn().mockResolvedValue({ status: 'valid', config, knowledgeFiles: [] }), + }); + + await renderAt('/chat'); + + expect(await screen.findByText('Project configuration')).toBeInTheDocument(); + expect(screen.queryByText('Programming workspace')).not.toBeInTheDocument(); + }); + + it('opens chat when the project has a fully configured Agent', async () => { + const project = { + id: 'local-project', + path: '/tmp/local-project', + name: 'local-project', + createdAt: '', + updatedAt: '', + lastOpenedAt: '', + }; + const config = { ...createCodingProjectConfigV2(), initialized: true, agents: [readyAgent] }; + codingWorkspaceStore.setState({ + activeProjectId: project.id, + activeProject: project, + load: vi.fn().mockResolvedValue(undefined), + }); + useProjectConfigStore.setState({ + load: vi.fn().mockResolvedValue({ status: 'valid', config, knowledgeFiles: [] }), + }); + + await renderAt('/chat'); + + expect(await screen.findByText('Programming workspace')).toBeInTheDocument(); + expect(screen.queryByText('Project configuration')).not.toBeInTheDocument(); + }); + it('redirects an orphaned token without a user identity away from a direct workspace route', async () => { useAuthStore.setState({ initialized: true, diff --git a/tests/unit/coding-chat-panel.test.tsx b/tests/unit/coding-chat-panel.test.tsx index 28b85cf..24fea85 100644 --- a/tests/unit/coding-chat-panel.test.tsx +++ b/tests/unit/coding-chat-panel.test.tsx @@ -80,8 +80,8 @@ const agent: CodingProjectAgent = { pinned: true, createdAt: '2026-08-23T00:00:00.000Z', updatedAt: '2026-08-23T00:00:00.000Z', - model: null, - modelResolution: 'required', + model: { accountId: 'account-1', modelId: 'model-1', thinkingLevel: 'off' }, + modelResolution: 'resolved', }; const config: CodingProjectConfig = { schemaVersion: 2, @@ -199,6 +199,24 @@ describe('CodingChatPanel first Conversation', () => { vi.resetModules(); }); + it('shows an actionable setup state when the active project has no usable Agent', async () => { + projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); + projectApi.config.mockResolvedValue({ project, config: configForAgents([]) }); + projectApi.conversations.mockResolvedValue([]); + const onOpenProjectSettings = vi.fn(); + const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); + + render(); + + expect(await screen.findByTestId('coding-chat-agent-setup-empty-state')).toBeVisible(); + expect(screen.getByText('创建项目智能体后开始对话')).toBeVisible(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(projectApi.create).not.toHaveBeenCalled(); + expect(conversationApi.snapshot).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '创建或恢复项目智能体' })); + expect(onOpenProjectSettings).toHaveBeenCalledOnce(); + }); + it('makes the first-Conversation textarea editable while runtime metadata is held', async () => { projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); projectApi.config.mockResolvedValue({ project, config }); diff --git a/tests/unit/coding-project-readiness.test.ts b/tests/unit/coding-project-readiness.test.ts new file mode 100644 index 0000000..38122b5 --- /dev/null +++ b/tests/unit/coding-project-readiness.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import type { + CodingProjectAgent, + CodingProjectConfig, +} from '../../shared/coding-project-contracts'; +import { + hasReadyCodingProjectAgent, + isCodingProjectAgentReady, + isCodingProjectConversationReady, +} from '../../shared/coding-project-contracts'; + +const createdAt = '2026-09-06T00:00:00.000Z'; + +function createAgent(overrides: Partial = {}): CodingProjectAgent { + return { + id: 'agent-1', + avatarId: 'spark', + roleName: '编程助手', + name: '小洛', + builtIn: false, + enabled: true, + skillIds: [], + responsibility: { + mission: '帮助完成项目', + owns: [], + boundaries: [], + collaborators: [], + principles: [], + }, + prompt: '', + archivedAt: null, + pinned: false, + model: { + accountId: 'account-1', + modelId: 'model-1', + thinkingLevel: 'off', + }, + modelResolution: 'resolved', + createdAt, + updatedAt: createdAt, + ...overrides, + }; +} + +function createConfig(overrides: Partial = {}): CodingProjectConfig { + return { + schemaVersion: 2, + projectType: 'custom', + initialized: true, + agents: [createAgent()], + knowledgeDirectory: 'knowledge', + createdAt, + updatedAt: createdAt, + ...overrides, + }; +} + +describe('coding project Conversation readiness', () => { + it('accepts an enabled, unarchived, fully configured Agent', () => { + const agent = createAgent(); + const config = createConfig({ agents: [agent] }); + + expect(isCodingProjectAgentReady(agent)).toBe(true); + expect(hasReadyCodingProjectAgent(config)).toBe(true); + expect(isCodingProjectConversationReady(config)).toBe(true); + }); + + it('rejects a project with no Agents', () => { + const config = createConfig({ agents: [] }); + + expect(hasReadyCodingProjectAgent(config)).toBe(false); + expect(isCodingProjectConversationReady(config)).toBe(false); + }); + + it.each([ + ['disabled', { enabled: false }], + ['archived', { archivedAt: createdAt }], + ] satisfies Array<[string, Partial]>)('rejects a %s Agent', (_label, overrides) => { + const agent = createAgent(overrides); + + expect(isCodingProjectAgentReady(agent)).toBe(false); + }); + + it('rejects an Agent whose model still requires resolution', () => { + const agent = createAgent({ model: null, modelResolution: 'required' }); + + expect(isCodingProjectAgentReady(agent)).toBe(false); + }); + + it('rejects an Agent with a resolved but missing model', () => { + const agent = createAgent({ model: null }); + + expect(isCodingProjectAgentReady(agent)).toBe(false); + }); + + it('rejects an Agent with an empty responsibility mission', () => { + const agent = createAgent({ + responsibility: { + ...createAgent().responsibility, + mission: ' ', + }, + }); + + expect(isCodingProjectAgentReady(agent)).toBe(false); + }); + + it('requires the project to be initialized', () => { + const config = createConfig({ initialized: false }); + + expect(hasReadyCodingProjectAgent(config)).toBe(true); + expect(isCodingProjectConversationReady(config)).toBe(false); + }); +}); diff --git a/tests/unit/main-layout-module-gate.test.tsx b/tests/unit/main-layout-module-gate.test.tsx index 827d9c7..62d688f 100644 --- a/tests/unit/main-layout-module-gate.test.tsx +++ b/tests/unit/main-layout-module-gate.test.tsx @@ -6,9 +6,34 @@ import { codingWorkspaceStore } from '@/stores/coding-workspace'; import { useProjectConfigStore } from '@/stores/project-config'; import { useSettingsStore } from '@/stores/settings'; import { createCodingProjectConfigV2 } from '@electron/coding-projects/project-config'; +import type { CodingProjectAgent } from '@/types/coding-project'; const learningIpcMock = vi.hoisted(() => vi.fn()); +const readyAgent: CodingProjectAgent = { + id: 'agent-1', + avatarId: 'avatar-01', + roleName: '项目智能体', + name: 'Builder', + builtIn: false, + enabled: true, + model: { accountId: 'account-1', modelId: 'model-1', thinkingLevel: 'off' }, + modelResolution: 'resolved', + skillIds: [], + responsibility: { + mission: '完成项目工作', + owns: [], + boundaries: [], + collaborators: [], + principles: [], + }, + prompt: '', + archivedAt: null, + pinned: false, + createdAt: '2026-09-06T00:00:00.000Z', + updatedAt: '2026-09-06T00:00:00.000Z', +}; + vi.mock('@/components/layout/Sidebar', () => ({ Sidebar: ({ sidebarCollapsedOverride }: { sidebarCollapsedOverride?: boolean }) => (
-
- 5 小时 - - {formatRemainingPercent(tokenUsageState.usage?.five_hour_remaining_percent)} - -
-
- 1 周 - - {formatRemainingPercent(tokenUsageState.usage?.weekly_remaining_percent)} - -
- {fiveHourRefreshLabel ? ( -
- {fiveHourRefreshLabel} -
- ) : null} - {weeklyRefreshLabel ? ( -
- {weeklyRefreshLabel} -
- ) : null} - {tokenUsageExhausted ? ( -
-

当前额度已用尽,升级订阅后可继续使用。

+ {tokenPointState.status === 'error' ? ( +
+

词元点数暂时无法获取。

) : null} + {(tokenPointState.status === 'idle' || tokenPointState.status === 'loading') && !tokenPointBalance ? ( +
+ {authUser ? '正在加载词元点数…' : '登录后查看词元点数'} +
+ ) : null} + {tokenPointBalance && tokenPointsCoarse ? ( + <> +
+ {tokenPointBalance.family_shared ? '共享额度' : '词元点数'} + {getCoarsePointLabel(tokenPointBalance)} +
+
+ {tokenPointBalance.family_shared + ? '具体词元点数由家庭管理员管理' + : '具体词元点数由家长管理'} +
+ + ) : null} + {tokenPointBalance && !tokenPointsCoarse ? ( + <> +
+ 本周剩余 + + {getWeeklyPointLabel(tokenPointBalance)} + +
+
+ 总可用 + + {getTotalPointLabel(tokenPointBalance)} + +
+ + ) : null} + {tokenPointBalance && !tokenPointsCoarse && nextRefreshLabel ? ( +
+ {nextRefreshLabel} +
+ ) : null} + {tokenPointBalanceExhausted ? ( +
+

+ {tokenPointsCoarse + ? tokenPointBalance?.family_shared + ? '家庭共享额度已用尽,请联系家庭管理员。' + : '词元点数已用尽,请联系家长。' + : '当前词元点数已用尽,升级订阅后可继续使用。'} +

+ {!tokenPointsCoarse && tokenPointBalance?.upgrade_action === 'self_service' ? ( + + ) : null} +
+ ) : null}