openclaw-agent-dashboard 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.github/workflows/release.yml +56 -0
  2. package/README.md +302 -0
  3. package/docs/CHANGELOG_AGENT_MODIFICATIONS.md +132 -0
  4. package/docs/RELEASE-LATEST.md +189 -0
  5. package/docs/RELEASE-MODEL-CONFIG.md +95 -0
  6. package/docs/release-guide.md +259 -0
  7. package/docs/release-operations-manual.md +167 -0
  8. package/docs/specs/tr3-install-system.md +580 -0
  9. package/docs/windows-collaboration-model-paths-troubleshooting.md +0 -0
  10. package/frontend/index.html +12 -0
  11. package/frontend/package-lock.json +1240 -0
  12. package/frontend/package.json +19 -0
  13. package/frontend/src/App.vue +331 -0
  14. package/frontend/src/components/AgentCard.vue +796 -0
  15. package/frontend/src/components/AgentConfigPanel.vue +539 -0
  16. package/frontend/src/components/AgentDetailPanel.vue +738 -0
  17. package/frontend/src/components/ErrorAnalysisView.vue +546 -0
  18. package/frontend/src/components/ErrorCenterPanel.vue +844 -0
  19. package/frontend/src/components/PerformanceMonitor.vue +515 -0
  20. package/frontend/src/components/SettingsPanel.vue +236 -0
  21. package/frontend/src/components/TokenAnalysisPanel.vue +683 -0
  22. package/frontend/src/components/chain/ChainEdge.vue +85 -0
  23. package/frontend/src/components/chain/ChainNode.vue +166 -0
  24. package/frontend/src/components/chain/TaskChainView.vue +425 -0
  25. package/frontend/src/components/chain/index.ts +3 -0
  26. package/frontend/src/components/chain/types.ts +70 -0
  27. package/frontend/src/components/collaboration/CollaborationFlowSection.vue +1032 -0
  28. package/frontend/src/components/collaboration/CollaborationFlowWrapper.vue +113 -0
  29. package/frontend/src/components/performance/PerformancePanel.vue +119 -0
  30. package/frontend/src/components/performance/PerformanceSection.vue +1137 -0
  31. package/frontend/src/components/tasks/TaskStatusSection.vue +973 -0
  32. package/frontend/src/components/timeline/TimelineConnector.vue +31 -0
  33. package/frontend/src/components/timeline/TimelineRound.vue +135 -0
  34. package/frontend/src/components/timeline/TimelineStep.vue +691 -0
  35. package/frontend/src/components/timeline/TimelineToolLink.vue +109 -0
  36. package/frontend/src/components/timeline/TimelineView.vue +540 -0
  37. package/frontend/src/components/timeline/index.ts +5 -0
  38. package/frontend/src/components/timeline/types.ts +120 -0
  39. package/frontend/src/composables/index.ts +7 -0
  40. package/frontend/src/composables/useDebounce.ts +48 -0
  41. package/frontend/src/composables/useRealtime.ts +52 -0
  42. package/frontend/src/composables/useState.ts +52 -0
  43. package/frontend/src/composables/useThrottle.ts +46 -0
  44. package/frontend/src/composables/useVirtualScroll.ts +106 -0
  45. package/frontend/src/main.ts +4 -0
  46. package/frontend/src/managers/EventDispatcher.ts +127 -0
  47. package/frontend/src/managers/RealtimeDataManager.ts +293 -0
  48. package/frontend/src/managers/StateManager.ts +128 -0
  49. package/frontend/src/managers/index.ts +5 -0
  50. package/frontend/src/types/collaboration.ts +135 -0
  51. package/frontend/src/types/index.ts +20 -0
  52. package/frontend/src/types/performance.ts +105 -0
  53. package/frontend/src/types/task.ts +38 -0
  54. package/frontend/vite.config.ts +18 -0
  55. package/package.json +22 -0
  56. package/plugin/README.md +99 -0
  57. package/plugin/config.json.example +1 -0
  58. package/plugin/index.js +250 -0
  59. package/plugin/openclaw.plugin.json +17 -0
  60. package/plugin/package.json +21 -0
  61. package/scripts/build-plugin.js +67 -0
  62. package/scripts/bundle.sh +62 -0
  63. package/scripts/install-plugin.sh +162 -0
  64. package/scripts/install-python-deps.js +346 -0
  65. package/scripts/install-python-deps.sh +226 -0
  66. package/scripts/install.js +512 -0
  67. package/scripts/install.sh +367 -0
  68. package/scripts/lib/common.js +490 -0
  69. package/scripts/lib/common.sh +137 -0
  70. package/scripts/release-pack.sh +110 -0
  71. package/scripts/start.js +50 -0
  72. package/scripts/test_available_models.py +284 -0
  73. package/scripts/test_websocket_ping.py +44 -0
  74. package/src/backend/agents.py +73 -0
  75. package/src/backend/api/__init__.py +1 -0
  76. package/src/backend/api/agent_config_api.py +90 -0
  77. package/src/backend/api/agents.py +73 -0
  78. package/src/backend/api/agents_config.py +75 -0
  79. package/src/backend/api/chains.py +126 -0
  80. package/src/backend/api/collaboration.py +902 -0
  81. package/src/backend/api/debug_paths.py +39 -0
  82. package/src/backend/api/error_analysis.py +146 -0
  83. package/src/backend/api/errors.py +281 -0
  84. package/src/backend/api/performance.py +784 -0
  85. package/src/backend/api/subagents.py +770 -0
  86. package/src/backend/api/timeline.py +144 -0
  87. package/src/backend/api/websocket.py +251 -0
  88. package/src/backend/collaboration.py +405 -0
  89. package/src/backend/data/__init__.py +1 -0
  90. package/src/backend/data/agent_config_manager.py +270 -0
  91. package/src/backend/data/chain_reader.py +299 -0
  92. package/src/backend/data/config_reader.py +153 -0
  93. package/src/backend/data/error_analyzer.py +430 -0
  94. package/src/backend/data/session_reader.py +445 -0
  95. package/src/backend/data/subagent_reader.py +244 -0
  96. package/src/backend/data/task_history.py +118 -0
  97. package/src/backend/data/timeline_reader.py +981 -0
  98. package/src/backend/errors.py +63 -0
  99. package/src/backend/main.py +89 -0
  100. package/src/backend/mechanism_reader.py +131 -0
  101. package/src/backend/mechanisms.py +32 -0
  102. package/src/backend/performance.py +474 -0
  103. package/src/backend/requirements.txt +5 -0
  104. package/src/backend/session_reader.py +238 -0
  105. package/src/backend/status/__init__.py +1 -0
  106. package/src/backend/status/error_detector.py +122 -0
  107. package/src/backend/status/status_calculator.py +301 -0
  108. package/src/backend/status_calculator.py +121 -0
  109. package/src/backend/subagent_reader.py +229 -0
  110. package/src/backend/watchers/__init__.py +4 -0
  111. package/src/backend/watchers/file_watcher.py +159 -0
