feat: route the console on paths, with the console at /

Routing landed on the hash a commit ago because the page referenced every
asset relatively, which a path route would resolve against the routed path and
404. That was the wrong thing to work around: the console reads as an app, so
its address bar should too. Make the references absolute and the constraint
goes away.

The console now answers at / rather than redirecting to /chat, and each view is
a path under it: /agents, /settings, /settings/models. /chat stays as a
redirect, since it is what older bookmarks and the startup banner point at.

The settings view is /settings, not /config: /config is already the config API,
which both this console and the desktop client call. The view routes sit last
in the URL table so web.py's first-match rule keeps any view name from
shadowing an endpoint, and a test pins that collision so it cannot return
quietly. Unknown paths still 404 rather than being swallowed by the console.

This costs no reverse-proxy subpath mount, because there was never one to
lose: every API call the console makes is already an absolute /api/... path, so
it could not be served from under a prefix either way. Supporting one means
prefixing all of them, not keeping asset references relative.

Co-authored-by: cowagent <cow@cowagent.ai>
This commit is contained in:
zkjqdandcowagent committed 2026-09-16 18:08:38 +08:00
1 parent 5101c0c37c
commit c2efb0bbaf
17 files changed
+348 -156

No files matched your search

+18 -7
View File
@@ -117,12 +117,23 @@ node --stack-size=40000 channel/web/tools/check-load-order.mjs
## 地址栏路由
视图和标签页反映在 `location.hash` 上:`#config`、`#config/models`。
刷新后回到原处,链接可以分享,浏览器的前进后退在视图之间移动。
控制台就是 `/` 这个应用本身,视图和标签页是它下面的路径:
`/agents`、`/settings`、`/settings/models`。刷新后回到原处,链接可以分享,
浏览器的前进后退在视图之间移动。`/chat` 是旧地址,保留为到 `/` 的重定向。
**用 hash 而不是路径**,是因为页面里所有资源引用都是相对路径(`assets/js/...`)。
`/chat/config` 这样的路径路由会让它们相对 `/chat/` 解析,整页资源 404。
用 hash 则后端一行都不用改,反向代理和桌面版也不需要额外配置。
**路由名不总是等于视图的内部 id。** 设置页的内部 id 是 `config`,但 URL 是
`/settings`——`/config` 已经是后端的配置接口,网页控制台和桌面客户端都在调它。
`web_channel.py` 的 URL 表把这些路径指向同一个外壳,前端路由再打开对应视图;
两张表必须保持一致,`tests/test_web_console_routing.py` 会对比它们。
视图路径排在 URL 表的**最后**:web.py 取第一个匹配,所以任何视图名都不可能
盖住前面的接口路由。反过来,新增接口时也不用担心撞上视图名。
**页面里的资源引用必须是绝对路径**(`/assets/js/...`)。这是路径路由的前提:
相对引用在 `/settings/models` 下会被解析成 `/settings/assets/...` 而整页 404。
这不构成对反向代理子路径挂载(例如 `https://host/cow/`)的妥协:控制台本来就
挂不上去,它的每一处接口调用写的都是 `/api/...` 这样的绝对路径。要支持子路径,
需要的是给全部接口和资源引用加统一前缀,而不是把资源引用改回相对路径。
路由只记到视图和标签页两层。再深的状态(当前会话、编辑器打开的文件)
刻意不进 URL——它们已经由 localStorage 恢复,写进 URL 会让地址栏在
@@ -136,8 +147,8 @@ node --stack-size=40000 channel/web/tools/check-load-order.mjs
- **重复进入当前视图**(再点一次已选中的侧边栏项)同样是替换,
否则连点几次之后按后退会像是没有反应。
写地址栏一律走 `pushState`/`replaceState`,它们不触发 `hashchange`,
因此写入不会再绕回来被当成一次导航。反过来,前进后退触发的 `hashchange`
写地址栏一律走 `pushState`/`replaceState`,它们不触发 `popstate`,
因此写入不会再绕回来被当成一次导航。反过来,前进后退触发的 `popstate`
是唯一的入口,`_routeApplying` 标记在应用路由期间关掉写入,避免重复记录。
未保存的编辑仍然拦得住:后退时地址栏已经先变了,`navigateTo` 返回 `false`
+60 -60
View File
@@ -4,21 +4,21 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CowAgent Console</title>
<link rel="icon" href="assets/favicon.ico" type="image/x-icon">
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
<!-- Vendored third-party assets (no external CDN dependency).
See channel/web/static/vendor/README.md for sources & versions. -->
<link rel="stylesheet" href="assets/vendor/fontawesome/css/all.min.css">
<link rel="stylesheet" href="assets/vendor/fonts/inter/inter.css">
<script src="assets/vendor/tailwind/tailwind.min.js"></script>
<script src="assets/vendor/markdown-it/markdown-it.min.js"></script>
<link id="hljs-light" rel="stylesheet" href="assets/vendor/highlightjs/styles/github.min.css">
<link id="hljs-dark" rel="stylesheet" href="assets/vendor/highlightjs/styles/github-dark.min.css" disabled>
<script src="assets/vendor/highlightjs/highlight.min.js"></script>
<script src="assets/vendor/highlightjs/languages/python.min.js"></script>
<script src="assets/vendor/highlightjs/languages/javascript.min.js"></script>
<script src="assets/vendor/highlightjs/languages/java.min.js"></script>
<script src="assets/vendor/highlightjs/languages/go.min.js"></script>
<script src="assets/vendor/highlightjs/languages/bash.min.js"></script>
<link rel="stylesheet" href="/assets/vendor/fontawesome/css/all.min.css">
<link rel="stylesheet" href="/assets/vendor/fonts/inter/inter.css">
<script src="/assets/vendor/tailwind/tailwind.min.js"></script>
<script src="/assets/vendor/markdown-it/markdown-it.min.js"></script>
<link id="hljs-light" rel="stylesheet" href="/assets/vendor/highlightjs/styles/github.min.css">
<link id="hljs-dark" rel="stylesheet" href="/assets/vendor/highlightjs/styles/github-dark.min.css" disabled>
<script src="/assets/vendor/highlightjs/highlight.min.js"></script>
<script src="/assets/vendor/highlightjs/languages/python.min.js"></script>
<script src="/assets/vendor/highlightjs/languages/javascript.min.js"></script>
<script src="/assets/vendor/highlightjs/languages/java.min.js"></script>
<script src="/assets/vendor/highlightjs/languages/go.min.js"></script>
<script src="/assets/vendor/highlightjs/languages/bash.min.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
@@ -45,14 +45,14 @@
<!-- Split out of a single console.css. The order is load-bearing: later
sheets override earlier ones, so keep it as-is and see
channel/web/README.md before inserting anything. -->
<link rel="stylesheet" href="assets/css/base.css">
<link rel="stylesheet" href="assets/css/sessions.css">
<link rel="stylesheet" href="assets/css/components.css">
<link rel="stylesheet" href="assets/css/markdown.css">
<link rel="stylesheet" href="assets/css/chat.css">
<link rel="stylesheet" href="assets/css/workspace.css">
<link rel="stylesheet" href="assets/css/knowledge.css">
<link rel="stylesheet" href="assets/css/agents.css">
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="/assets/css/sessions.css">
<link rel="stylesheet" href="/assets/css/components.css">
<link rel="stylesheet" href="/assets/css/markdown.css">
<link rel="stylesheet" href="/assets/css/chat.css">
<link rel="stylesheet" href="/assets/css/workspace.css">
<link rel="stylesheet" href="/assets/css/knowledge.css">
<link rel="stylesheet" href="/assets/css/agents.css">
<!-- Apply theme/lang before first paint to avoid flash of unstyled content.
This runs synchronously in <head> so the correct class is on <html>
before any CSS or body rendering occurs. -->
@@ -155,20 +155,20 @@
adding, removing or moving a line. -->
<!-- doc-editor first: views/doc-viewers.js builds its editors at load time. -->
<script defer src="assets/js/doc-editor.js"></script>
<script defer src="/assets/js/doc-editor.js"></script>
<script defer src="assets/js/core/version.js"></script>
<script defer src="assets/js/core/i18n.js"></script>
<script defer src="assets/js/core/theme.js"></script>
<script defer src="assets/js/core/utils.js"></script>
<script defer src="assets/js/core/markdown.js"></script>
<script defer src="assets/js/core/confirm.js"></script>
<script defer src="assets/js/core/notify.js"></script>
<script defer src="assets/js/core/nav.js"></script>
<script defer src="/assets/js/core/version.js"></script>
<script defer src="/assets/js/core/i18n.js"></script>
<script defer src="/assets/js/core/theme.js"></script>
<script defer src="/assets/js/core/utils.js"></script>
<script defer src="/assets/js/core/markdown.js"></script>
<script defer src="/assets/js/core/confirm.js"></script>
<script defer src="/assets/js/core/notify.js"></script>
<script defer src="/assets/js/core/nav.js"></script>
<!-- After nav.js, whose navigateTo it drives and whose VIEW_META it
validates against. Only registers a hashchange listener at load time;
the first route is applied from initApp(), once auth has settled. -->
<script defer src="assets/js/core/router.js"></script>
<script defer src="/assets/js/core/router.js"></script>
<!-- Ahead of chat/, not with the other views: chat/state.js computes
sessionId at load time and activeSessionStorageKey() reads
@@ -176,46 +176,46 @@
puts that binding in its temporal dead zone, and chat/state.js dies
on the spot - taking the restored session, and so the visible
conversation history, with it. -->
<script defer src="assets/js/views/agents.js"></script>
<script defer src="/assets/js/views/agents.js"></script>
<script defer src="assets/js/chat/state.js"></script>
<script defer src="assets/js/chat/context-usage.js"></script>
<script defer src="assets/js/chat/workspace-selector.js"></script>
<script defer src="assets/js/chat/session-settings.js"></script>
<script defer src="assets/js/chat/composer-input.js"></script>
<script defer src="assets/js/chat/message-actions.js"></script>
<script defer src="assets/js/chat/send.js"></script>
<script defer src="assets/js/chat/scheduler-notify.js"></script>
<script defer src="assets/js/chat/render.js"></script>
<script defer src="assets/js/chat/new-chat.js"></script>
<script defer src="/assets/js/chat/state.js"></script>
<script defer src="/assets/js/chat/context-usage.js"></script>
<script defer src="/assets/js/chat/workspace-selector.js"></script>
<script defer src="/assets/js/chat/session-settings.js"></script>
<script defer src="/assets/js/chat/composer-input.js"></script>
<script defer src="/assets/js/chat/message-actions.js"></script>
<script defer src="/assets/js/chat/send.js"></script>
<script defer src="/assets/js/chat/scheduler-notify.js"></script>
<script defer src="/assets/js/chat/render.js"></script>
<script defer src="/assets/js/chat/new-chat.js"></script>
<script defer src="assets/js/views/sessions.js"></script>
<script defer src="assets/js/views/config.js"></script>
<script defer src="assets/js/views/models.js"></script>
<script defer src="assets/js/views/models-custom-provider.js"></script>
<script defer src="assets/js/views/channels.js"></script>
<script defer src="assets/js/views/channels-weixin.js"></script>
<script defer src="assets/js/views/channels-wecom.js"></script>
<script defer src="assets/js/views/channels-feishu.js"></script>
<script defer src="assets/js/views/tasks.js"></script>
<script defer src="assets/js/views/tasks-modal.js"></script>
<script defer src="assets/js/views/skills.js"></script>
<script defer src="assets/js/views/memory.js"></script>
<script defer src="assets/js/views/doc-viewers.js"></script>
<script defer src="assets/js/views/knowledge.js"></script>
<script defer src="assets/js/views/logs.js"></script>
<script defer src="/assets/js/views/sessions.js"></script>
<script defer src="/assets/js/views/config.js"></script>
<script defer src="/assets/js/views/models.js"></script>
<script defer src="/assets/js/views/models-custom-provider.js"></script>
<script defer src="/assets/js/views/channels.js"></script>
<script defer src="/assets/js/views/channels-weixin.js"></script>
<script defer src="/assets/js/views/channels-wecom.js"></script>
<script defer src="/assets/js/views/channels-feishu.js"></script>
<script defer src="/assets/js/views/tasks.js"></script>
<script defer src="/assets/js/views/tasks-modal.js"></script>
<script defer src="/assets/js/views/skills.js"></script>
<script defer src="/assets/js/views/memory.js"></script>
<script defer src="/assets/js/views/doc-viewers.js"></script>
<script defer src="/assets/js/views/knowledge.js"></script>
<script defer src="/assets/js/views/logs.js"></script>
<!-- Last of the core layer rather than first, which is where it sat in
console.js. It installs a window.fetch wrapper for 401 handling, and
chat/state.js installs one that appends agent_id; keeping this one
outermost means the 401 check still sees the URL the caller passed. -->
<script defer src="assets/js/core/auth.js"></script>
<script defer src="/assets/js/core/auth.js"></script>
<!-- boot before workspace.js, which is where console.js used to sit:
applyI18n() probes for relocalizeWorkspacePanel with a typeof guard,
and it has always run before workspace.js defined it. -->
<script defer src="assets/js/boot.js"></script>
<script defer src="/assets/js/boot.js"></script>
<script defer src="assets/js/workspace.js"></script>
<script defer src="/assets/js/workspace.js"></script>
</body>
</html>
+1 -1
View File
@@ -123,7 +123,7 @@ function newChat(optimistic = true, inherit = true) {
ws.className = 'flex flex-col items-center justify-center h-full px-6 pb-16';
ws.style.paddingTop = '6vh';
ws.innerHTML = `
<img src="assets/logo.jpg" alt="CowAgent" class="w-16 h-16 rounded-2xl mb-6 shadow-lg shadow-primary-500/20">
<img src="/assets/logo.jpg" alt="CowAgent" class="w-16 h-16 rounded-2xl mb-6 shadow-lg shadow-primary-500/20">
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100 mb-3">${appConfig.title || 'CowAgent'}</h1>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-lg mb-10 leading-relaxed" data-i18n="welcome_subtitle">${t('welcome_subtitle')}</p>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full max-w-2xl">
+10 -4
View File
@@ -563,14 +563,20 @@ messagesDiv.addEventListener('click', (e) => {
const a = e.target.closest('a');
if (!a) return;
const href = a.getAttribute('href') || '';
// Links the Agent writes into its answers. /memory/dreams is a real route
// now, so letting the click through would work too -- it is still handled
// here so it stays a view switch rather than a page load. navigateTo puts
// the view and its tab up in one synchronous step, which is what the
// timeouts these calls used to be wrapped in were waiting for.
if (href === '/memory/dreams') {
e.preventDefault();
navigateTo('memory');
setTimeout(() => switchMemoryTab('dreams'), 50);
navigateTo('memory', 'dreams');
} else if (href === '/memory/MEMORY.md') {
// Not a route: which file the viewer holds is deliberately not in the
// URL, so this opens the file on top of the files tab.
e.preventDefault();
navigateTo('memory');
setTimeout(() => { switchMemoryTab('files'); openMemoryFile('MEMORY.md', 'memory'); }, 50);
navigateTo('memory', 'files');
openMemoryFile('MEMORY.md', 'memory');
}
});
const attachmentPreview = document.getElementById('attachment-preview');
+3 -2
View File
@@ -136,8 +136,9 @@ function navigateTo(viewId, tab) {
renderMemoryAgentSelect();
switchMemoryTab(tab || 'files');
}
// Knowledge keeps whichever tab it was left on, so it only switches when
// the route names one.
// loadKnowledgeView lands on the docs tab itself, so unlike the views
// above there is no default to pass -- only a route-named tab to override
// it with.
else if (viewId === 'knowledge') { loadKnowledgeView(); if (tab) switchKnowledgeTab(tab); }
else if (viewId === 'channels') loadChannelsView();
else if (viewId === 'tasks') { switchTasksTab(tab || 'tasks'); loadTasksView(); }
+52 -28
View File
@@ -1,5 +1,5 @@
/* Hash routing: mirrors the current view and tab into location.hash, so a
reload lands where the user left off and Back/Forward move between views.
/* Address-bar routing: mirrors the current view and tab into the URL path, so
a reload lands where the user left off and Back/Forward move between views.
These are classic scripts sharing one global scope; see
channel/web/README.md before changing the load order. */
@@ -7,14 +7,30 @@
// Routing
// =====================================================================
// A route names a view and, for the views that have tabs, one of its tabs:
// #config, #config/models. Deeper state -- which session is open, which file
// the editor holds -- deliberately stays out. It is already restored from
// /settings, /settings/models. Deeper state -- which session is open, which
// file the editor holds -- deliberately stays out. It is already restored from
// localStorage, and putting it in the URL would rewrite the address bar on
// every click in the session list.
//
// The hash carries this rather than the path: every asset in the page is
// referenced relatively (assets/js/...), so a path route like /chat/config
// would resolve them against /chat/ and 404 the whole console.
// The view a route names is not always the view's internal id: /settings is
// the config view, because /config is the backend's config API, which this
// console and the desktop client both call. web_channel.py serves these paths
// from the same shell; the table there has to stay in step with this one.
const ROUTE_PATHS = {
chat: '',
agents: 'agents',
config: 'settings',
skills: 'skills',
memory: 'memory',
knowledge: 'knowledge',
channels: 'channels',
tasks: 'tasks',
logs: 'logs',
};
const ROUTE_VIEWS = {};
for (const view in ROUTE_PATHS) ROUTE_VIEWS[ROUTE_PATHS[view]] = view;
const ROUTE_TABS = {
config: ['basic', 'models'],
memory: ['files', 'dreams'],
@@ -32,23 +48,29 @@ let routeTab = '';
// applied, and writing would stack a duplicate entry onto the history.
let _routeApplying = false;
function _routeParse(hash) {
const [view, tab] = String(hash || '').replace(/^#/, '').split('/');
// An unknown view -- a stale bookmark, a hand-edited URL -- falls back to
function _routePath(view, tab) {
const path = ROUTE_PATHS[view] || '';
return '/' + path + (path && tab ? '/' + tab : '');
}
function _routeParse(pathname) {
const parts = String(pathname || '/').replace(/^\/+|\/+$/g, '').split('/');
const view = ROUTE_VIEWS[parts[0] || ''];
// An unknown path -- a stale bookmark, a hand-edited URL -- falls back to
// chat rather than leaving the console on whatever happens to be on screen.
if (!view || !VIEW_META[view]) return { view: 'chat', tab: '' };
const allowed = ROUTE_TABS[view] || [];
// An unknown tab is dropped, not passed on: the tab switchers index into
// the DOM by name and would throw on one that does not exist.
return { view: view, tab: allowed.indexOf(tab) === -1 ? '' : tab };
return { view: view, tab: allowed.indexOf(parts[1]) === -1 ? '' : parts[1] };
}
function _routeWrite(view, tab, replace) {
const hash = '#' + view + (tab ? '/' + tab : '');
if (location.hash === hash) return;
// pushState/replaceState do not fire hashchange, so writing the address bar
const path = _routePath(view, tab);
if (location.pathname === path) return;
// pushState/replaceState do not fire popstate, so writing the address bar
// cannot loop back in as a navigation.
history[replace ? 'replaceState' : 'pushState'](null, '', hash);
history[replace ? 'replaceState' : 'pushState'](null, '', path);
}
function _routeApplyTab(view, tab) {
@@ -68,7 +90,7 @@ function routeEnterView(view) {
// again -- is not a new destination. Refine the entry instead of stacking
// another, or Back would have to undo a run of no-op navigations before it
// appeared to do anything.
const reentry = _routeParse(location.hash).view === view;
const reentry = _routeParse(location.pathname).view === view;
_routeWrite(view, '', reentry);
}
@@ -85,7 +107,7 @@ function routeNoteTab(view, tab) {
// Apply what the address bar says: on Back/Forward, and once at startup.
function routeApply() {
const parsed = _routeParse(location.hash);
const parsed = _routeParse(location.pathname);
if (parsed.view === currentView) {
if (parsed.tab && parsed.tab !== routeTab) {
@@ -93,18 +115,20 @@ function routeApply() {
try { _routeApplyTab(parsed.view, parsed.tab); }
finally { _routeApplying = false; }
}
return;
} else {
_routeApplying = true;
try { navigateTo(parsed.view, parsed.tab); }
finally { _routeApplying = false; }
}
_routeApplying = true;
let moved;
try { moved = navigateTo(parsed.view, parsed.tab); }
finally { _routeApplying = false; }
// navigateTo refuses while a document editor holds unsaved changes: it
// asks first, and calls back only if the user discards them. On Back the
// address bar has already moved by then, so put it back until it happens.
if (moved === false) _routeWrite(currentView, routeTab, true);
// Settle the address bar on whatever ended up on screen. Usually it
// already says that and this writes nothing. It earns its keep in the two
// cases where the path and the page disagree: a path that named a tab the
// view does not have (/settings/bogus, /settings on its own) lands on the
// view's default tab and the path is completed to match; and a navigation
// the unsaved-edit guard refused leaves the page where it was, so the
// address bar -- which Back has already moved -- has to go back with it.
_routeWrite(currentView, routeTab, true);
}
window.addEventListener('hashchange', routeApply);
window.addEventListener('popstate', routeApply);
+1 -1
View File
@@ -72,7 +72,7 @@ function agentAvatarHTML(agent, size) {
// uploaded picture, so the instance's own Agent wears the CowAgent face.
// Added Agents keep the initial-disc fallback so a team stays distinguishable.
if (agent && agent.id && agent.id === defaultAgentId) {
return `<img class="${cls}" src="assets/logo.jpg" alt="">`;
return `<img class="${cls}" src="/assets/logo.jpg" alt="">`;
}
const initial = avatarInitial(agent && (agent.name || agent.id));
return `<span class="${cls} agent-avatar-tone-${avatarTone(agent && agent.id)}">${escapeHtml(initial)}</span>`;
+1 -1
View File
@@ -751,7 +751,7 @@ function ensureD3Loaded() {
if (_d3LoadPromise) return _d3LoadPromise;
_d3LoadPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'assets/vendor/d3/d3.min.js';
script.src = '/assets/vendor/d3/d3.min.js';
script.async = true;
script.onload = () => resolve(window.d3);
script.onerror = () => reject(new Error('Failed to load d3'));
+1 -1
View File
@@ -43,7 +43,7 @@ const MODELS_CAPABILITY_DEFS = [
// absent. Vendors whose mark is rendered in pure (or near-pure) black are
// listed in MODELS_PROVIDER_LOGO_DARK_INVERT — for those, we apply a CSS
// invert filter in dark mode so the glyph stays visible against #1A1A1A.
const MODELS_PROVIDER_LOGO_PATH = 'assets/logos';
const MODELS_PROVIDER_LOGO_PATH = '/assets/logos';
const MODELS_PROVIDER_LOGO_DARK_INVERT = new Set([
'openai', // black wordmark
'moonshot', // dark monogram
+1 -1
View File
@@ -2,7 +2,7 @@
<div id="login-overlay" class="fixed inset-0 z-[200] bg-gray-50 dark:bg-[#111111] flex items-center justify-center hidden">
<div class="w-full max-w-sm mx-4">
<div class="flex flex-col items-center mb-8">
<img src="assets/logo.jpg" alt="CowAgent" class="w-16 h-16 rounded-2xl mb-4 shadow-lg">
<img src="/assets/logo.jpg" alt="CowAgent" class="w-16 h-16 rounded-2xl mb-4 shadow-lg">
<h1 class="text-xl font-bold text-slate-800 dark:text-slate-100">CowAgent</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" id="login-subtitle">请输入密码以访问控制台</p>
</div>
+1 -1
View File
@@ -6,7 +6,7 @@
transition-transform duration-300 ease-in-out">
<!-- Logo -->
<div class="flex items-center gap-3 px-5 h-14 border-b border-white/10 flex-shrink-0">
<img src="assets/logo.jpg" alt="CowAgent" class="w-8 h-8 rounded-lg flex-shrink-0">
<img src="/assets/logo.jpg" alt="CowAgent" class="w-8 h-8 rounded-lg flex-shrink-0">
<div class="flex flex-col min-w-0">
<span class="text-white font-semibold text-sm truncate">CowAgent</span>
<span class="text-neutral-500 text-xs" data-i18n="console">控制台</span>
+1 -1
View File
@@ -7,7 +7,7 @@
<div id="chat-messages" class="flex-1 overflow-y-auto">
<!-- Welcome Screen -->
<div id="welcome-screen" class="flex flex-col items-center justify-center h-full px-6 pb-16" style="padding-top: 6vh">
<img src="assets/logo.jpg" alt="CowAgent" class="w-16 h-16 rounded-2xl mb-6 shadow-lg shadow-primary-500/20">
<img src="/assets/logo.jpg" alt="CowAgent" class="w-16 h-16 rounded-2xl mb-6 shadow-lg shadow-primary-500/20">
<h1 id="welcome-title" class="text-2xl font-bold text-slate-800 dark:text-slate-100 mb-3">CowAgent</h1>
<p id="welcome-subtitle" class="text-slate-500 dark:text-slate-400 text-center max-w-lg mb-10 leading-relaxed"
data-i18n-html="welcome_subtitle">我可以帮你解答问题、管理计算机、创造和执行技能,并通过<br>长期记忆和知识库不断成长</p>
+57 -32
View File
@@ -32,26 +32,28 @@ function navigateTo(viewId, tab) {
if (viewId === 'config') switchConfigTab(tab || 'basic');
else if (viewId === 'memory') switchMemoryTab(tab || 'files');
else if (viewId === 'tasks') switchTasksTab(tab || 'tasks');
else if (viewId === 'knowledge') { if (tab) switchKnowledgeTab(tab); }
// Knowledge takes no default from the caller because loadKnowledgeView
// lands on docs by itself; a route-named tab then overrides it.
else if (viewId === 'knowledge') { switchKnowledgeTab('docs'); if (tab) switchKnowledgeTab(tab); }
return true;
}
`;
function makeApp(startHash) {
function makeApp(startPath) {
const log = { nav: [], tabs: [], writes: [] };
const state = { hash: startHash || '' };
const state = { path: startPath || '/' };
const VIEW_META = {};
for (const v of ['chat', 'agents', 'config', 'skills', 'memory',
'knowledge', 'channels', 'tasks', 'logs']) VIEW_META[v] = {};
const location = { get hash() { return state.hash; } };
const location = { get pathname() { return state.path; } };
const history = {
pushState(_s, _t, h) { log.writes.push('push ' + h); state.hash = h; },
replaceState(_s, _t, h) { log.writes.push('replace ' + h); state.hash = h; },
pushState(_s, _t, p) { log.writes.push('push ' + p); state.path = p; },
replaceState(_s, _t, p) { log.writes.push('replace ' + p); state.path = p; },
};
let fire = null;
const window = { addEventListener(ev, fn) { if (ev === 'hashchange') fire = fn; } };
const window = { addEventListener(ev, fn) { if (ev === 'popstate') fire = fn; } };
const factory = new Function('env', `
const { VIEW_META, location, history, window, log } = env;
@@ -68,10 +70,10 @@ function makeApp(startHash) {
`);
const app = factory({ VIEW_META, location, history, window, log });
// Back/Forward: the browser moves the hash, then notifies.
app.back = (hash) => { state.hash = hash; fire(); };
// Back/Forward: the browser moves the URL, then notifies.
app.back = (path) => { state.path = path; fire(); };
app.log = log;
app.hash = () => state.hash;
app.path = () => state.path;
return app;
}
@@ -84,50 +86,73 @@ function check(label, actual, expected) {
if (!ok) console.log(` got ${a}\n expected ${e}`);
}
// --- a fresh visit with no hash stays on chat and writes nothing ----------
let app = makeApp('');
// --- the console root opens chat and writes nothing -----------------------
let app = makeApp('/');
app.routeApply();
check('empty hash: no navigation', app.log.nav, []);
check('empty hash: address bar untouched', app.log.writes, []);
check('root: no navigation', app.log.nav, []);
check('root: address bar untouched', app.log.writes, []);
// --- a shared link opens the view and tab it names, without churn ---------
app = makeApp('#config/models');
app = makeApp('/settings/models');
app.routeApply();
check('deep link: navigates once', app.log.nav, ['config/models']);
check('deep link: opens the named tab', app.log.tabs, ['config/models']);
check('deep link: no history entries added', app.log.writes, []);
check('deep link: lands on the view', app.currentView, 'config');
// the settings view is not at /config: that path is the config API
app = makeApp('/config');
app.routeApply();
check('/config is not a view route', app.currentView, 'chat');
// --- a hand-edited or stale route degrades instead of throwing ------------
app = makeApp('#knowledge/bogus');
app = makeApp('/knowledge/bogus');
app.routeApply();
check('unknown tab: dropped, view still opens', app.log.nav, ['knowledge']);
check('unknown tab: no tab switch attempted', app.log.tabs, []);
check('unknown tab: never switched to', app.log.tabs, ['knowledge/docs']);
check('unknown tab: address bar corrected to the tab that did open',
app.path(), '/knowledge/docs');
app = makeApp('#nope');
app = makeApp('/nope');
app.routeApply();
check('unknown view: falls back to chat, no navigation', app.log.nav, []);
check('unknown path: falls back to chat, no navigation', app.log.nav, []);
check('unknown path: address bar corrected to chat', app.path(), '/');
// a view named without one of its tabs lands on its default, and says so
app = makeApp('/settings');
app.routeApply();
check('bare view: opens the default tab', app.log.tabs, ['config/basic']);
check('bare view: address bar completed to it', app.path(), '/settings/basic');
// a trailing slash is the same route
app = makeApp('/settings/models/');
app.routeApply();
check('trailing slash: same route', app.log.nav, ['config/models']);
check('trailing slash: normalised away', app.path(), '/settings/models');
// --- clicking through the sidebar leaves one entry per view ---------------
app = makeApp('');
app = makeApp('/');
app.navigateTo('config');
check('sidebar: one entry for the view, refined to its tab',
app.log.writes, ['push #config', 'replace #config/basic']);
app.log.writes, ['push /settings', 'replace /settings/basic']);
app.log.writes.length = 0;
app.navigateTo('skills');
check('sidebar: a tabless view is one plain entry',
app.log.writes, ['push #skills']);
app.log.writes, ['push /skills']);
app.log.writes.length = 0;
app.navigateTo('chat');
check('sidebar: chat is the root path', app.log.writes, ['push /']);
// --- re-entering the current view refines rather than stacking ------------
app = makeApp('');
app = makeApp('/');
app.navigateTo('config');
app.log.writes.length = 0;
app.navigateTo('config', 'models');
check('re-entry: refines the entry, never pushes a second one',
app.log.writes, ['replace #config', 'replace #config/models']);
app.log.writes, ['replace /settings', 'replace /settings/models']);
// clicking the sidebar item you are already on, repeatedly
app = makeApp('');
app = makeApp('/');
app.navigateTo('skills');
app.log.writes.length = 0;
app.navigateTo('skills');
@@ -135,25 +160,25 @@ app.navigateTo('skills');
check('re-entry: a tabless view stays at one entry', app.log.writes, []);
// --- Back returns to the previous view -----------------------------------
app = makeApp('');
app = makeApp('/');
app.navigateTo('config');
app.log.nav.length = 0;
app.back('#chat');
app.back('/');
check('back: navigates to the previous view', app.log.nav, ['chat']);
check('back: lands there', app.currentView, 'chat');
// --- Back with unsaved edits puts the address bar back --------------------
app = makeApp('');
app = makeApp('/');
app.navigateTo('config');
app.guardRefuses = true;
app.log.writes.length = 0;
app.back('#chat');
app.back('/');
check('guarded back: stays on the view', app.currentView, 'config');
check('guarded back: address bar restored, without a new entry',
app.log.writes, ['replace #config/basic']);
app.log.writes, ['replace /settings/basic']);
// --- applying a route must not re-enter through the hash ------------------
app = makeApp('#tasks/records');
// --- applying a route must not re-enter through the address bar -----------
app = makeApp('/tasks/records');
app.routeApply();
check('apply: does not loop back in', app.log.nav, ['tasks/records']);
check('apply: tracks the tab for a later restore', app.routeTab, 'records');
+23 -2
View File
@@ -2257,7 +2257,8 @@ class WebChannel(ChatChannel):
logger.debug(f"[WebChannel] Skipped creating static dir (read-only bundle?): {e}")
urls = (
'/', 'RootHandler',
'/', 'ChatHandler',
'/chat', 'RootHandler',
'/api/health', 'HealthHandler',
'/auth/login', 'AuthLoginHandler',
'/auth/check', 'AuthCheckHandler',
@@ -2333,6 +2334,15 @@ class WebChannel(ChatChannel):
'/api/update/status', 'UpdateStatusHandler',
'/mcp/oauth/callback', 'McpOAuthCallbackHandler',
'/assets/(.*)', 'AssetsHandler',
# Views inside the single-page console. Each serves the same shell;
# the frontend router reads the path and opens the view it names,
# so a reload or a shared link lands where it says. Last in the
# table on purpose: web.py takes the first match, so no view name
# can ever shadow an API route above -- which is also why the
# settings view is /settings and not /config, a path the config
# API already owns.
'/(?:agents|settings|skills|memory|knowledge|channels|tasks|logs)'
'(?:/[a-z]+)?/?', 'ChatHandler',
)
app = web.application(urls, globals(), autoreload=False)
@@ -2393,8 +2403,13 @@ class WebChannel(ChatChannel):
class RootHandler:
"""Where /chat used to live. The console is at / now, so that the address
bar reads as paths into one app rather than as a page with state after it.
Kept as a redirect because /chat is what older bookmarks, and the startup
banner of any running instance, still point at."""
def GET(self):
raise web.seeother('/chat')
raise web.seeother('/')
class HealthHandler:
@@ -2841,6 +2856,12 @@ class ChatHandler:
# Read per request, not at import: nothing guarantees this module is
# imported after app.py has parsed its arguments.
if os.environ.get('COW_LEGACY_CONSOLE') == '1':
# The snapshot predates routing: it has no router, and it points at
# its scripts relatively, so under a two-segment path like
# /settings/models the browser would look for them beside that path
# and render nothing. Send it back to the one path it works at.
if web.ctx.get('path', '/') != '/':
raise web.seeother('/')
html = _legacy_console_page(str(int(time.time())))
else:
# The shell pulls its layout, views and modals in from templates/;
+4 -3
View File
@@ -35,9 +35,10 @@ def console_js():
page = template.render("chat.html")
parts = []
# The served page stamps each asset with its mtime, so the path is followed
# by a ?v= query rather than the closing quote.
for src in re.findall(r'<script defer src="assets/(js/[^"?]+)(?:\?[^"]*)?"', page):
# Asset URLs are absolute so they resolve the same from every routed path,
# and each carries a ?v= stamp, so the name sits between /assets/ and the
# query rather than between /assets/ and the closing quote.
for src in re.findall(r'<script defer src="/assets/(js/[^"?]+)(?:\?[^"]*)?"', page):
with open(os.path.join(_WEB_DIR, "static", src), encoding="utf-8") as f:
parts.append(f.read())
return "\n".join(parts)
+9 -8
View File
@@ -21,9 +21,10 @@ def _page():
def _scripts(page):
# The served page stamps each asset with its mtime, so the path is followed
# by a ?v= query rather than the closing quote.
return re.findall(r'<script defer src="assets/(js/[^"?]+)(?:\?[^"]*)?"', page)
# Asset URLs are absolute so they resolve the same from every routed path,
# and each carries a ?v= stamp, so the name sits between /assets/ and the
# query rather than between /assets/ and the closing quote.
return re.findall(r'<script defer src="/assets/(js/[^"?]+)(?:\?[^"]*)?"', page)
def test_every_console_script_is_listed_exactly_once_and_exists():
@@ -48,7 +49,7 @@ def test_every_console_script_is_listed_exactly_once_and_exists():
def test_every_stylesheet_is_listed_and_exists():
sheets = re.findall(
r'<link rel="stylesheet" href="assets/(css/[^"?]+)(?:\?[^"]*)?"', _page())
r'<link rel="stylesheet" href="/assets/(css/[^"?]+)(?:\?[^"]*)?"', _page())
absent = [s for s in sheets if not os.path.exists(os.path.join(STATIC, s))]
assert not absent, absent
assert sheets == sorted(set(sheets), key=sheets.index), "a sheet is linked twice"
@@ -148,7 +149,7 @@ def test_the_old_console_flag_is_off_unless_asked_for():
os.environ.pop("COW_LEGACY_CONSOLE", None)
with patch.object(web_channel.web, "header", lambda *a, **k: None):
html = web_channel.ChatHandler().GET()
assert 'src="assets/js/core/i18n.js' in html, value
assert 'src="/assets/js/core/i18n.js' in html, value
assert "assets/legacy/" not in html, value
@@ -215,11 +216,11 @@ def test_an_assets_version_moves_with_the_file_and_not_with_the_clock():
finally:
os.utime(path, (before.st_atime, before.st_mtime))
moved = {ref for ref in re.findall(r'assets/(?:js|css)/[^"\']+', first)}
moved ^= {ref for ref in re.findall(r'assets/(?:js|css)/[^"\']+', after)}
moved = {ref for ref in re.findall(r'/assets/(?:js|css)/[^"\']+', first)}
moved ^= {ref for ref in re.findall(r'/assets/(?:js|css)/[^"\']+', after)}
# Exactly one URL differs: the touched file's, in its old and new form.
assert len(moved) == 2, sorted(moved)
assert all(ref.startswith("assets/" + victim + "?v=") for ref in moved), sorted(moved)
assert all(ref.startswith("/assets/" + victim + "?v=") for ref in moved), sorted(moved)
def test_only_stamped_assets_are_advertised_as_immutable():
+105 -3
View File
@@ -1,11 +1,16 @@
# encoding:utf-8
"""Guard the console's hash routing.
"""Guard the console's path routing.
The address bar is now part of the console's contract: a reload or a shared
link has to land on the view and tab it names. Nothing here fails at build
time, and the failure mode is quiet -- a renamed tab turns an old bookmark
into a dead route, and a tab switcher that stops reporting leaves the URL
pointing somewhere the user is not.
These tests pin the wiring: that the router's vocabulary matches the page's,
and that the backend serves what the router hands out. What the router *does*
once it runs -- which entries land on the history stack -- is checked by
``channel/web/tools/check-router.mjs``.
"""
import os
@@ -30,6 +35,13 @@ def _route_tabs():
for view, tabs in re.findall(r"(\w+):\s*\[([^\]]+)\]", block.group(1))}
def _route_paths():
"""view id -> the path segment it is reached at, read from the router."""
block = re.search(r"const ROUTE_PATHS = \{(.*?)\n\};", _js("core/router.js"), re.S)
assert block, "ROUTE_PATHS is no longer where the tests can read it"
return dict(re.findall(r"(\w+):\s*'([^']*)'", block.group(1)))
def test_the_routable_tabs_are_the_tabs_the_page_actually_has():
"""A route names a tab by the id its element carries. Rename the element
and every link to that tab dies silently -- the router drops the unknown
@@ -71,12 +83,64 @@ def test_the_router_loads_after_the_navigation_it_drives():
"""router.js calls navigateTo and validates against VIEW_META, both of
which live in nav.js."""
page = template.render("chat.html")
scripts = re.findall(r'<script defer src="assets/(js/[^"?]+)(?:\?[^"]*)?"', page)
scripts = re.findall(r'<script defer src="/assets/(js/[^"?]+)(?:\?[^"]*)?"', page)
assert scripts.count("js/core/router.js") == 1, scripts
assert scripts.index("js/core/nav.js") < scripts.index("js/core/router.js")
def _backend_urls():
with open(os.path.join(WEB, "web_channel.py"), encoding="utf-8") as f:
source = f.read()
table = re.search(r"\n urls = \((.*?)\n \)", source, re.S)
assert table, "the URL table is no longer where the tests can read it"
return table.group(1)
def test_the_backend_serves_every_path_the_router_hands_out():
"""The router writes these paths into the address bar, so a reload or a
shared link arrives at the backend asking for one. A path the URL table
does not serve is a 404 on an address the console itself produced."""
served = re.search(r"'/\(\?:([a-z|]+)\)'", _backend_urls())
assert served, "the view-route pattern is no longer where the tests can read it"
served = set(served.group(1).split("|"))
handed_out = {path for path in _route_paths().values() if path}
assert handed_out == served, (sorted(handed_out), sorted(served))
def test_no_view_path_shadows_an_api_route():
"""web.py takes the first match in the table, so a view named after an
existing endpoint would not break visibly -- it would quietly serve HTML
where the console expects JSON, or never reach the view at all. /settings
exists for exactly this reason: /config is the config API."""
urls = _backend_urls()
endpoints = set(re.findall(r"^\s*'(/[^']*)',\s*'\w+Handler'", urls, re.M))
for path in _route_paths().values():
if not path:
continue
assert "/" + path not in endpoints, path
# The collision that forced the naming, pinned so it cannot quietly return.
assert "/config" in endpoints
assert _route_paths()["config"] == "settings"
def test_the_console_answers_at_the_root():
"""The console is the app at /, not a page at /chat with state after it.
/chat stays as a redirect: it is what older bookmarks, and the startup
banner of a running instance, still point at."""
urls = _backend_urls()
assert re.search(r"'/',\s*'ChatHandler'", urls)
assert re.search(r"'/chat',\s*'RootHandler'", urls)
with open(os.path.join(WEB, "web_channel.py"), encoding="utf-8") as f:
source = f.read()
root = source[source.index("class RootHandler:"):]
assert "seeother('/')" in root[:root.index("\n\n\nclass ")]
def test_the_first_route_is_applied_only_once_auth_has_settled():
"""Routing at load time would switch views behind the login overlay, so
the first apply hangs off initApp() -- the one function all three paths
@@ -87,6 +151,44 @@ def test_the_first_route_is_applied_only_once_auth_has_settled():
assert "routeApply()" in init
router = _js("core/router.js")
assert "addEventListener('hashchange', routeApply)" in router
assert "addEventListener('popstate', routeApply)" in router
# A bare call at the top level would run before auth.
assert not re.search(r"^routeApply\(\)", router, re.M)
def test_the_old_console_is_sent_back_to_the_root():
"""`python app.py -old` serves a snapshot that predates routing: no router,
and its scripts referenced relatively. Under /settings/models a browser
would look for them beside that path and render nothing, so the view paths
-- which all reach the same handler -- have to bounce back to /."""
from unittest.mock import patch
import web
import channel.web.web_channel as web_channel
# A handler driven straight from a test has no request context. seeother
# resolves its Location against ctx.home, and raising writes through
# ctx.headers, so both have to stand in for what a live request carries.
fields = ("home", "path", "headers", "status", "output")
try:
with patch.dict(os.environ, {"COW_LEGACY_CONSOLE": "1"}):
for path in ("/settings/models", "/agents", "/knowledge/graph"):
web.ctx.home = "http://testserver"
web.ctx.path = path
web.ctx.headers = []
web.ctx.status = "200 OK"
web.ctx.output = ""
try:
web_channel.ChatHandler().GET()
except web.HTTPError:
# Raising is how web.py hands a redirect back; the status
# and Location it settled on are left on the context.
assert web.ctx.status.startswith("303"), (path, web.ctx.status)
location = dict(web.ctx.headers).get("Location")
assert location == "http://testserver/", (path, location)
else:
raise AssertionError("%s served the snapshot" % path)
finally:
for key in fields:
web.ctx.pop(key, None)