mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-10-26 01:54:36 +08:00
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:
1 parent
5101c0c37c
commit
c2efb0bbaf
17 files changed
+348
-156
No files matched your search
+18
-7
@@ -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
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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);
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user