@@ -0,0 +1,299 @@
1
+ """
2
+ 任务链路读取器 - 解析 Agent 间的任务派发关系
3
+ """
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ from typing import List, Dict, Any, Optional
8
+ from dataclasses import dataclass, asdict
9
+ from enum import Enum
10
+ from datetime import datetime
11
+
12
+
13
+ class ChainNodeStatus(str, Enum):
14
+ PENDING = "pending"
15
+ RUNNING = "running"
16
+ COMPLETED = "completed"
17
+ ERROR = "error"
18
+
19
+
20
+ class ChainStatus(str, Enum):
21
+ RUNNING = "running"
22
+ COMPLETED = "completed"
23
+ ERROR = "error"
24
+
25
+
26
+ from data.config_reader import get_openclaw_root
27
+
28
+
29
+ def _get_agents_config() -> Dict[str, Any]:
30
+ """获取 agents 配置"""
31
+ config_file = get_openclaw_root() / "openclaw.json"
32
+ if not config_file.exists():
33
+ return {}
34
+
35
+ try:
36
+ with open(config_file, 'r', encoding='utf-8') as f:
37
+ return json.load(f)
38
+ except:
39
+ return {}
40
+
41
+
42
+ def _get_agent_info(agent_id: str) -> Dict[str, Any]:
43
+ """获取单个 agent 的信息"""
44
+ config = _get_agents_config()
45
+ agents = config.get('agents', {}).get('list', [])
46
+ for a in agents:
47
+ if a.get('id') == agent_id:
48
+ return a
49
+ return {}
50
+
51
+
52
+ def _parse_session_key(session_key: str) -> Dict[str, str]:
53
+ """解析 session key,提取 agent_id 等信息"""
54
+ # 格式: agent:agent-id:subagent:uuid 或 agent:agent-id:main
55
+ parts = session_key.split(':')
56
+ result = {'type': parts[0] if len(parts) > 0 else 'unknown'}
57
+
58
+ if len(parts) >= 2:
59
+ result['agent_id'] = parts[1]
60
+ if len(parts) >= 3:
61
+ result['session_type'] = parts[2] # main 或 subagent
62
+ if len(parts) >= 4:
63
+ result['uuid'] = parts[3]
64
+
65
+ return result
66
+
67
+
68
+ def _load_runs() -> Dict[str, Any]:
69
+ """加载 runs.json"""
70
+ runs_file = get_openclaw_root() / "subagents" / "runs.json"
71
+ if not runs_file.exists():
72
+ return {"version": 2, "runs": {}}
73
+
74
+ try:
75
+ with open(runs_file, 'r', encoding='utf-8') as f:
76
+ return json.load(f)
77
+ except:
78
+ return {"version": 2, "runs": {}}
79
+
80
+
81
+ def _get_workflow_state(project_id: str) -> Dict[str, Any]:
82
+ """获取项目的 workflow 状态"""
83
+ # 尝试多个可能的项目路径
84
+ possible_paths = [
85
+ get_openclaw_root() / f"workspace-{project_id}" / ".staging" / "workflow_state.json",
86
+ Path.home() / "vrt-projects" / "projects" / project_id / ".staging" / "workflow_state.json",
87
+ ]
88
+
89
+ for path in possible_paths:
90
+ if path.exists():
91
+ try:
92
+ with open(path, 'r', encoding='utf-8') as f:
93
+ return json.load(f)
94
+ except:
95
+ pass
96
+
97
+ return {}
98
+
99
+
100
+ def build_task_chains(limit: int = 20) -> List[Dict[str, Any]]:
101
+ """
102
+ 构建任务链路列表
103
+
104
+ 通过解析 runs.json 中的派发关系,构建完整的任务执行链路
105
+ """
106
+ runs_data = _load_runs()
107
+ runs = runs_data.get('runs', {})
108
+
109
+ if not runs:
110
+ return []
111
+
112
+ # 按 requesterSessionKey 分组,构建链路
113
+ chains_map: Dict[str, Dict[str, Any]] = {}
114
+
115
+ for run_id, run_info in runs.items():
116
+ if not isinstance(run_info, dict):
117
+ continue
118
+
119
+ requester_key = run_info.get('requesterSessionKey', '')
120
+ child_key = run_info.get('childSessionKey', '')
121
+
122
+ if not requester_key or not child_key:
123
+ continue
124
+
125
+ # 解析 requester 和 child
126
+ requester = _parse_session_key(requester_key)
127
+ child = _parse_session_key(child_key)
128
+
129
+ requester_id = requester.get('agent_id', 'main')
130
+ child_id = child.get('agent_id', 'unknown')
131
+
132
+ # 使用 requester 的 session 作为链路 ID(简化处理)
133
+ chain_id = requester_key.split(':subagent:')[0] if ':subagent:' in requester_key else requester_key
134
+
135
+ if chain_id not in chains_map:
136
+ chains_map[chain_id] = {
137
+ 'chainId': chain_id,
138
+ 'rootTask': '',
139
+ 'startedAt': None,
140
+ 'status': ChainStatus.RUNNING.value,
141
+ 'nodes': {},
142
+ 'edges': [],
143
+ 'projectId': None
144
+ }
145
+
146
+ chain = chains_map[chain_id]
147
+
148
+ # 添加 requester 节点(如果不存在)
149
+ if requester_id not in chain['nodes']:
150
+ agent_info = _get_agent_info(requester_id)
151
+ chain['nodes'][requester_id] = {
152
+ 'agentId': requester_id,
153
+ 'agentName': agent_info.get('name', requester_id),
154
+ 'role': requester_id.split('-')[0] if '-' in requester_id else requester_id,
155
+ 'status': ChainNodeStatus.COMPLETED.value,
156
+ 'startedAt': None,
157
+ 'endedAt': None,
158
+ 'duration': None,
159
+ 'task': None,
160
+ 'runId': None,
161
+ 'input': None,
162
+ 'output': None,
163
+ 'artifacts': [],
164
+ 'toolCallCount': 0,
165
+ 'tokenUsage': {'input': 0, 'output': 0}
166
+ }
167
+
168
+ # 添加子节点
169
+ agent_info = _get_agent_info(child_id)
170
+ started_at = run_info.get('startedAt')
171
+ ended_at = run_info.get('endedAt')
172
+
173
+ node_status = ChainNodeStatus.PENDING.value
174
+ if started_at and ended_at:
175
+ node_status = ChainNodeStatus.COMPLETED.value if run_info.get('outcome') == 'ok' else ChainNodeStatus.ERROR.value
176
+ elif started_at:
177
+ node_status = ChainNodeStatus.RUNNING.value
178
+
179
+ chain['nodes'][child_id] = {
180
+ 'agentId': child_id,
181
+ 'agentName': agent_info.get('name', child_id),
182
+ 'role': child_id.split('-')[0] if '-' in child_id else child_id,
183
+ 'status': node_status,
184
+ 'startedAt': started_at,
185
+ 'endedAt': ended_at,
186
+ 'duration': (ended_at - started_at) if started_at and ended_at else None,
187
+ 'task': run_info.get('task', ''),
188
+ 'runId': run_id,
189
+ 'input': None,
190
+ 'output': None,
191
+ 'artifacts': [],
192
+ 'toolCallCount': 0,
193
+ 'tokenUsage': {'input': 0, 'output': 0}
194
+ }
195
+
196
+ # 添加边
197
+ edge = {'from': requester_id, 'to': child_id}
198
+ if edge not in chain['edges']:
199
+ chain['edges'].append(edge)
200
+
201
+ # 更新链路开始时间
202
+ if started_at:
203
+ if chain['startedAt'] is None or started_at < chain['startedAt']:
204
+ chain['startedAt'] = started_at
205
+
206
+ # 设置根任务(使用第一个子节点的任务)
207
+ if not chain['rootTask'] and run_info.get('task'):
208
+ chain['rootTask'] = run_info.get('task')
209
+
210
+ # 保存 archiveAtMs(用于超时倒计时)
211
+ if run_info.get('archiveAtMs') and not chain.get('archiveAtMs'):
212
+ chain['archiveAtMs'] = run_info.get('archiveAtMs')
213
+
214
+ # 转换节点为列表并计算统计信息
215
+ chains = []
216
+ for chain_id, chain in chains_map.items():
217
+ nodes_list = list(chain['nodes'].values())
218
+
219
+ # 计算进度
220
+ completed = sum(1 for n in nodes_list if n['status'] == ChainNodeStatus.COMPLETED.value)
221
+ running = sum(1 for n in nodes_list if n['status'] == ChainNodeStatus.RUNNING.value)
222
+ total = len(nodes_list)
223
+
224
+ progress = completed / total if total > 0 else 0
225
+
226
+ # 计算总耗时
227
+ total_duration = sum(n['duration'] or 0 for n in nodes_list)
228
+
229
+ # 确定链路状态
230
+ if any(n['status'] == ChainNodeStatus.ERROR.value for n in nodes_list):
231
+ chain_status = ChainStatus.ERROR.value
232
+ elif running > 0:
233
+ chain_status = ChainStatus.RUNNING.value
234
+ else:
235
+ chain_status = ChainStatus.COMPLETED.value
236
+
237
+ chains.append({
238
+ 'chainId': chain_id,
239
+ 'projectId': chain.get('projectId'),
240
+ 'rootTask': chain.get('rootTask', '未知任务'),
241
+ 'startedAt': chain.get('startedAt'),
242
+ 'archiveAtMs': chain.get('archiveAtMs'),
243
+ 'status': chain_status,
244
+ 'nodes': nodes_list,
245
+ 'edges': chain['edges'],
246
+ 'progress': progress,
247
+ 'completedNodes': completed,
248
+ 'totalNodes': total,
249
+ 'totalDuration': total_duration
250
+ })
251
+
252
+ # 按开始时间排序
253
+ chains.sort(key=lambda x: x.get('startedAt') or 0, reverse=True)
254
+
255
+ return chains[:limit]
256
+
257
+
258
+ def get_task_chain(chain_id: str) -> Optional[Dict[str, Any]]:
259
+ """获取单个任务链的详情"""
260
+ chains = build_task_chains(limit=100)
261
+ for chain in chains:
262
+ if chain['chainId'] == chain_id:
263
+ return chain
264
+ return None
265
+
266
+
267
+ def get_active_chain() -> Optional[Dict[str, Any]]:
268
+ """获取当前活跃的任务链(正在执行的)"""
269
+ chains = build_task_chains(limit=50)
270
+ for chain in chains:
271
+ if chain['status'] == ChainStatus.RUNNING.value:
272
+ return chain
273
+ return None
274
+
275
+
276
+ def get_chains_summary() -> Dict[str, Any]:
277
+ """获取任务链摘要统计"""
278
+ chains = build_task_chains(limit=100)
279
+
280
+ running = sum(1 for c in chains if c['status'] == ChainStatus.RUNNING.value)
281
+ completed = sum(1 for c in chains if c['status'] == ChainStatus.COMPLETED.value)
282
+ error = sum(1 for c in chains if c['status'] == ChainStatus.ERROR.value)
283
+
284
+ return {
285
+ 'total': len(chains),
286
+ 'running': running,
287
+ 'completed': completed,
288
+ 'error': error,
289
+ 'chains': [
290
+ {
291
+ 'chainId': c['chainId'],
292
+ 'rootTask': c['rootTask'][:50] + '...' if len(c['rootTask']) > 50 else c['rootTask'],
293
+ 'status': c['status'],
294
+ 'progress': c['progress'],
295
+ 'startedAt': c['startedAt']
296
+ }
297
+ for c in chains[:10]
298
+ ]
299
+ }
@@ -0,0 +1,153 @@
1
+ """
2
+ 配置读取器 - 读取 openclaw.json
3
+ 支持 OPENCLAW_STATE_DIR、OPENCLAW_HOME 环境变量(跨平台,含 Windows)
4
+ """
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import List, Dict, Any
9
+
10
+
11
+ def get_openclaw_root() -> Path:
12
+ """OpenClaw 根目录,统一解析优先级(兼容 Windows):
13
+ 1. OPENCLAW_STATE_DIR(最高优先级)
14
+ 2. OPENCLAW_HOME(兼容两种写法:直接指向 .openclaw,或指向用户 Home 需拼接 .openclaw)
15
+ 3. ~/.openclaw(兜底)
16
+ """
17
+ # 1. OPENCLAW_STATE_DIR
18
+ env_state = os.environ.get("OPENCLAW_STATE_DIR")
19
+ if env_state:
20
+ return Path(env_state).expanduser().resolve()
21
+
22
+ # 2. OPENCLAW_HOME
23
+ env_home = os.environ.get("OPENCLAW_HOME")
24
+ if env_home:
25
+ p = Path(env_home).expanduser().resolve()
26
+ # 兼容两种写法:直接指向 .openclaw 目录,或指向用户 Home
27
+ if p.name in (".openclaw", "openclaw"):
28
+ return p
29
+ return p / ".openclaw"
30
+
31
+ # 3. 兜底
32
+ return Path.home() / ".openclaw"
33
+
34
+
35
+ def load_config() -> Dict[str, Any]:
36
+ """加载 openclaw.json"""
37
+ config_path = get_openclaw_root() / "openclaw.json"
38
+ if not config_path.exists():
39
+ return {}
40
+
41
+ with open(config_path, 'r', encoding='utf-8') as f:
42
+ return json.load(f)
43
+
44
+
45
+ def get_agents_list() -> List[Dict[str, Any]]:
46
+ """获取 Agent 列表"""
47
+ config = load_config()
48
+ agents = config.get('agents')
49
+ if agents is None or not isinstance(agents, dict):
50
+ return []
51
+ return agents.get('list', [])
52
+
53
+
54
+ def get_main_agent_id() -> str:
55
+ """获取主 Agent ID(配置中 id 为 main 的,或列表第一个)"""
56
+ agents = get_agents_list()
57
+ for a in agents:
58
+ if a.get('id') == 'main':
59
+ return 'main'
60
+ return agents[0].get('id', 'main') if agents else 'main'
61
+
62
+
63
+ def get_workspace_paths() -> List[Path]:
64
+ """获取所有 Agent 的 workspace 路径(用于 model-failures.log 等)"""
65
+ agents = get_agents_list()
66
+ paths = []
67
+ seen = set()
68
+ for a in agents:
69
+ ws = a.get('workspace')
70
+ if ws and ws not in seen:
71
+ p = Path(ws).expanduser() if isinstance(ws, str) else Path(ws)
72
+ if p.exists():
73
+ paths.append(p)
74
+ seen.add(ws)
75
+ if not paths:
76
+ paths.append(get_openclaw_root() / "workspace-main")
77
+ return paths
78
+
79
+
80
+ def get_agent_config(agent_id: str) -> Dict[str, Any]:
81
+ """获取单个 Agent 配置"""
82
+ agents = get_agents_list()
83
+ for agent in agents:
84
+ if agent.get('id') == agent_id:
85
+ return agent
86
+ return {}
87
+
88
+
89
+ def get_default_config() -> Dict[str, Any]:
90
+ """获取默认配置"""
91
+ config = load_config()
92
+ agents = config.get('agents')
93
+ if agents is None or not isinstance(agents, dict):
94
+ return {}
95
+ return agents.get('defaults', {})
96
+
97
+
98
+ def get_agent_models(agent_id: str) -> Dict[str, Any]:
99
+ """获取 Agent 的模型配置(primary + fallbacks)"""
100
+ agent = get_agent_config(agent_id)
101
+ model_cfg = agent.get('model') or {}
102
+ defaults = get_default_config()
103
+ default_model = defaults.get('model', {})
104
+ primary = model_cfg.get('primary') or default_model.get('primary') or ''
105
+ fallbacks = model_cfg.get('fallbacks') or default_model.get('fallbacks') or []
106
+ return {'primary': primary, 'fallbacks': fallbacks}
107
+
108
+
109
+ def get_models_configured_by_agents() -> List[str]:
110
+ """
111
+ 从配置中收集「各 Agent 实际配置使用」的模型 ID(仅 primary + fallbacks)。
112
+ 用于协作流程右侧模型面板:只显示有 Agent 配置的模型,不含白名单中未使用的。
113
+ """
114
+ agents = get_agents_list()
115
+ model_ids = set()
116
+ defaults = get_default_config()
117
+ default_model = defaults.get('model', {})
118
+ if default_model.get('primary'):
119
+ model_ids.add(default_model['primary'])
120
+ for fb in default_model.get('fallbacks') or []:
121
+ model_ids.add(fb)
122
+ for agent in agents:
123
+ cfg = get_agent_models(agent.get('id', ''))
124
+ if cfg.get('primary'):
125
+ model_ids.add(cfg['primary'])
126
+ for fb in cfg.get('fallbacks', []):
127
+ model_ids.add(fb)
128
+ return sorted(model_ids)
129
+
130
+
131
+ def get_all_models_from_agents() -> List[str]:
132
+ """
133
+ 从配置中收集模型 ID(provider/model 格式),用于无 models.providers 时的下拉选项。
134
+ 来源(合并去重):
135
+ 1. 各 Agent 实际配置(primary + fallbacks)
136
+ 2. agents.defaults.models(白名单 key,确保配置过的能选)
137
+ """
138
+ model_ids = set(get_models_configured_by_agents())
139
+ defaults = get_default_config()
140
+ models_cfg = defaults.get('models', {}) or {}
141
+ if isinstance(models_cfg, dict):
142
+ for mid in models_cfg.keys():
143
+ if mid and isinstance(mid, str):
144
+ model_ids.add(mid)
145
+ return sorted(model_ids)
146
+
147
+
148
+ def get_model_display_name(model_id: str) -> str:
149
+ """获取模型显示名。展示策略:使用 id 不用别名(与 OpenClaw 白名单逻辑一致)"""
150
+ if not model_id:
151
+ return ''
152
+ parts = model_id.split('/')
153
+ return parts[-1] if len(parts) > 1 else model_id