feat: enhance agent orchestration, knowledge flow and UI refinements

This commit is contained in:
2026-03-29 20:31:13 +08:00
parent d85cb9cf35
commit e0fe3ca623
301 changed files with 1197804 additions and 7863 deletions

View File

@@ -9,17 +9,20 @@
"version": "0.0.0",
"dependencies": {
"@vueuse/core": "^14.2.1",
"3d-force-graph": "^1.79.0",
"axios": "^1.13.6",
"echarts": "^6.0.0",
"element-plus": "^2.13.6",
"lucide-vue-next": "^0.577.0",
"motion": "^12.38.0",
"pinia": "^3.0.4",
"three": "^0.180.0",
"vue": "^3.5.30",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^25.5.0",
"@types/three": "^0.180.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.9.0",
@@ -104,6 +107,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz",
@@ -218,6 +230,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -266,6 +279,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -279,6 +293,13 @@
"node": ">=14"
}
},
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@element-plus/icons-vue": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
@@ -714,6 +735,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tweenjs/tween.js": {
"version": "25.0.0",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
"integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -761,6 +788,7 @@
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/lodash": "*"
}
@@ -771,16 +799,54 @@
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": "*",
"@webgpu/types": "*",
"fflate": "~0.8.2",
"meshoptimizer": "~0.22.0"
}
},
"node_modules/@types/three/node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.5",
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz",
@@ -1194,6 +1260,29 @@
"vue": "^3.5.0"
}
},
"node_modules/@webgpu/types": {
"version": "0.1.69",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
"integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/3d-force-graph": {
"version": "1.79.1",
"resolved": "https://registry.npmjs.org/3d-force-graph/-/3d-force-graph-1.79.1.tgz",
"integrity": "sha512-iscIVt4jWjJ11KEEswgOIOWk8Ew4EFKHRyERJXJ0ouycqzHCtWwb9E5imnxS5rYF1f1IESkFNAfB+h3EkU0Irw==",
"license": "MIT",
"dependencies": {
"accessor-fn": "1",
"kapsule": "^1.16",
"three": ">=0.118 <1",
"three-forcegraph": "1",
"three-render-objects": "^1.35"
},
"engines": {
"node": ">=12"
}
},
"node_modules/abbrev": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/abbrev/-/abbrev-2.0.0.tgz",
@@ -1204,6 +1293,15 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/accessor-fn": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz",
"integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/alien-signals": {
"version": "1.0.13",
"resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz",
@@ -1439,6 +1537,177 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-binarytree": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
"license": "MIT"
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force-3d": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz",
"integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==",
"license": "MIT",
"dependencies": {
"d3-binarytree": "1",
"d3-dispatch": "1 - 3",
"d3-octree": "1",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-octree": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
"integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
"license": "MIT"
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/data-bind-mapper": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/data-bind-mapper/-/data-bind-mapper-1.0.3.tgz",
"integrity": "sha512-QmU3lyEnbENQPo0M1F9BMu4s6cqNNp8iJA+b/HP2sSb7pf3dxwF3+EP1eO69rwBfH9kFJ1apmzrtogAmVt2/Xw==",
"license": "MIT",
"dependencies": {
"accessor-fn": "1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-7.0.0.tgz",
@@ -1721,6 +1990,27 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"dev": true,
"license": "MIT"
},
"node_modules/float-tooltip": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz",
"integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==",
"license": "MIT",
"dependencies": {
"d3-selection": "2 - 3",
"kapsule": "^1.16",
"preact": "10"
},
"engines": {
"node": ">=12"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz",
@@ -1970,6 +2260,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -2060,6 +2359,7 @@
"integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@asamuzakjp/css-color": "^5.0.1",
"@asamuzakjp/dom-selector": "^7.0.3",
@@ -2095,6 +2395,18 @@
}
}
},
"node_modules/kapsule": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz",
"integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==",
"license": "MIT",
"dependencies": {
"lodash-es": "4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -2360,13 +2672,15 @@
"version": "4.17.23",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash-es": {
"version": "4.17.23",
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.23.tgz",
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash-unified": {
"version": "1.0.3",
@@ -2429,6 +2743,13 @@
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/meshoptimizer": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
"integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
"dev": true,
"license": "MIT"
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
@@ -2548,6 +2869,44 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/ngraph.events": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-1.4.0.tgz",
"integrity": "sha512-NeDGI4DSyjBNBRtA86222JoYietsmCXbs8CEB0dZ51Xeh4lhVl1y3wpWLumczvnha8sFQIW4E0vvVWwgmX2mGw==",
"license": "BSD-3-Clause"
},
"node_modules/ngraph.forcelayout": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/ngraph.forcelayout/-/ngraph.forcelayout-3.3.1.tgz",
"integrity": "sha512-MKBuEh1wujyQHFTW57y5vd/uuEOK0XfXYxm3lC7kktjJLRdt/KEKEknyOlc6tjXflqBKEuYBBcu7Ax5VY+S6aw==",
"license": "BSD-3-Clause",
"dependencies": {
"ngraph.events": "^1.0.0",
"ngraph.merge": "^1.0.0",
"ngraph.random": "^1.0.0"
}
},
"node_modules/ngraph.graph": {
"version": "20.1.2",
"resolved": "https://registry.npmjs.org/ngraph.graph/-/ngraph.graph-20.1.2.tgz",
"integrity": "sha512-W/G3GBR3Y5UxMLHTUCPP9v+pbtpzwuAEIqP5oZV+9IwgxAIEZwh+Foc60iPc1idlnK7Zxu0p3puxAyNmDvBd0Q==",
"license": "BSD-3-Clause",
"dependencies": {
"ngraph.events": "^1.4.0"
}
},
"node_modules/ngraph.merge": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/ngraph.merge/-/ngraph.merge-1.0.0.tgz",
"integrity": "sha512-5J8YjGITUJeapsomtTALYsw7rFveYkM+lBj3QiYZ79EymQcuri65Nw3knQtFxQBU1r5iOaVRXrSwMENUPK62Vg==",
"license": "MIT"
},
"node_modules/ngraph.random": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/ngraph.random/-/ngraph.random-1.2.0.tgz",
"integrity": "sha512-4EUeAGbB2HWX9njd6bP6tciN6ByJfoaAvmVL9QTaZSeXrW46eNGA9GajiXiPBbvFqxUWFkEbyo6x5qsACUuVfA==",
"license": "BSD-3-Clause"
},
"node_modules/nopt": {
"version": "7.2.1",
"resolved": "https://registry.npmmirror.com/nopt/-/nopt-7.2.1.tgz",
@@ -2680,6 +3039,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2708,6 +3068,18 @@
}
}
},
"node_modules/polished": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz",
"integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.17.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz",
@@ -2736,6 +3108,16 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/preact": {
"version": "10.29.0",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz",
"integrity": "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/proto-list": {
"version": "1.2.4",
"resolved": "https://registry.npmmirror.com/proto-list/-/proto-list-1.2.4.tgz",
@@ -3040,6 +3422,56 @@
"dev": true,
"license": "MIT"
},
"node_modules/three": {
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT",
"peer": true
},
"node_modules/three-forcegraph": {
"version": "1.43.1",
"resolved": "https://registry.npmjs.org/three-forcegraph/-/three-forcegraph-1.43.1.tgz",
"integrity": "sha512-lQnYPLvR31gb91mF5xHhU0jPHJgBPw9QB23R6poCk8Tgvz8sQtq7wTxwClcPdfKCBbHXsb7FSqK06Osiu1kQ5A==",
"license": "MIT",
"dependencies": {
"accessor-fn": "1",
"d3-array": "1 - 3",
"d3-force-3d": "2 - 3",
"d3-scale": "1 - 4",
"d3-scale-chromatic": "1 - 3",
"data-bind-mapper": "1",
"kapsule": "^1.16",
"ngraph.forcelayout": "3",
"ngraph.graph": "20",
"tinycolor2": "1"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"three": ">=0.118.3"
}
},
"node_modules/three-render-objects": {
"version": "1.40.5",
"resolved": "https://registry.npmjs.org/three-render-objects/-/three-render-objects-1.40.5.tgz",
"integrity": "sha512-iA+rYdal0tkond37YeXIvEMAxUFGxw1wU6+ce/GsuiOUKL+8zaxFXY7PTVft0F+Km50mbmtKQ24b2FdwSG3p3A==",
"license": "MIT",
"dependencies": {
"@tweenjs/tween.js": "18 - 25",
"accessor-fn": "1",
"float-tooltip": "^1.7",
"kapsule": "^1.16",
"polished": "4"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"three": ">=0.168"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz",
@@ -3047,6 +3479,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/tinycolor2": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.0.4.tgz",
@@ -3142,6 +3580,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -3173,6 +3612,7 @@
"integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.3",
@@ -3339,6 +3779,7 @@
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.30.tgz",
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-sfc": "3.5.30",

View File

@@ -10,6 +10,7 @@
"test": "vitest run"
},
"dependencies": {
"3d-force-graph": "^1.79.0",
"@vueuse/core": "^14.2.1",
"axios": "^1.13.6",
"echarts": "^6.0.0",
@@ -17,11 +18,13 @@
"lucide-vue-next": "^0.577.0",
"motion": "^12.38.0",
"pinia": "^3.0.4",
"three": "^0.180.0",
"vue": "^3.5.30",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^25.5.0",
"@types/three": "^0.180.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.9.0",

View File

@@ -0,0 +1,866 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>J.A.R.V.I.S. - Neural Command Center</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700;900&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
:root {
--jarvis-blue: #00f3ff;
--jarvis-blue-dim: rgba(0, 243, 255, 0.1);
--bg-color: #020408;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background-color: var(--bg-color);
color: var(--jarvis-blue);
font-family: 'Orbitron', 'Share Tech Mono', monospace;
overflow: hidden;
margin: 0;
height: 100vh;
width: 100vw;
}
.scanlines {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 100;
background: linear-gradient(to bottom,
rgba(255, 255, 255, 0),
rgba(255, 255, 255, 0) 50%,
rgba(0, 0, 0, 0.08) 50%,
rgba(0, 0, 0, 0.08));
background-size: 100% 3px;
}
.vignette {
position: fixed;
inset: 0;
background: radial-gradient(circle, transparent 40%, rgba(0, 0, 0, 0.8) 100%);
pointer-events: none;
z-index: 90;
}
.text-glow {
text-shadow: 0 0 8px var(--jarvis-blue), 0 0 15px rgba(0, 243, 255, 0.5);
}
.tech-panel {
background: rgba(0, 12, 28, 0.75);
border: 1px solid rgba(0, 243, 255, 0.2);
position: relative;
backdrop-filter: blur(8px);
transition: all 0.3s ease;
}
.tech-panel::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 10px;
height: 10px;
border-top: 1px solid var(--jarvis-blue);
border-left: 1px solid var(--jarvis-blue);
}
.tech-panel::after {
content: '';
position: absolute;
bottom: 0;
right: 0;
width: 10px;
height: 10px;
border-bottom: 1px solid var(--jarvis-blue);
border-right: 1px solid var(--jarvis-blue);
}
@keyframes pulse-status {
0%, 100% { opacity: 0.7; text-shadow: 0 0 5px var(--jarvis-blue); }
50% { opacity: 1; text-shadow: 0 0 15px var(--jarvis-blue), 0 0 30px rgba(0, 243, 255, 0.3); }
}
.status-pulse {
animation: pulse-status 2s infinite ease-in-out;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes typing-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-4px); opacity: 1; }
}
.typing-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--jarvis-blue);
margin: 0 3px;
animation: typing-bounce 1.2s ease-in-out infinite;
}
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes msg-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.msg-animate {
animation: msg-in 0.3s ease both;
}
#brain-canvas {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1;
opacity: 0.7;
}
.chat-panel {
position: relative;
z-index: 10;
background: rgba(0, 8, 20, 0.85);
}
.msg-bubble {
background: rgba(0, 30, 50, 0.6);
border: 1px solid rgba(0, 243, 255, 0.15);
padding: 10px 14px;
border-radius: 4px 12px 12px 12px;
max-width: 85%;
line-height: 1.6;
font-size: 13px;
}
.msg-bubble.user {
background: rgba(0, 243, 255, 0.08);
border-color: rgba(0, 243, 255, 0.25);
border-radius: 12px 4px 12px 12px;
}
.input-holo {
background: rgba(0, 15, 30, 0.7);
border: 1px solid rgba(0, 243, 255, 0.3);
padding: 12px 16px;
border-radius: 8px;
width: 100%;
color: #e0f7ff;
font-family: 'Share Tech Mono', monospace;
font-size: 13px;
outline: none;
transition: all 0.3s;
}
.input-holo:focus {
border-color: rgba(0, 243, 255, 0.6);
box-shadow: 0 0 20px rgba(0, 243, 255, 0.15);
}
.input-holo::placeholder {
color: rgba(0, 243, 255, 0.4);
}
.send-btn {
background: rgba(0, 243, 255, 0.1);
border: 1px solid rgba(0, 243, 255, 0.3);
color: var(--jarvis-blue);
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
font-family: 'Orbitron', monospace;
font-size: 11px;
letter-spacing: 0.1em;
transition: all 0.3s;
}
.send-btn:hover {
background: rgba(0, 243, 255, 0.2);
box-shadow: 0 0 20px rgba(0, 243, 255, 0.3);
}
.send-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
box-shadow: 0 0 8px currentColor;
}
.node-item {
padding: 6px 10px;
border-bottom: 1px solid rgba(0, 243, 255, 0.08);
cursor: pointer;
transition: all 0.2s;
font-size: 10px;
}
.node-item:hover {
background: rgba(0, 243, 255, 0.05);
}
.node-item.selected {
background: rgba(0, 243, 255, 0.1);
border-left: 2px solid var(--jarvis-blue);
}
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: rgba(0, 0, 0, 0.3); }
::-webkit-scrollbar-thumb { background: rgba(0, 243, 255, 0.4); border-radius: 2px; }
.metric-bar {
height: 3px;
background: rgba(0, 243, 255, 0.2);
border-radius: 2px;
overflow: hidden;
}
.metric-fill {
height: 100%;
background: var(--jarvis-blue);
box-shadow: 0 0 8px var(--jarvis-blue);
transition: width 0.5s ease-out;
}
</style>
</head>
<body>
<div class="scanlines"></div>
<div class="vignette"></div>
<div class="relative z-30 h-full w-full flex flex-col">
<!-- HEADER -->
<header class="flex justify-between items-center px-6 py-3 border-b border-cyan-500/20 bg-black/40 backdrop-blur-md">
<div class="flex items-center gap-4">
<div class="flex flex-col items-center">
<div class="w-2 h-2 bg-cyan-400 rounded-full shadow-[0_0_10px_#00f3ff]"></div>
<div class="w-px h-6 bg-cyan-500/50 my-1"></div>
</div>
<div>
<h1 class="text-2xl font-bold tracking-[0.2em] text-glow">J.A.R.V.I.S.</h1>
<div class="flex items-center gap-2 mt-1">
<div class="h-1 w-16 bg-cyan-500/30 overflow-hidden rounded">
<div class="h-full bg-cyan-400 w-1/3 animate-pulse"></div>
</div>
<span class="text-[9px] tracking-[0.3em] opacity-60">NEURAL COMMAND v2.0</span>
</div>
</div>
</div>
<div class="flex items-center gap-6">
<div class="flex items-center gap-2">
<span class="text-[10px] tracking-widest opacity-60">BRAIN MATRIX</span>
<span id="brain-status" class="text-xs font-bold px-3 py-1 rounded status-pulse bg-cyan-900/40 border border-cyan-500/30">ONLINE</span>
</div>
<div class="text-right">
<div class="text-[10px] opacity-50">SYS_TIME</div>
<div id="time-display" class="font-mono text-lg text-cyan-200 tracking-wider">00:00:00</div>
</div>
</div>
</header>
<!-- MAIN CONTENT -->
<div class="flex-grow flex relative">
<!-- BRAIN CANVAS BACKGROUND -->
<div id="brain-canvas" class="absolute inset-0 pointer-events-none"></div>
<!-- LEFT PANEL: Brain Metrics -->
<aside class="w-64 p-4 flex flex-col gap-4 relative z-20 bg-gradient-to-b from-black/60 to-transparent">
<div class="tech-panel p-4">
<h3 class="text-xs font-bold tracking-widest text-cyan-300 mb-3 border-b border-cyan-500/20 pb-2">NODE REGISTRY</h3>
<div id="node-list" class="max-h-40 overflow-y-auto space-y-1">
<!-- nodes injected -->
</div>
</div>
<div class="tech-panel p-4">
<h3 class="text-xs font-bold tracking-widest text-cyan-300 mb-3 border-b border-cyan-500/20 pb-2">BRAIN METRICS</h3>
<div class="space-y-3 text-[10px]">
<div>
<div class="flex justify-between mb-1"><span class="opacity-60">NODES</span><span id="m-nodes" class="text-cyan-300">14</span></div>
<div class="metric-bar"><div id="bar-nodes" class="metric-fill" style="width: 70%"></div></div>
</div>
<div>
<div class="flex justify-between mb-1"><span class="opacity-60">LINKS</span><span id="m-links" class="text-cyan-300">20</span></div>
<div class="metric-bar"><div id="bar-links" class="metric-fill" style="width: 66%"></div></div>
</div>
<div>
<div class="flex justify-between mb-1"><span class="opacity-60">CLUSTERS</span><span id="m-clusters" class="text-cyan-300">3</span></div>
<div class="metric-bar"><div id="bar-clusters" class="metric-fill" style="width: 60%"></div></div>
</div>
</div>
</div>
<div class="tech-panel p-4 flex-grow">
<h3 class="text-xs font-bold tracking-widest text-cyan-300 mb-3 border-b border-cyan-500/20 pb-2">SIGNAL LEGEND</h3>
<div class="space-y-2 text-[11px]">
<div class="flex items-center gap-2">
<div class="legend-dot" style="background: #00f3ff; color: #00f3ff;"></div>
<span class="text-cyan-300">KNOWLEDGE</span>
</div>
<div class="flex items-center gap-2">
<div class="legend-dot" style="background: #ff6b9d; color: #ff6b9d;"></div>
<span class="text-cyan-300">CHAT</span>
</div>
<div class="flex items-center gap-2">
<div class="legend-dot" style="background: #a855f7; color: #a855f7;"></div>
<span class="text-cyan-300">FORUM</span>
</div>
<div class="flex items-center gap-2">
<div class="legend-dot" style="background: #fbbf24; color: #fbbf24;"></div>
<span class="text-cyan-300">SCHEDULE</span>
</div>
</div>
</div>
</aside>
<!-- CENTER: Brain Activity (clickable area) -->
<div class="flex-grow relative z-10 flex flex-col justify-end pointer-events-none">
<div class="absolute inset-0 flex items-center justify-center pointer-events-auto" id="brain-click-zone">
<!-- Brain visualization is rendered here by Three.js -->
</div>
<!-- Terminal Log at bottom -->
<div class="tech-panel m-4 p-3 pointer-events-auto max-h-40 overflow-y-auto">
<div class="text-[10px] text-cyan-500/70 tracking-widest mb-2">// SYSTEM LOG</div>
<div id="terminal-log" class="space-y-1 text-[10px] font-mono">
<div class="text-cyan-400/60">>> Neural matrix initialized.</div>
<div class="text-cyan-400/60">>> 14 nodes, 20 connections active.</div>
</div>
</div>
</div>
<!-- RIGHT PANEL: Chat -->
<aside class="w-96 p-4 flex flex-col gap-4 relative z-20 bg-gradient-to-l from-black/60 to-transparent">
<div class="tech-panel p-4 flex-grow flex flex-col overflow-hidden">
<div class="flex justify-between items-center mb-3 border-b border-cyan-500/20 pb-2">
<h3 class="text-xs font-bold tracking-widest text-cyan-300">NEURAL CHAT</h3>
<span class="text-[9px] bg-cyan-500/20 px-2 py-1 rounded status-pulse">LIVE</span>
</div>
<div id="chat-messages" class="flex-grow overflow-y-auto space-y-3 mb-4 pr-1">
<!-- Welcome -->
<div class="text-center py-8">
<div class="text-3xl font-bold tracking-[0.3em] text-glow mb-2">JARVIS</div>
<div class="text-[10px] tracking-[0.2em] opacity-50">STRATEGIC THINKING PARTNER</div>
<div class="text-xs opacity-40 mt-4">有什么我可以帮您分析的?</div>
</div>
</div>
<!-- Chat Input -->
<div class="flex gap-2">
<input type="text" id="chat-input" class="input-holo flex-grow" placeholder="输入指令..." onkeydown="if(event.key==='Enter')sendMsg()">
<button id="send-btn" class="send-btn" onclick="sendMsg()">SEND</button>
</div>
</div>
<!-- Selected Node Detail -->
<div id="node-detail" class="tech-panel p-4 hidden">
<div class="flex justify-between items-center mb-2 border-b border-cyan-500/20 pb-2">
<h3 class="text-xs font-bold tracking-widest text-cyan-300">SELECTED NODE</h3>
<button onclick="clearSelection()" class="text-cyan-500/60 hover:text-cyan-300 text-lg">×</button>
</div>
<div id="detail-content" class="text-[11px] space-y-2">
<!-- detail injected -->
</div>
</div>
</aside>
</div>
</div>
<script>
(function() {
const NODES = [
{ id: 'core-1', name: 'Orchestrator Prime', type: 'knowledge', importance: 0.98, description: '中枢编排核心' },
{ id: 'core-2', name: 'Memory Vault', type: 'knowledge', importance: 0.91, description: '知识存储层' },
{ id: 'chat-1', name: 'Session Cluster', type: 'chat', importance: 0.89, description: '实时会话汇聚' },
{ id: 'chat-2', name: 'Operator Feed', type: 'chat', importance: 0.76, description: '操控台输入流' },
{ id: 'forum-1', name: 'Research Swarm', type: 'forum', importance: 0.81, description: '外部案例趋势池' },
{ id: 'forum-2', name: 'Threat Monitor', type: 'forum', importance: 0.72, description: '异常风险观察哨' },
{ id: 'sched-1', name: 'Sprint Grid', type: 'schedule', importance: 0.87, description: '执行排期矩阵' },
{ id: 'sched-2', name: 'Review Window', type: 'schedule', importance: 0.74, description: '评审验收窗口' },
{ id: 'bridge-1', name: 'Signal Bridge', type: 'knowledge', importance: 0.84, description: '跨域桥接节点' },
{ id: 'arch-1', name: 'Knowledge Archive', type: 'knowledge', importance: 0.66, description: '知识存档' },
{ id: 'thread-1', name: 'Conversation Thread', type: 'chat', importance: 0.62, description: '对话线程' },
{ id: 'signal-1', name: 'Forum Signal', type: 'forum', importance: 0.58, description: '外部论坛信号' },
{ id: 'lane-1', name: 'Schedule Lane', type: 'schedule', importance: 0.57, description: '排程支线' },
{ id: 'dormant-1', name: 'Dormant Trace', type: 'forum', importance: 0.45, description: '低优先级线索' },
];
const EDGES = [
{ id: 'e1', source: 'core-1', target: 'core-2', relation: '读取知识' },
{ id: 'e2', source: 'core-1', target: 'chat-1', relation: '接收会话' },
{ id: 'e3', source: 'core-1', target: 'sched-1', relation: '下发编排' },
{ id: 'e4', source: 'core-1', target: 'bridge-1', relation: '稳定桥接' },
{ id: 'e5', source: 'core-2', target: 'bridge-1', relation: '提供事实' },
{ id: 'e6', source: 'chat-1', target: 'chat-2', relation: '扩展输入' },
{ id: 'e7', source: 'chat-1', target: 'bridge-1', relation: '触发同步' },
{ id: 'e8', source: 'forum-1', target: 'bridge-1', relation: '补充趋势' },
{ id: 'e9', source: 'forum-2', target: 'bridge-1', relation: '反馈风险' },
{ id: 'e10', source: 'bridge-1', target: 'sched-1', relation: '投递任务' },
{ id: 'e11', source: 'sched-1', target: 'sched-2', relation: '进入评审' },
{ id: 'e12', source: 'forum-1', target: 'sched-2', relation: '支撑验证' },
{ id: 'e13', source: 'core-2', target: 'arch-1', relation: '归档事实' },
{ id: 'e14', source: 'bridge-1', target: 'arch-1', relation: '桥接引用' },
{ id: 'e15', source: 'chat-1', target: 'thread-1', relation: '拆分会话' },
{ id: 'e16', source: 'chat-2', target: 'thread-1', relation: '补充回执' },
{ id: 'e17', source: 'forum-1', target: 'signal-1', relation: '吸收案例' },
{ id: 'e18', source: 'sched-1', target: 'lane-1', relation: '拆分排期' },
{ id: 'e19', source: 'sched-2', target: 'lane-1', relation: '投递评审' },
{ id: 'e20', source: 'forum-2', target: 'dormant-1', relation: '弱风险' },
];
const TYPE_COLORS = { knowledge: 0x00f3ff, chat: 0xff6b9d, forum: 0xa855f7, schedule: 0xfbbf24 };
const TYPE_LABELS = { knowledge: '知识库', chat: '对话', forum: '论坛', schedule: '日程' };
let scene, camera, renderer;
let nodeObjects = new Map();
let particleSystem, coreMesh, innerMesh;
let hoveredNodeId = null;
let selectedNodeId = null;
let mouseX = 0, mouseY = 0;
let targetRotX = 0, targetRotY = 0;
let windowHalfX = window.innerWidth / 2;
let windowHalfY = window.innerHeight / 2;
const nodeMeshes = [];
function updateTime() {
const now = new Date();
const el = document.getElementById('time-display');
if (el) el.textContent = now.toLocaleTimeString('en-US', { hour12: false });
}
setInterval(updateTime, 1000);
updateTime();
function init() {
const container = document.getElementById('brain-canvas');
const w = window.innerWidth;
const h = window.innerHeight;
scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x020408, 0.003);
camera = new THREE.PerspectiveCamera(55, w / h, 0.1, 2000);
camera.position.z = 70;
renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
renderer.setSize(w, h);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.3;
container.style.width = w + 'px';
container.style.height = h + 'px';
container.appendChild(renderer.domElement);
const ambient = new THREE.AmbientLight(0x404060, 0.5);
scene.add(ambient);
const point = new THREE.PointLight(0x00f3ff, 2, 250);
point.position.set(0, 30, 50);
scene.add(point);
createBackground();
createBrainCore();
createNodes();
createEdges();
createOrbitalRings();
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('wheel', onWheel);
window.addEventListener('resize', onResize);
updateNodeList();
animate();
}
function createBackground() {
const count = 1200;
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
positions[i * 3] = (Math.random() - 0.5) * 500;
positions[i * 3 + 1] = (Math.random() - 0.5) * 500;
positions[i * 3 + 2] = (Math.random() - 0.5) * 500;
const c = new THREE.Color();
c.setHSL(0.55 + Math.random() * 0.1, 0.8, 0.5 + Math.random() * 0.3);
colors[i * 3] = c.r;
colors[i * 3 + 1] = c.g;
colors[i * 3 + 2] = c.b;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const mat = new THREE.PointsMaterial({
size: 0.4, vertexColors: true, transparent: true, opacity: 0.5,
blending: THREE.AdditiveBlending,
});
particleSystem = new THREE.Points(geo, mat);
scene.add(particleSystem);
}
function createBrainCore() {
coreMesh = new THREE.Mesh(
new THREE.IcosahedronGeometry(3, 2),
new THREE.MeshBasicMaterial({ color: 0x00f3ff, wireframe: true, transparent: true, opacity: 0.1 })
);
scene.add(coreMesh);
innerMesh = new THREE.Mesh(
new THREE.IcosahedronGeometry(1.8, 1),
new THREE.MeshBasicMaterial({ color: 0x00f3ff, transparent: true, opacity: 0.15 })
);
scene.add(innerMesh);
}
function createHexShape(r) {
const shape = new THREE.Shape();
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2 - Math.PI / 6;
const x = Math.cos(a) * r, y = Math.sin(a) * r;
if (i === 0) shape.moveTo(x, y); else shape.lineTo(x, y);
}
shape.closePath();
return shape;
}
function createNodes() {
const angleStep = (Math.PI * 2) / NODES.length;
const radius = 28;
NODES.forEach((node, i) => {
const angle = i * angleStep;
const layer = Math.floor(i / 7);
const r = radius + layer * 16;
const x = Math.cos(angle) * r;
const z = Math.sin(angle) * r;
const y = (Math.random() - 0.5) * 6 + layer * 3;
const color = TYPE_COLORS[node.type];
const imp = node.importance;
const height = 2.5 + imp * 10;
const r2 = 1.2 + imp * 1.2;
const group = new THREE.Group();
group.userData = { nodeId: node.id };
group.position.set(x, y, z);
const geo = new THREE.ExtrudeGeometry(createHexShape(r2), {
depth: height, bevelEnabled: true, bevelThickness: 0.15, bevelSize: 0.1, bevelSegments: 1,
});
geo.center();
const mat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.1, side: THREE.DoubleSide });
const mesh = new THREE.Mesh(geo, mat);
mesh.rotation.x = -Math.PI / 2;
group.add(mesh);
const edgesGeo = new THREE.EdgesGeometry(geo);
const edgesMat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.7 });
const edges = new THREE.LineSegments(edgesGeo, edgesMat);
edges.rotation.x = -Math.PI / 2;
group.add(edges);
const glowGeo = new THREE.SphereGeometry(r2 * 0.5, 10, 10);
const glowMat = new THREE.MeshBasicMaterial({
color, transparent: true, opacity: 0.25 + imp * 0.4, blending: THREE.AdditiveBlending,
});
const glow = new THREE.Mesh(glowGeo, glowMat);
group.add(glow);
nodeMeshes.push(glow);
const topGeo = new THREE.CircleGeometry(r2 * 0.35, 6);
const topMat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.6, side: THREE.DoubleSide });
const top = new THREE.Mesh(topGeo, topMat);
top.rotation.x = -Math.PI / 2;
top.position.y = height / 2;
group.add(top);
group.rotation.y = Math.random() * Math.PI;
scene.add(group);
nodeObjects.set(node.id, group);
});
}
function createEdges() {
EDGES.forEach(edge => {
const src = nodeObjects.get(edge.source);
const tgt = nodeObjects.get(edge.target);
if (!src || !tgt) return;
const srcNode = NODES.find(n => n.id === edge.source);
const color = TYPE_COLORS[srcNode?.type || 'knowledge'];
const points = [src.position.clone(), tgt.position.clone()];
const geo = new THREE.BufferGeometry().setFromPoints(points);
const mat = new THREE.LineDashedMaterial({
color, transparent: true, opacity: 0.35,
dashSize: 1.2, gapSize: 0.8, blending: THREE.AdditiveBlending,
});
const line = new THREE.Line(geo, mat);
line.computeLineDistances();
scene.add(line);
});
}
function createOrbitalRings() {
const ring1Geo = new THREE.BufferGeometry();
const ring1Pos = new Float32Array(60 * 3);
for (let i = 0; i < 60; i++) {
const t = (i / 60) * Math.PI * 2;
ring1Pos[i * 3] = Math.cos(t) * 40;
ring1Pos[i * 3 + 1] = (Math.random() - 0.5) * 0.4;
ring1Pos[i * 3 + 2] = Math.sin(t) * 40;
}
ring1Geo.setAttribute('position', new THREE.BufferAttribute(ring1Pos, 3));
const ring1 = new THREE.Points(ring1Geo, new THREE.PointsMaterial({ color: 0x00f3ff, size: 0.25, transparent: true, opacity: 0.35 }));
ring1.userData = { sx: 0.0008, sy: 0.0015 };
scene.add(ring1);
const ring2Geo = new THREE.BufferGeometry();
const ring2Pos = new Float32Array(50 * 3);
for (let i = 0; i < 50; i++) {
const t = (i / 50) * Math.PI * 2;
ring2Pos[i * 3] = (Math.random() - 0.5) * 0.4;
ring2Pos[i * 3 + 1] = Math.cos(t) * 45;
ring2Pos[i * 3 + 2] = Math.sin(t) * 45;
}
ring2Geo.setAttribute('position', new THREE.BufferAttribute(ring2Pos, 3));
const ring2 = new THREE.Points(ring2Geo, new THREE.PointsMaterial({ color: 0xa855f7, size: 0.2, transparent: true, opacity: 0.25 }));
ring2.userData = { sx: 0.0015, sy: 0.0008 };
scene.add(ring2);
}
function onMouseMove(e) {
mouseX = e.clientX - windowHalfX;
mouseY = e.clientY - windowHalfY;
targetRotY = mouseX * 0.0004;
targetRotX = -mouseY * 0.0004;
}
function onWheel(e) {
camera.position.z += e.deltaY * 0.04;
camera.position.z = Math.max(35, Math.min(180, camera.position.z));
}
function onResize() {
const w = window.innerWidth;
const h = window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
const container = document.getElementById('brain-canvas');
container.style.width = w + 'px';
container.style.height = h + 'px';
}
function animate() {
requestAnimationFrame(animate);
const t = Date.now() * 0.001;
if (coreMesh) {
coreMesh.rotation.y += 0.001;
coreMesh.rotation.x -= 0.002;
}
if (innerMesh) {
innerMesh.rotation.y -= 0.002;
innerMesh.rotation.z += 0.001;
}
scene.rotation.y += (targetRotY - scene.rotation.y) * 0.03;
scene.rotation.x += (targetRotX - scene.rotation.x) * 0.03;
if (particleSystem) {
particleSystem.rotation.y += 0.00008;
particleSystem.rotation.x += 0.00004;
}
nodeMeshes.forEach((m, i) => {
const p = Math.sin(t * 1.8 + i * 0.4) * 0.12;
m.scale.setScalar(1 + p);
});
scene.children.forEach(c => {
if (c instanceof THREE.Points && c.userData.sx) {
c.rotation.x += c.userData.sx;
c.rotation.y += c.userData.sy;
}
});
renderer.render(scene, camera);
}
function updateNodeList() {
const list = document.getElementById('node-list');
list.innerHTML = '';
NODES.forEach(node => {
const color = '#' + TYPE_COLORS[node.type].toString(16).padStart(6, '0');
const div = document.createElement('div');
div.className = 'node-item flex items-center gap-2';
div.innerHTML = `
<div class="w-2 h-2 rounded-full flex-shrink-0" style="background: ${color}; box-shadow: 0 0 6px ${color};"></div>
<span class="text-cyan-300 truncate flex-grow">${node.name}</span>
<span class="opacity-50">${Math.round(node.importance * 100)}%</span>
`;
div.onclick = () => selectNode(node.id);
list.appendChild(div);
});
}
function selectNode(nodeId) {
selectedNodeId = nodeId;
const node = NODES.find(n => n.id === nodeId);
if (!node) return;
document.querySelectorAll('.node-item').forEach(el => el.classList.remove('selected'));
const items = document.querySelectorAll('.node-item');
const idx = NODES.findIndex(n => n.id === nodeId);
if (items[idx]) items[idx].classList.add('selected');
const color = '#' + TYPE_COLORS[node.type].toString(16).padStart(6, '0');
const detail = document.getElementById('detail-content');
const labels = { knowledge: '知识库', chat: '对话', forum: '论坛', schedule: '日程' };
detail.innerHTML = `
<div class="text-center mb-2">
<div class="text-[9px] opacity-50 uppercase tracking-widest" style="color: ${color}">${labels[node.type]}</div>
<div class="text-sm font-bold text-cyan-100 tracking-wider">${node.name}</div>
</div>
<div class="flex justify-between"><span class="opacity-60">重要性</span><span class="text-cyan-300">${Math.round(node.importance * 100)}%</span></div>
<div class="opacity-60">描述</div>
<div class="text-cyan-300/80 pl-2 border-l border-cyan-500/30">${node.description}</div>
<div class="mt-2">
<div class="opacity-60 mb-1">关联链路</div>
${EDGES.filter(e => e.source === nodeId || e.target === nodeId).map(e => {
const otherId = e.source === nodeId ? e.target : e.source;
const other = NODES.find(n => n.id === otherId);
return `<div class="text-[9px] text-cyan-500/70">${e.relation}${other?.name || ''}</div>`;
}).join('')}
</div>
`;
document.getElementById('node-detail').classList.remove('hidden');
addLog('>> Node selected: ' + node.name);
}
window.clearSelection = function() {
selectedNodeId = null;
document.querySelectorAll('.node-item').forEach(el => el.classList.remove('selected'));
document.getElementById('node-detail').classList.add('hidden');
};
function addLog(msg) {
const log = document.getElementById('terminal-log');
if (!log) return;
const div = document.createElement('div');
div.className = 'text-cyan-400/60';
div.textContent = '>> ' + msg;
log.appendChild(div);
log.scrollTop = log.scrollHeight;
while (log.children.length > 20) log.removeChild(log.firstChild);
}
window.sendMsg = function() {
const input = document.getElementById('chat-input');
const val = input.value.trim();
if (!val) return;
const messages = document.getElementById('chat-messages');
// User msg
const userDiv = document.createElement('div');
userDiv.className = 'flex justify-end msg-animate';
userDiv.innerHTML = `<div class="msg-bubble user">${escapeHtml(val)}</div>`;
messages.appendChild(userDiv);
input.value = '';
// Simulate Jarvis response
setTimeout(() => {
const respDiv = document.createElement('div');
respDiv.className = 'flex justify-start msg-animate';
respDiv.innerHTML = `<div class="msg-bubble">正在分析您的请求...</div>`;
messages.appendChild(respDiv);
messages.scrollTop = messages.scrollHeight;
setTimeout(() => {
respDiv.querySelector('.msg-bubble').innerHTML = `我已经分析了这个问题。基于当前知识图谱的相关节点:<br><br>
<span class="text-cyan-400">• Signal Bridge</span> 跨域桥接节点正在协调<br>
<span class="text-cyan-400">• Sprint Grid</span> 执行排期矩阵已就绪<br><br>
需要我展开某个节点的详细信息吗?`;
addLog('Response generated for: ' + val.substring(0, 30) + '...');
}, 1200);
}, 500);
messages.scrollTop = messages.scrollHeight;
addLog('User input: ' + val.substring(0, 40));
};
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Raycasting for node hover
document.addEventListener('mousemove', (e) => {
const canvas = renderer.domElement;
const rect = canvas.getBoundingClientRect();
if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) return;
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera({ x, y }, camera);
const hits = raycaster.intersectObjects(scene.children, true);
let found = null;
for (const hit of hits) {
let obj = hit.object;
while (obj) {
if (obj.userData?.nodeId) { found = obj.userData.nodeId; break; }
obj = obj.parent;
}
if (found) break;
}
hoveredNodeId = found;
canvas.style.cursor = found ? 'pointer' : 'grab';
});
document.addEventListener('click', (e) => {
const canvas = renderer.domElement;
const rect = canvas.getBoundingClientRect();
if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) {
if (hoveredNodeId) selectNode(hoveredNodeId);
}
});
init();
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,831 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>J.A.R.V.I.S. - 知识脑图</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700;900&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
:root {
--jarvis-blue: #00f3ff;
--jarvis-blue-dim: rgba(0, 243, 255, 0.1);
--jarvis-alert: #ff3333;
--bg-color: #020408;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background-color: var(--bg-color);
color: var(--jarvis-blue);
font-family: 'Orbitron', sans-serif;
overflow: hidden;
margin: 0;
height: 100vh;
width: 100vw;
}
body::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: linear-gradient(to bottom,
rgba(255, 255, 255, 0),
rgba(255, 255, 255, 0) 50%,
rgba(0, 0, 0, 0.1) 50%,
rgba(0, 0, 0, 0.1));
background-size: 100% 3px;
pointer-events: none;
z-index: 50;
}
.vignette {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle, transparent 50%, rgba(0, 0, 0, 0.95) 100%);
pointer-events: none;
z-index: 40;
}
.text-glow {
text-shadow: 0 0 5px var(--jarvis-blue), 0 0 10px var(--jarvis-blue);
}
.tech-panel {
background: rgba(0, 15, 30, 0.7);
border-left: 1px solid rgba(0, 243, 255, 0.3);
border-right: 1px solid rgba(0, 243, 255, 0.3);
position: relative;
backdrop-filter: blur(4px);
transition: all 0.3s ease;
}
.tech-panel:hover {
border-color: rgba(0, 243, 255, 0.8);
background: rgba(0, 20, 40, 0.8);
}
.tech-panel::before,
.tech-panel::after {
content: '';
position: absolute;
width: 8px;
height: 8px;
border: 1px solid var(--jarvis-blue);
transition: all 0.3s;
}
.tech-panel::before {
top: -1px;
left: -1px;
border-right: none;
border-bottom: none;
}
.tech-panel::after {
bottom: -1px;
right: -1px;
border-left: none;
border-top: none;
}
.tech-border {
clip-path: polygon(10px 0,
100% 0,
100% calc(100% - 10px),
calc(100% - 10px) 100%,
0 100%,
0 10px);
border-top: 1px solid var(--jarvis-blue-dim);
}
@keyframes pulse-status {
0%, 100% { opacity: 0.6; text-shadow: 0 0 2px var(--jarvis-blue); box-shadow: 0 0 5px var(--jarvis-blue-dim); }
50% { opacity: 1; text-shadow: 0 0 15px var(--jarvis-blue); box-shadow: 0 0 15px var(--jarvis-blue-dim); }
}
.status-pulse {
animation: pulse-status 3s infinite ease-in-out;
}
@keyframes flash-row {
0% { background-color: transparent; }
50% { background-color: rgba(0, 243, 255, 0.15); }
100% { background-color: transparent; }
}
.flash-row:hover {
animation: flash-row 0.8s infinite;
cursor: pointer;
background: rgba(0, 243, 255, 0.05);
}
#brain-canvas-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
}
#brain-canvas-container canvas {
pointer-events: auto;
cursor: grab;
}
#brain-canvas-container canvas:active {
cursor: grabbing;
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
box-shadow: 0 0 6px currentColor;
}
.detail-panel {
transform: translateX(100%);
transition: transform 0.3s ease;
}
.detail-panel.active {
transform: translateX(0);
}
</style>
</head>
<body>
<div class="vignette"></div>
<div class="relative z-30 h-full w-full flex flex-col p-4 md:p-6 pointer-events-none">
<header class="flex justify-between items-start pointer-events-auto mb-2 border-b border-cyan-500/20 pb-2">
<div class="flex items-center gap-4">
<div class="flex flex-col items-center justify-center">
<div class="w-2 h-2 bg-cyan-400 rounded-full mb-1"></div>
<div class="h-8 w-px bg-cyan-500/50"></div>
</div>
<div>
<h1 class="text-4xl font-bold tracking-[0.15em] text-glow font-orbitron">J.A.R.V.I.S.</h1>
<div class="flex items-center gap-2 mt-1">
<div class="h-1 w-20 bg-cyan-500/30 overflow-hidden">
<div class="h-full bg-cyan-400 w-1/2 animate-pulse"></div>
</div>
<div class="text-[9px] tracking-[0.3em] opacity-70">KNOWLEDGE BRAIN V2.0</div>
</div>
</div>
</div>
<div class="text-right">
<div class="flex items-center justify-end gap-3 mb-1">
<span class="text-[10px] tracking-widest opacity-60">BRAIN STATUS</span>
<span id="brain-status" class="font-bold text-xs status-pulse bg-cyan-900/40 px-3 py-1 rounded-sm border border-cyan-500/30">INITIALIZING</span>
</div>
<div class="flex justify-end items-baseline gap-2">
<span class="text-[10px] opacity-50">SYS_TIME</span>
<div id="time-display" class="font-mono text-xl tracking-wider text-cyan-200">00:00:00</div>
</div>
</div>
</header>
<div class="flex-grow grid grid-cols-1 md:grid-cols-4 gap-6 pointer-events-auto h-0 min-h-0 mt-2">
<div class="flex flex-col gap-4 h-full overflow-hidden">
<div class="tech-panel p-4 tech-border">
<h3 class="text-xs font-bold tracking-widest border-b border-cyan-500/30 pb-2 mb-3 text-cyan-300">
NODE REGISTRY</h3>
<div class="space-y-2 font-mono text-[10px] max-h-48 overflow-y-auto pr-1">
<div id="node-list"></div>
</div>
</div>
<div class="tech-panel p-4 tech-border">
<h3 class="text-xs font-bold tracking-widest border-b border-cyan-500/30 pb-2 mb-3 text-cyan-300">
BRAIN METRICS</h3>
<div class="space-y-4 font-mono text-[10px]">
<div>
<div class="flex justify-between mb-1"><span class="opacity-70">NODE_COUNT</span><span class="text-cyan-300" id="metric-nodes">0</span></div>
<div class="w-full bg-cyan-900/50 h-1">
<div id="nodes-bar" class="sys-bar bg-cyan-400 h-full w-[0%] shadow-[0_0_8px_#00f3ff]"></div>
</div>
</div>
<div>
<div class="flex justify-between mb-1"><span class="opacity-70">LINK_DENSITY</span><span class="text-cyan-300" id="metric-links">0</span></div>
<div class="w-full bg-cyan-900/50 h-1">
<div id="links-bar" class="sys-bar bg-cyan-400 h-full w-[0%] shadow-[0_0_8px_#00f3ff]"></div>
</div>
</div>
<div>
<div class="flex justify-between mb-1"><span class="opacity-70">CLUSTER_COUNT</span><span class="text-cyan-300" id="metric-clusters">0</span></div>
<div class="w-full bg-cyan-900/50 h-1">
<div id="clusters-bar" class="sys-bar bg-cyan-400 h-full w-[0%] shadow-[0_0_8px_#00f3ff]"></div>
</div>
</div>
<div class="grid grid-cols-2 gap-2 pt-2">
<div class="bg-cyan-900/20 p-2 border border-cyan-500/20 text-center">
<div class="opacity-50 text-[9px]">CORE_NODES</div>
<div id="core-nodes" class="text-sm font-bold text-cyan-300">0</div>
</div>
<div class="bg-cyan-900/20 p-2 border border-cyan-500/20 text-center">
<div class="opacity-50 text-[9px]">BRIDGE_NODES</div>
<div id="bridge-nodes" class="text-sm font-bold text-cyan-300">0</div>
</div>
</div>
</div>
</div>
<div class="tech-panel p-4 flex-grow overflow-hidden flex flex-col">
<div class="flex justify-between items-center border-b border-cyan-500/30 pb-2 mb-2">
<h3 class="text-xs font-bold tracking-widest text-cyan-300">SIGNAL LEGEND</h3>
</div>
<div class="space-y-3 font-mono text-[11px]">
<div class="flex items-center gap-2">
<div class="legend-dot" style="background: #00f3ff; color: #00f3ff;"></div>
<span class="text-cyan-300">KNOWLEDGE</span>
</div>
<div class="flex items-center gap-2">
<div class="legend-dot" style="background: #ff6b9d; color: #ff6b9d;"></div>
<span class="text-cyan-300">CHAT</span>
</div>
<div class="flex items-center gap-2">
<div class="legend-dot" style="background: #a855f7; color: #a855f7;"></div>
<span class="text-cyan-300">FORUM</span>
</div>
<div class="flex items-center gap-2">
<div class="legend-dot" style="background: #fbbf24; color: #fbbf24;"></div>
<span class="text-cyan-300">SCHEDULE</span>
</div>
</div>
</div>
</div>
<div class="md:col-span-2 relative flex flex-col justify-end items-center">
<div class="absolute top-10 w-full text-center pointer-events-none">
<div id="brain-title" class="text-xl md:text-2xl font-bold tracking-[0.3em] text-glow uppercase text-cyan-100">
NEURAL BRAIN MATRIX
</div>
<div class="text-[10px] text-cyan-400/70 tracking-[0.5em] mt-2">INTERACTIVE 3D GRAPH</div>
<div class="text-[10px] text-cyan-400/50 tracking-[0.2em] mt-1">DRAG TO ROTATE / SCROLL TO ZOOM</div>
</div>
<div id="brain-canvas-container" class="w-full h-full"></div>
<div class="terminal-window w-full h-48 bg-black/90 border-t border-cyan-500/50 rounded-tl-lg rounded-tr-lg overflow-hidden flex flex-col backdrop-blur-md shadow-[0_-5px_20px_rgba(0,243,255,0.1)]">
<div class="bg-cyan-900/20 px-3 py-1.5 text-[10px] flex justify-between border-b border-cyan-500/30 items-center">
<span class="tracking-widest text-cyan-400">BRAIN_CONSOLE // SYSTEM LOG</span>
<div class="flex gap-2 font-mono text-cyan-600">
<span id="active-nodes-val">ACTIVE: 0</span>
<span id="selected-val">SELECTED: NONE</span>
</div>
</div>
<div id="brain-terminal" class="p-4 font-mono text-xs text-cyan-300/80 overflow-y-auto flex-grow space-y-1.5">
</div>
</div>
</div>
<div class="flex flex-col gap-4 h-full overflow-hidden">
<div id="detail-panel" class="tech-panel p-4 tech-border detail-panel">
<h3 class="text-xs font-bold tracking-widest border-b border-cyan-500/30 pb-2 mb-3 text-cyan-300">
SELECTED NODE</h3>
<div id="node-detail" class="font-mono text-[11px] space-y-3">
<div class="text-center text-cyan-500/50 py-8">NO NODE SELECTED</div>
</div>
</div>
<div class="tech-panel p-4 flex-grow flex flex-col overflow-hidden">
<h3 class="text-xs font-bold tracking-widest border-b border-cyan-500/30 pb-2 mb-3 text-cyan-300">
OUTGOING CONNECTIONS</h3>
<div id="connections-list" class="font-mono text-[10px] overflow-y-auto flex-grow space-y-2">
<div class="text-center text-cyan-500/50 py-4">SELECT A NODE</div>
</div>
</div>
<div class="tech-panel h-32 p-4 relative overflow-hidden flex flex-col justify-between">
<div class="text-[10px] opacity-60 tracking-widest mb-1">BRAIN ACTIVITY</div>
<div class="flex items-end justify-between h-full gap-1 pt-2" id="activity-graph">
<div class="sys-bar w-1 bg-cyan-600 h-[30%]"></div>
<div class="sys-bar w-1 bg-cyan-500 h-[50%]"></div>
<div class="sys-bar w-1 bg-cyan-400 h-[80%]"></div>
<div class="sys-bar w-1 bg-cyan-300 h-[40%]"></div>
<div class="sys-bar w-1 bg-cyan-400 h-[60%]"></div>
<div class="sys-bar w-1 bg-cyan-500 h-[90%]"></div>
<div class="sys-bar w-1 bg-cyan-600 h-[45%]"></div>
<div class="sys-bar w-1 bg-cyan-500 h-[70%]"></div>
<div class="sys-bar w-1 bg-cyan-400 h-[30%]"></div>
<div class="sys-bar w-1 bg-cyan-300 h-[60%]"></div>
</div>
</div>
</div>
</div>
</div>
<script>
(function() {
const NODES = [
{ id: 'core-1', name: 'Orchestrator Prime', type: 'knowledge', importance: 0.98, description: '中枢编排核心' },
{ id: 'core-2', name: 'Memory Vault', type: 'knowledge', importance: 0.91, description: '知识存储层' },
{ id: 'chat-1', name: 'Session Cluster', type: 'chat', importance: 0.89, description: '实时会话汇聚' },
{ id: 'chat-2', name: 'Operator Feed', type: 'chat', importance: 0.76, description: '操控台输入流' },
{ id: 'forum-1', name: 'Research Swarm', type: 'forum', importance: 0.81, description: '外部案例趋势池' },
{ id: 'forum-2', name: 'Threat Monitor', type: 'forum', importance: 0.72, description: '异常风险观察哨' },
{ id: 'sched-1', name: 'Sprint Grid', type: 'schedule', importance: 0.87, description: '执行排期矩阵' },
{ id: 'sched-2', name: 'Review Window', type: 'schedule', importance: 0.74, description: '评审验收窗口' },
{ id: 'bridge-1', name: 'Signal Bridge', type: 'knowledge', importance: 0.84, description: '跨域桥接节点' },
{ id: 'arch-1', name: 'Knowledge Archive', type: 'knowledge', importance: 0.66, description: '知识存档' },
{ id: 'thread-1', name: 'Conversation Thread', type: 'chat', importance: 0.62, description: '对话线程' },
{ id: 'signal-1', name: 'Forum Signal', type: 'forum', importance: 0.58, description: '外部论坛信号' },
{ id: 'lane-1', name: 'Schedule Lane', type: 'schedule', importance: 0.57, description: '排程支线' },
{ id: 'dormant-1', name: 'Dormant Trace', type: 'forum', importance: 0.45, description: '低优先级线索' },
];
const EDGES = [
{ id: 'e1', source: 'core-1', target: 'core-2', relation: '读取知识' },
{ id: 'e2', source: 'core-1', target: 'chat-1', relation: '接收会话' },
{ id: 'e3', source: 'core-1', target: 'sched-1', relation: '下发编排' },
{ id: 'e4', source: 'core-1', target: 'bridge-1', relation: '稳定桥接' },
{ id: 'e5', source: 'core-2', target: 'bridge-1', relation: '提供事实' },
{ id: 'e6', source: 'chat-1', target: 'chat-2', relation: '扩展输入' },
{ id: 'e7', source: 'chat-1', target: 'bridge-1', relation: '触发同步' },
{ id: 'e8', source: 'forum-1', target: 'bridge-1', relation: '补充趋势' },
{ id: 'e9', source: 'forum-2', target: 'bridge-1', relation: '反馈风险' },
{ id: 'e10', source: 'bridge-1', target: 'sched-1', relation: '投递任务' },
{ id: 'e11', source: 'sched-1', target: 'sched-2', relation: '进入评审' },
{ id: 'e12', source: 'forum-1', target: 'sched-2', relation: '支撑验证' },
{ id: 'e13', source: 'core-2', target: 'arch-1', relation: '归档事实' },
{ id: 'e14', source: 'bridge-1', target: 'arch-1', relation: '桥接引用' },
{ id: 'e15', source: 'chat-1', target: 'thread-1', relation: '拆分会话' },
{ id: 'e16', source: 'chat-2', target: 'thread-1', relation: '补充回执' },
{ id: 'e17', source: 'forum-1', target: 'signal-1', relation: '吸收案例' },
{ id: 'e18', source: 'sched-1', target: 'lane-1', relation: '拆分排期' },
{ id: 'e19', source: 'sched-2', target: 'lane-1', relation: '投递评审' },
{ id: 'e20', source: 'forum-2', target: 'dormant-1', relation: '弱风险' },
];
const TYPE_COLORS = {
knowledge: 0x00f3ff,
chat: 0xff6b9d,
forum: 0xa855f7,
schedule: 0xfbbf24,
};
const TYPE_LABELS = {
knowledge: '知识库',
chat: '对话',
forum: '论坛',
schedule: '日程',
};
let scene, camera, renderer;
let nodeObjects = new Map();
let edgeObjects = [];
let particleSystem;
let hoveredNodeId = null;
let selectedNodeId = null;
let mouseX = 0, mouseY = 0;
let targetX = 0, targetY = 0;
let windowHalfX = window.innerWidth / 2;
let windowHalfY = window.innerHeight / 2;
const nodeMeshes = [];
function updateTime() {
const now = new Date();
const el = document.getElementById('time-display');
if (el) el.innerText = now.toLocaleTimeString('en-US', { hour12: false });
}
setInterval(updateTime, 1000);
updateTime();
function init() {
const container = document.getElementById('brain-canvas-container');
const w = container.parentElement.clientWidth || 600;
const h = container.parentElement.clientHeight || 600;
scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x020408, 0.0025);
camera = new THREE.PerspectiveCamera(60, w / h, 0.1, 2000);
camera.position.z = 80;
renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
renderer.setSize(w, h);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
container.appendChild(renderer.domElement);
const ambientLight = new THREE.AmbientLight(0x404060, 0.4);
scene.add(ambientLight);
const pointLight = new THREE.PointLight(0x00f3ff, 1.5, 300);
pointLight.position.set(0, 50, 50);
scene.add(pointLight);
createBackground();
createBrainCore();
createNodes();
createEdges();
createOrbitalRings();
document.addEventListener('mousemove', onDocumentMouseMove);
document.addEventListener('mousedown', onDocumentMouseDown);
document.addEventListener('mouseup', onDocumentMouseUp);
document.addEventListener('wheel', onDocumentWheel);
window.addEventListener('resize', onWindowResize);
updateMetrics();
updateNodeList();
setTimeout(() => {
const statusEl = document.getElementById('brain-status');
if (statusEl) {
statusEl.innerText = "ONLINE";
statusEl.classList.remove('text-red-500');
statusEl.classList.add('text-cyan-300', 'bg-cyan-500/20', 'border-cyan-400');
}
addLog(">> Brain matrix initialized.");
addLog(">> Neural pathways loaded.");
addLog(">> 14 nodes, 20 connections active.");
}, 1500);
animate();
}
function createBackground() {
const count = 1500;
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
positions[i * 3] = (Math.random() - 0.5) * 600;
positions[i * 3 + 1] = (Math.random() - 0.5) * 600;
positions[i * 3 + 2] = (Math.random() - 0.5) * 600;
const c = new THREE.Color();
c.setHSL(0.55 + Math.random() * 0.1, 0.7, 0.5 + Math.random() * 0.4);
colors[i * 3] = c.r;
colors[i * 3 + 1] = c.g;
colors[i * 3 + 2] = c.b;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const mat = new THREE.PointsMaterial({
size: 0.5, vertexColors: true, transparent: true, opacity: 0.6,
blending: THREE.AdditiveBlending,
});
particleSystem = new THREE.Points(geo, mat);
scene.add(particleSystem);
}
function createBrainCore() {
const coreGeo = new THREE.IcosahedronGeometry(3, 2);
const coreMat = new THREE.MeshBasicMaterial({
color: 0x00f3ff,
wireframe: true,
transparent: true,
opacity: 0.12
});
const coreMesh = new THREE.Mesh(coreGeo, coreMat);
scene.add(coreMesh);
const innerGeo = new THREE.IcosahedronGeometry(2, 1);
const innerMat = new THREE.MeshBasicMaterial({
color: 0x00f3ff,
transparent: true,
opacity: 0.2
});
const innerMesh = new THREE.Mesh(innerGeo, innerMat);
scene.add(innerMesh);
}
function createHexShape(r) {
const shape = new THREE.Shape();
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2 - Math.PI / 6;
const x = Math.cos(a) * r, y = Math.sin(a) * r;
if (i === 0) shape.moveTo(x, y); else shape.lineTo(x, y);
}
shape.closePath();
return shape;
}
function createNodes() {
const angleStep = (Math.PI * 2) / NODES.length;
const radius = 30;
NODES.forEach((node, i) => {
const angle = i * angleStep;
const layer = Math.floor(i / 7);
const r = radius + layer * 18;
const x = Math.cos(angle) * r;
const z = Math.sin(angle) * r;
const y = (Math.random() - 0.5) * 8 + layer * 4;
const color = TYPE_COLORS[node.type];
const imp = node.importance;
const height = 3 + imp * 12;
const radius = 1.5 + imp * 1.5;
const group = new THREE.Group();
group.userData = { nodeId: node.id };
group.position.set(x, y, z);
const geo = new THREE.ExtrudeGeometry(createHexShape(radius), {
depth: height, bevelEnabled: true, bevelThickness: 0.2, bevelSize: 0.15, bevelSegments: 2,
});
geo.center();
const mat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.12, side: THREE.DoubleSide });
const mesh = new THREE.Mesh(geo, mat);
mesh.rotation.x = -Math.PI / 2;
group.add(mesh);
const edgesGeo = new THREE.EdgesGeometry(geo);
const edgesMat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.8 });
const edges = new THREE.LineSegments(edgesGeo, edgesMat);
edges.rotation.x = -Math.PI / 2;
group.add(edges);
const glowGeo = new THREE.SphereGeometry(radius * 0.6, 12, 12);
const glowMat = new THREE.MeshBasicMaterial({
color, transparent: true, opacity: 0.3 + imp * 0.4, blending: THREE.AdditiveBlending,
});
const glow = new THREE.Mesh(glowGeo, glowMat);
group.add(glow);
const topGeo = new THREE.CircleGeometry(radius * 0.4, 6);
const topMat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.7, side: THREE.DoubleSide });
const top = new THREE.Mesh(topGeo, topMat);
top.rotation.x = -Math.PI / 2;
top.position.y = height / 2;
group.add(top);
group.rotation.y = Math.random() * Math.PI;
scene.add(group);
nodeObjects.set(node.id, group);
nodeMeshes.push(glow);
});
}
function createEdges() {
EDGES.forEach(edge => {
const src = nodeObjects.get(edge.source);
const tgt = nodeObjects.get(edge.target);
if (!src || !tgt) return;
const srcNode = NODES.find(n => n.id === edge.source);
const color = TYPE_COLORS[srcNode?.type || 'knowledge'];
const points = [src.position.clone(), tgt.position.clone()];
const geo = new THREE.BufferGeometry().setFromPoints(points);
const mat = new THREE.LineDashedMaterial({
color, transparent: true, opacity: 0.4,
dashSize: 1.5, gapSize: 1, blending: THREE.AdditiveBlending,
});
const line = new THREE.Line(geo, mat);
line.computeLineDistances();
scene.add(line);
edgeObjects.push(line);
});
}
function createOrbitalRings() {
const ring1Geo = new THREE.BufferGeometry();
const ring1Pos = new Float32Array(80 * 3);
for (let i = 0; i < 80; i++) {
const theta = (i / 80) * Math.PI * 2;
ring1Pos[i * 3] = Math.cos(theta) * 45;
ring1Pos[i * 3 + 1] = (Math.random() - 0.5) * 0.5;
ring1Pos[i * 3 + 2] = Math.sin(theta) * 45;
}
ring1Geo.setAttribute('position', new THREE.BufferAttribute(ring1Pos, 3));
const ring1Mat = new THREE.PointsMaterial({ color: 0x00f3ff, size: 0.3, transparent: true, opacity: 0.4 });
const ring1 = new THREE.Points(ring1Geo, ring1Mat);
ring1.userData = { speedX: 0.001, speedY: 0.002 };
scene.add(ring1);
const ring2Geo = new THREE.BufferGeometry();
const ring2Pos = new Float32Array(60 * 3);
for (let i = 0; i < 60; i++) {
const theta = (i / 60) * Math.PI * 2;
ring2Pos[i * 3] = (Math.random() - 0.5) * 0.5;
ring2Pos[i * 3 + 1] = Math.cos(theta) * 50;
ring2Pos[i * 3 + 2] = Math.sin(theta) * 50;
}
ring2Geo.setAttribute('position', new THREE.BufferAttribute(ring2Pos, 3));
const ring2Mat = new THREE.PointsMaterial({ color: 0xa855f7, size: 0.25, transparent: true, opacity: 0.3 });
const ring2 = new THREE.Points(ring2Geo, ring2Mat);
ring2.userData = { speedX: 0.002, speedY: 0.001 };
scene.add(ring2);
}
function onDocumentMouseMove(e) {
mouseX = e.clientX - windowHalfX;
mouseY = e.clientY - windowHalfY;
}
function onDocumentMouseDown(e) {}
function onDocumentMouseUp(e) {
if (hoveredNodeId) selectNode(hoveredNodeId);
}
function onDocumentWheel(e) {
camera.position.z += e.deltaY * 0.05;
camera.position.z = Math.max(30, Math.min(200, camera.position.z));
}
function onWindowResize() {
const container = document.getElementById('brain-canvas-container');
const w = container.parentElement.clientWidth || 600;
const h = container.parentElement.clientHeight || 600;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
}
function animate() {
requestAnimationFrame(animate);
const t = Date.now() * 0.001;
targetX = mouseX * 0.0005;
targetY = mouseY * 0.0005;
camera.rotation.y += (targetX - camera.rotation.y) * 0.05;
camera.rotation.x += (-targetY - camera.rotation.x) * 0.05;
particleSystem.rotation.y += 0.0001;
particleSystem.rotation.x += 0.00005;
nodeMeshes.forEach((mesh, i) => {
const pulse = Math.sin(t * 2 + i * 0.5) * 0.15;
mesh.scale.setScalar(1 + pulse);
});
edgeObjects.forEach(line => {
const mat = line.material;
mat.dashSize = 1.5 + Math.sin(t) * 0.3;
});
scene.children.forEach(child => {
if (child instanceof THREE.Points && child.userData.speedX) {
child.rotation.x += child.userData.speedX;
child.rotation.y += child.userData.speedY;
}
});
renderer.render(scene, camera);
}
function updateMetrics() {
document.getElementById('metric-nodes').textContent = NODES.length;
document.getElementById('metric-links').textContent = EDGES.length;
document.getElementById('nodes-bar').style.width = (NODES.length / 20 * 100) + '%';
document.getElementById('links-bar').style.width = (EDGES.length / 30 * 100) + '%';
const clusters = 3;
document.getElementById('metric-clusters').textContent = clusters;
document.getElementById('clusters-bar').style.width = (clusters / 5 * 100) + '%';
const coreCount = NODES.filter(n => n.importance >= 0.86).length;
const bridgeCount = NODES.filter(n => n.importance >= 0.7 && n.importance < 0.86).length;
document.getElementById('core-nodes').textContent = coreCount;
document.getElementById('bridge-nodes').textContent = bridgeCount;
document.getElementById('active-nodes-val').textContent = 'ACTIVE: ' + NODES.length;
}
function updateNodeList() {
const list = document.getElementById('node-list');
list.innerHTML = '';
NODES.forEach(node => {
const color = '#' + TYPE_COLORS[node.type].toString(16).padStart(6, '0');
const div = document.createElement('div');
div.className = 'flash-row p-2 flex justify-between items-center border-b border-cyan-500/10';
div.innerHTML = `
<div class="flex items-center gap-2">
<div class="w-1.5 h-1.5 rounded-full" style="background: ${color}; box-shadow: 0 0 4px ${color};"></div>
<span class="text-cyan-300 truncate max-w-[100px]">${node.name}</span>
</div>
<span class="opacity-50 text-[9px]">${Math.round(node.importance * 100)}%</span>
`;
div.onclick = () => selectNode(node.id);
list.appendChild(div);
});
}
function selectNode(nodeId) {
selectedNodeId = nodeId;
const node = NODES.find(n => n.id === nodeId);
if (!node) return;
document.getElementById('selected-val').textContent = 'SELECTED: ' + node.name;
const color = '#' + TYPE_COLORS[node.type].toString(16).padStart(6, '0');
const detail = document.getElementById('node-detail');
detail.innerHTML = `
<div class="mb-3 text-center">
<div class="text-[9px] opacity-50 uppercase tracking-widest">${TYPE_LABELS[node.type]}</div>
<div class="text-lg font-bold text-cyan-100 tracking-wider">${node.name}</div>
</div>
<div class="space-y-2">
<div class="flex justify-between"><span class="opacity-60">IMPORTANCE</span><span class="text-cyan-300">${Math.round(node.importance * 100)}%</span></div>
<div class="flex justify-between"><span class="opacity-60">DESCRIPTION</span></div>
<div class="text-cyan-300/80 text-[10px] pl-2 border-l border-cyan-500/30">${node.description}</div>
</div>
`;
const conns = EDGES.filter(e => e.source === nodeId || e.target === nodeId);
const list = document.getElementById('connections-list');
list.innerHTML = '';
if (conns.length === 0) {
list.innerHTML = '<div class="text-center text-cyan-500/50 py-4">NO CONNECTIONS</div>';
} else {
conns.forEach(conn => {
const otherId = conn.source === nodeId ? conn.target : conn.source;
const other = NODES.find(n => n.id === otherId);
const otherColor = '#' + TYPE_COLORS[other?.type || 'knowledge'].toString(16).padStart(6, '0');
const div = document.createElement('div');
div.className = 'border-b border-cyan-500/10 pb-1 mb-1';
div.innerHTML = `
<div class="flex items-center gap-1 text-[9px]">
<span class="text-cyan-500">${conn.relation}</span>
<span class="opacity-30">→</span>
<span class="w-2 h-2 rounded-full inline-block" style="background: ${otherColor}; box-shadow: 0 0 4px ${otherColor};"></span>
<span class="text-cyan-300">${other?.name || ''}</span>
</div>
`;
list.appendChild(div);
});
}
document.getElementById('detail-panel').classList.add('active');
addLog('>> Node selected: ' + node.name);
}
function addLog(msg) {
const terminal = document.getElementById('brain-terminal');
if (!terminal) return;
const div = document.createElement('div');
div.className = 'border-l-2 border-cyan-800 pl-2 py-0.5';
div.innerHTML = `<span class="text-[10px] text-cyan-600 mr-2 font-bold">[${new Date().toLocaleTimeString('en-US', { hour12: false })}]</span><span class="text-xs font-mono text-cyan-300/80">${msg}</span>`;
terminal.appendChild(div);
if (terminal.children.length > 15) terminal.removeChild(terminal.firstChild);
terminal.scrollTop = terminal.scrollHeight;
}
function updateActivityGraph() {
const bars = document.querySelectorAll('#activity-graph .sys-bar');
bars.forEach(bar => {
bar.style.height = Math.floor(Math.random() * 80 + 10) + '%';
});
}
setInterval(updateActivityGraph, 500);
document.addEventListener('mousemove', (e) => {
const container = document.getElementById('brain-canvas-container');
if (!container) return;
const rect = container.getBoundingClientRect();
if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) {
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera({ x, y }, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
let found = null;
for (const hit of intersects) {
let obj = hit.object;
while (obj) {
if (obj.userData?.nodeId) { found = obj.userData.nodeId; break; }
obj = obj.parent;
}
if (found) break;
}
hoveredNodeId = found;
container.style.cursor = found ? 'pointer' : 'grab';
}
});
init();
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,752 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>J.A.R.V.I.S. Interface</title>
<script src="https://cdn.tailwindcss.com"></script>
<link
href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700;900&family=Share+Tech+Mono&display=swap"
rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
:root {
--jarvis-blue: #00f3ff;
--jarvis-blue-dim: rgba(0, 243, 255, 0.1);
--jarvis-alert: #ff3333;
--bg-color: #020408;
}
body {
background-color: var(--bg-color);
color: var(--jarvis-blue);
font-family: 'Orbitron', sans-serif;
overflow: hidden;
margin: 0;
height: 100vh;
width: 100vw;
}
/* Scanline effect */
body::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: linear-gradient(to bottom,
rgba(255, 255, 255, 0),
rgba(255, 255, 255, 0) 50%,
rgba(0, 0, 0, 0.1) 50%,
rgba(0, 0, 0, 0.1));
background-size: 100% 3px;
pointer-events: none;
z-index: 50;
}
/* Vignette */
.vignette {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle, transparent 50%, rgba(0, 0, 0, 0.95) 100%);
pointer-events: none;
z-index: 40;
}
/* Text & Border Utilities */
.text-glow {
text-shadow: 0 0 5px var(--jarvis-blue), 0 0 10px var(--jarvis-blue);
}
.text-dim {
color: rgba(0, 243, 255, 0.6);
}
/* Tech Panels */
.tech-panel {
background: rgba(0, 15, 30, 0.7);
border-left: 1px solid rgba(0, 243, 255, 0.3);
border-right: 1px solid rgba(0, 243, 255, 0.3);
position: relative;
backdrop-filter: blur(4px);
transition: all 0.3s ease;
}
.tech-panel:hover {
border-color: rgba(0, 243, 255, 0.8);
background: rgba(0, 20, 40, 0.8);
}
/* Corner accents */
.tech-panel::before,
.tech-panel::after {
content: '';
position: absolute;
width: 8px;
height: 8px;
border: 1px solid var(--jarvis-blue);
transition: all 0.3s;
}
.tech-panel::before {
top: -1px;
left: -1px;
border-right: none;
border-bottom: none;
}
.tech-panel::after {
bottom: -1px;
right: -1px;
border-left: none;
border-top: none;
}
/* Tech Border Box */
.tech-border {
clip-path: polygon(10px 0,
100% 0,
100% calc(100% - 10px),
calc(100% - 10px) 100%,
0 100%,
0 10px);
border-top: 1px solid var(--jarvis-blue-dim);
}
/* Scrollbars */
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.3);
}
::-webkit-scrollbar-thumb {
background: var(--jarvis-blue);
border-radius: 2px;
}
/* Animations */
@keyframes pulse-status {
0%,
100% {
opacity: 0.6;
text-shadow: 0 0 2px var(--jarvis-blue);
box-shadow: 0 0 5px var(--jarvis-blue-dim);
}
50% {
opacity: 1;
text-shadow: 0 0 15px var(--jarvis-blue);
box-shadow: 0 0 15px var(--jarvis-blue-dim);
}
}
.status-pulse {
animation: pulse-status 3s infinite ease-in-out;
}
@keyframes flash-row {
0% {
background-color: transparent;
}
50% {
background-color: rgba(0, 243, 255, 0.15);
}
100% {
background-color: transparent;
}
}
.flash-row:hover {
animation: flash-row 0.8s infinite;
cursor: pointer;
background: rgba(0, 243, 255, 0.05);
}
/* Smooth Bar Transition */
.sys-bar {
transition: width 0.5s ease-out, height 0.3s ease-out;
}
/* Canvas Positioning */
#canvas-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -55%);
z-index: 10;
width: 800px;
height: 800px;
pointer-events: none;
}
</style>
</head>
<body>
<div class="vignette"></div>
<!-- MAIN UI WRAPPER -->
<div class="relative z-30 h-full w-full flex flex-col p-4 md:p-6 pointer-events-none">
<!-- HEADER -->
<header class="flex justify-between items-start pointer-events-auto mb-2 border-b border-cyan-500/20 pb-2">
<div class="flex items-center gap-4">
<div class="flex flex-col items-center justify-center">
<div class="w-2 h-2 bg-cyan-400 rounded-full mb-1"></div>
<div class="h-8 w-px bg-cyan-500/50"></div>
</div>
<div>
<h1 class="text-4xl font-bold tracking-[0.15em] text-glow font-orbitron">J.A.R.V.I.S.</h1>
<div class="flex items-center gap-2 mt-1">
<div class="h-1 w-20 bg-cyan-500/30 overflow-hidden">
<div class="h-full bg-cyan-400 w-1/2 animate-pulse"></div>
</div>
<div class="text-[9px] tracking-[0.3em] opacity-70">NEURAL INTERFACE V5.1</div>
</div>
</div>
</div>
<div class="text-right">
<div class="flex items-center justify-end gap-3 mb-1">
<span class="text-[10px] tracking-widest opacity-60">LINK STATUS</span>
<span id="connection-status"
class="font-bold text-xs status-pulse bg-cyan-900/40 px-3 py-1 rounded-sm border border-cyan-500/30">DISCONNECTED</span>
</div>
<div class="flex justify-end items-baseline gap-2">
<span class="text-[10px] opacity-50">SYS_TIME</span>
<div id="time-display" class="font-mono text-xl tracking-wider text-cyan-200">00:00:00</div>
</div>
</div>
</header>
<!-- MAIN GRID LAYOUT -->
<div class="flex-grow grid grid-cols-1 md:grid-cols-4 gap-6 pointer-events-auto h-0 min-h-0 mt-2">
<!-- LEFT COLUMN: DEVICES & IOT -->
<div class="flex flex-col gap-4 h-full overflow-hidden">
<!-- Server Status -->
<div class="tech-panel p-4 tech-border">
<h3 class="text-xs font-bold tracking-widest border-b border-cyan-500/30 pb-2 mb-3 text-cyan-300">
SERVER CLUSTER LOAD</h3>
<div class="space-y-4 font-mono text-[10px]">
<div>
<div class="flex justify-between mb-1"><span class="opacity-70">MAIN_FRAME_01</span><span
class="text-cyan-300" id="server-1-val">ONLINE</span></div>
<div class="w-full bg-cyan-900/50 h-1">
<div id="server-1-bar"
class="sys-bar bg-cyan-400 h-full w-[34%] shadow-[0_0_8px_#00f3ff]"></div>
</div>
</div>
<div>
<div class="flex justify-between mb-1"><span class="opacity-70">DATA_CORE_ZETA</span><span
class="text-cyan-300" id="server-2-val">PROCESSING</span></div>
<div class="w-full bg-cyan-900/50 h-1">
<div id="server-2-bar"
class="sys-bar bg-cyan-400 h-full w-[89%] shadow-[0_0_8px_#00f3ff]"></div>
</div>
</div>
<div>
<div class="flex justify-between mb-1"><span class="opacity-70">STORAGE_ARRAY</span><span
class="text-cyan-300" id="server-3-val">STABLE</span></div>
<div class="w-full bg-cyan-900/50 h-1">
<div id="server-3-bar"
class="sys-bar bg-cyan-400 h-full w-[12%] shadow-[0_0_8px_#00f3ff]"></div>
</div>
</div>
<div class="grid grid-cols-2 gap-2 pt-2">
<div class="bg-cyan-900/20 p-2 border border-cyan-500/20 text-center">
<div class="opacity-50 text-[9px]">TEMP</div>
<div id="sys-temp" class="text-sm font-bold text-cyan-300">42°C</div>
</div>
<div class="bg-cyan-900/20 p-2 border border-cyan-500/20 text-center">
<div class="opacity-50 text-[9px]">POWER</div>
<div id="sys-power" class="text-sm font-bold text-cyan-300">98%</div>
</div>
</div>
</div>
</div>
<!-- Connected Devices -->
<div class="tech-panel p-4 flex-grow overflow-hidden flex flex-col">
<div class="flex justify-between items-center border-b border-cyan-500/30 pb-2 mb-2">
<h3 class="text-xs font-bold tracking-widest text-cyan-300">CONNECTED DEVICES</h3>
<span class="text-[9px] bg-cyan-500/20 px-1 rounded animate-pulse">SCANNING</span>
</div>
<ul id="device-list"
class="font-mono text-[11px] space-y-1 overflow-y-auto pr-1 flex-grow custom-scrollbar">
<li
class="flash-row p-2 flex justify-between items-center group border-b border-cyan-500/10 hover:border-cyan-500/50 transition-colors">
<div class="flex items-center gap-2">
<div class="w-1.5 h-1.5 bg-green-500 rounded-full"></div>
<span>SMART_HOME_HUB</span>
</div>
<span class="opacity-50 text-[9px]">192.168.1.10</span>
</li>
<li
class="flash-row p-2 flex justify-between items-center group border-b border-cyan-500/10 hover:border-cyan-500/50 transition-colors">
<div class="flex items-center gap-2">
<div class="w-1.5 h-1.5 bg-green-500 rounded-full"></div>
<span>SECURITY_CAM_GRID</span>
</div>
<span class="device-status opacity-50 text-[9px]">ACTIVE</span>
</li>
<li
class="flash-row p-2 flex justify-between items-center group border-b border-cyan-500/10 hover:border-cyan-500/50 transition-colors">
<div class="flex items-center gap-2">
<div class="w-1.5 h-1.5 bg-yellow-500 rounded-full animate-pulse"></div>
<span>DRONE_SENTRY_04</span>
</div>
<span class="device-status opacity-50 text-[9px]">CHARGING</span>
</li>
<li
class="flash-row p-2 flex justify-between items-center group border-b border-cyan-500/10 hover:border-cyan-500/50 transition-colors">
<div class="flex items-center gap-2">
<div class="w-1.5 h-1.5 bg-green-500 rounded-full"></div>
<span>MAIN_GATE_LOCK</span>
</div>
<span class="opacity-50 text-[9px]">SECURE</span>
</li>
<li
class="flash-row p-2 flex justify-between items-center group border-b border-cyan-500/10 hover:border-cyan-500/50 transition-colors">
<div class="flex items-center gap-2">
<div class="w-1.5 h-1.5 bg-red-500 rounded-full"></div>
<span>EXT_PERIMETER_SENSORS</span>
</div>
<span class="device-status opacity-50 text-[9px]">ALERT</span>
</li>
<li
class="flash-row p-2 flex justify-between items-center group border-b border-cyan-500/10 hover:border-cyan-500/50 transition-colors">
<div class="flex items-center gap-2">
<div class="w-1.5 h-1.5 bg-green-500 rounded-full"></div>
<span>LAB_3D_PRINTER</span>
</div>
<span class="device-status opacity-50 text-[9px]">IDLE</span>
</li>
</ul>
</div>
</div>
<!-- CENTER COLUMN: NEURAL VISUALIZATION & OUTPUT -->
<div class="md:col-span-2 relative flex flex-col justify-end items-center">
<!-- Floating Status -->
<div class="absolute top-10 w-full text-center pointer-events-none">
<div id="ai-status"
class="text-xl md:text-2xl font-bold tracking-[0.3em] text-glow uppercase text-cyan-100">
NEURAL ENGINE: STANDBY
</div>
<div class="text-[10px] text-cyan-400/70 tracking-[0.5em] mt-2">AWAITING INPUT PARAMETERS</div>
</div>
<!-- Main Terminal (Bottom Center) -->
<div
class="terminal-window w-full h-56 bg-black/90 border-t border-cyan-500/50 rounded-tl-lg rounded-tr-lg overflow-hidden flex flex-col backdrop-blur-md shadow-[0_-5px_20px_rgba(0,243,255,0.1)]">
<div
class="bg-cyan-900/20 px-3 py-1.5 text-[10px] flex justify-between border-b border-cyan-500/30 items-center">
<span class="tracking-widest text-cyan-400">CONSOLE_OUT // ROOT_ACCESS</span>
<div class="flex gap-2 font-mono text-cyan-600">
<span id="pid-val">PID:4092</span>
<span id="mem-val">MEM:24%</span>
</div>
</div>
<div id="terminal-content"
class="p-4 font-mono text-xs text-cyan-300/80 overflow-y-auto flex-grow space-y-1.5">
<!-- Logs inject here -->
<div class="opacity-50 border-l-2 border-cyan-800 pl-2">>> Initializing neural weights... OK
</div>
<div class="opacity-50 border-l-2 border-cyan-800 pl-2">>> Mounting device file system... OK
</div>
</div>
</div>
</div>
<!-- RIGHT COLUMN: FILES & ALGORITHMS -->
<div class="flex flex-col gap-4 h-full overflow-hidden">
<!-- Algorithm Status -->
<div class="tech-panel p-4 flex-grow flex flex-col">
<h3 class="text-xs font-bold tracking-widest border-b border-cyan-500/30 pb-2 mb-2 text-cyan-300">
ACTIVE THREADS</h3>
<div class="font-mono text-[11px] overflow-y-auto pr-1 flex-grow space-y-3">
<div class="mb-2">
<div class="text-cyan-100 opacity-60 mb-1 text-[10px]">/PROCESS/VISION</div>
<div class="pl-2 border-l border-cyan-500/30 space-y-1.5">
<div class="flash-row flex items-center justify-between"><span
class="text-cyan-400">object_recognition.py</span> <span
class="text-[9px] bg-cyan-900 px-1 rounded">RUNNING</span></div>
<div class="flash-row flex items-center justify-between"><span
class="text-cyan-400">facial_scan_v2.exe</span> <span
class="text-[9px] bg-cyan-900 px-1 rounded">IDLE</span></div>
</div>
</div>
<div>
<div class="text-cyan-100 opacity-60 mb-1 text-[10px]">/PROCESS/LANGUAGE</div>
<div class="pl-2 border-l border-cyan-500/30 space-y-1.5">
<div class="flash-row flex items-center justify-between"><span
class="text-cyan-400">nlp_core_en.lib</span> <span
class="text-[9px] bg-cyan-900 px-1 rounded">LOADED</span></div>
<div class="flash-row flex items-center justify-between"><span
class="text-cyan-400">voice_synth_mod.dll</span> <span
class="text-[9px] bg-cyan-900 px-1 rounded">ACTIVE</span></div>
</div>
</div>
<div>
<div class="text-cyan-100 opacity-60 mb-1 text-[10px]">/PROCESS/NETWORK</div>
<div class="pl-2 border-l border-cyan-500/30 space-y-1.5">
<div class="flash-row flex items-center justify-between"><span
class="text-cyan-400">packet_sniffer.sh</span> <span
class="text-[9px] bg-cyan-900 px-1 rounded">BG</span></div>
<div class="flash-row flex items-center justify-between"><span
class="text-cyan-400">firewall_daemon</span> <span
class="text-[9px] bg-cyan-900 px-1 rounded">ACTIVE</span></div>
</div>
</div>
</div>
</div>
<!-- Network Topology (Active Visualizer) -->
<div class="tech-panel h-32 p-4 relative overflow-hidden flex flex-col justify-between">
<div class="text-[10px] opacity-60 tracking-widest mb-1">NETWORK_TRAFFIC</div>
<!-- Mock Graph Bars -->
<div class="flex items-end justify-between h-full gap-1 pt-2" id="network-graph">
<div class="sys-bar w-1 bg-cyan-600 h-[30%]"></div>
<div class="sys-bar w-1 bg-cyan-500 h-[50%]"></div>
<div class="sys-bar w-1 bg-cyan-400 h-[80%]"></div>
<div class="sys-bar w-1 bg-cyan-300 h-[40%]"></div>
<div class="sys-bar w-1 bg-cyan-400 h-[60%]"></div>
<div class="sys-bar w-1 bg-cyan-500 h-[90%]"></div>
<div class="sys-bar w-1 bg-cyan-600 h-[45%]"></div>
<div class="sys-bar w-1 bg-cyan-500 h-[70%]"></div>
<div class="sys-bar w-1 bg-cyan-400 h-[30%]"></div>
<div class="sys-bar w-1 bg-cyan-300 h-[60%]"></div>
</div>
</div>
</div>
</div>
</div>
<!-- THREE.JS CONTAINER -->
<div id="canvas-container"></div>
<script>
(function () {
// --- CLOCK UTILITY ---
function updateTime() {
const now = new Date();
const timeDisplay = document.getElementById('time-display');
if (timeDisplay) {
timeDisplay.innerText = now.toLocaleTimeString('en-US', { hour12: false });
}
}
setInterval(updateTime, 1000);
updateTime();
// --- THREE.JS ADVANCED NEURAL VISUALIZATION ---
const container = document.getElementById('canvas-container');
if (container) {
const scene = new THREE.Scene();
// Subtler fog for deep space feel
scene.fog = new THREE.FogExp2(0x020408, 0.003);
const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000);
camera.position.z = 20;
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
renderer.setSize(800, 800);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);
// --- GROUPING ---
const brainGroup = new THREE.Group();
scene.add(brainGroup);
// 1. INNER CORE (Dense Wireframe Sphere)
const coreGeo = new THREE.IcosahedronGeometry(4, 2);
const coreMat = new THREE.MeshBasicMaterial({
color: 0x00f3ff,
wireframe: true,
transparent: true,
opacity: 0.15
});
const coreMesh = new THREE.Mesh(coreGeo, coreMat);
brainGroup.add(coreMesh);
// 2. NEURAL CLOUD (Nodes & Lines)
const particleCount = 400; // Increased count
const cloudRadius = 9;
const particlesData = [];
let particlePositions = new Float32Array(particleCount * 3);
const pMaterial = new THREE.PointsMaterial({
color: 0x00f3ff,
size: 0.15,
blending: THREE.AdditiveBlending,
transparent: true,
opacity: 0.8
});
// Generate random points in a spherical shell
for (let i = 0; i < particleCount; i++) {
const r = cloudRadius + (Math.random() - 0.5) * 4; // Vary radius for depth
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos((Math.random() * 2) - 1);
const x = r * Math.sin(phi) * Math.cos(theta);
const y = r * Math.sin(phi) * Math.sin(theta);
const z = r * Math.cos(phi);
particlePositions[i * 3] = x;
particlePositions[i * 3 + 1] = y;
particlePositions[i * 3 + 2] = z;
particlesData.push({
velocity: new THREE.Vector3(
(-1 + Math.random() * 2) * 0.02,
(-1 + Math.random() * 2) * 0.02,
(-1 + Math.random() * 2) * 0.02
),
originalPos: new THREE.Vector3(x, y, z)
});
}
const particlesGeo = new THREE.BufferGeometry();
particlesGeo.setAttribute('position', new THREE.BufferAttribute(particlePositions, 3));
const particleSystem = new THREE.Points(particlesGeo, pMaterial);
brainGroup.add(particleSystem);
// Lines setup
const maxConnections = particleCount;
const linesGeo = new THREE.BufferGeometry();
const linePositions = new Float32Array(maxConnections * maxConnections * 3);
const lineColors = new Float32Array(maxConnections * maxConnections * 3);
linesGeo.setAttribute('position', new THREE.BufferAttribute(linePositions, 3));
linesGeo.setAttribute('color', new THREE.BufferAttribute(lineColors, 3));
const lMaterial = new THREE.LineBasicMaterial({
vertexColors: true,
blending: THREE.AdditiveBlending,
transparent: true,
opacity: 0.2
});
const linesMesh = new THREE.LineSegments(linesGeo, lMaterial);
brainGroup.add(linesMesh);
// 3. ORBITAL RINGS (Fast moving outer data)
const ringGroup = new THREE.Group();
brainGroup.add(ringGroup);
function createOrbitalRing(radius, count, speedX, speedY) {
const ringGeo = new THREE.BufferGeometry();
const ringPos = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const theta = (i / count) * Math.PI * 2;
ringPos[i * 3] = Math.cos(theta) * radius;
ringPos[i * 3 + 1] = Math.sin(theta) * radius;
ringPos[i * 3 + 2] = (Math.random() - 0.5) * 0.5;
}
ringGeo.setAttribute('position', new THREE.BufferAttribute(ringPos, 3));
const ringMat = new THREE.PointsMaterial({ color: 0x00f3ff, size: 0.1, transparent: true, opacity: 0.6 });
const ring = new THREE.Points(ringGeo, ringMat);
ring.userData = { speedX, speedY };
return ring;
}
const ring1 = createOrbitalRing(11, 100, 0.005, 0.01);
const ring2 = createOrbitalRing(13, 80, -0.01, 0.002);
ringGroup.add(ring1);
ringGroup.add(ring2);
// --- ANIMATION LOOP ---
function animate() {
requestAnimationFrame(animate);
// 1. Rotate entire group
brainGroup.rotation.y += 0.002;
// 2. Animate Core
coreMesh.rotation.x -= 0.005;
coreMesh.rotation.z += 0.002;
// 3. Animate Particles & Lines
let vertexpos = 0;
let colorpos = 0;
let numConnected = 0;
// Update particles
for (let i = 0; i < particleCount; i++) {
// Jitter particles
const p = particlesData[i];
// Constrain to radius roughly
particlePositions[i * 3] += p.velocity.x;
particlePositions[i * 3 + 1] += p.velocity.y;
particlePositions[i * 3 + 2] += p.velocity.z;
// Very simple bounce back logic
if (Math.abs(particlePositions[i * 3] - p.originalPos.x) > 1) p.velocity.x = -p.velocity.x;
if (Math.abs(particlePositions[i * 3 + 1] - p.originalPos.y) > 1) p.velocity.y = -p.velocity.y;
if (Math.abs(particlePositions[i * 3 + 2] - p.originalPos.z) > 1) p.velocity.z = -p.velocity.z;
}
particlesGeo.attributes.position.needsUpdate = true;
// Connect lines (Optimize: only check subset or smaller distance)
const connectionDist = 2.5;
for (let i = 0; i < particleCount; i++) {
for (let j = i + 1; j < particleCount; j++) {
const dx = particlePositions[i * 3] - particlePositions[j * 3];
const dy = particlePositions[i * 3 + 1] - particlePositions[j * 3 + 1];
const dz = particlePositions[i * 3 + 2] - particlePositions[j * 3 + 2];
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (dist < connectionDist) {
const alpha = 1.0 - dist / connectionDist;
linePositions[vertexpos++] = particlePositions[i * 3];
linePositions[vertexpos++] = particlePositions[i * 3 + 1];
linePositions[vertexpos++] = particlePositions[i * 3 + 2];
linePositions[vertexpos++] = particlePositions[j * 3];
linePositions[vertexpos++] = particlePositions[j * 3 + 1];
linePositions[vertexpos++] = particlePositions[j * 3 + 2];
lineColors[colorpos++] = 0; lineColors[colorpos++] = alpha; lineColors[colorpos++] = 1;
lineColors[colorpos++] = 0; lineColors[colorpos++] = alpha; lineColors[colorpos++] = 1;
numConnected++;
}
}
}
linesMesh.geometry.setDrawRange(0, numConnected * 2);
linesMesh.geometry.attributes.position.needsUpdate = true;
linesMesh.geometry.attributes.color.needsUpdate = true;
// 4. Animate Rings
ring1.rotation.x += ring1.userData.speedX;
ring1.rotation.y += ring1.userData.speedY;
ring2.rotation.x += ring2.userData.speedX;
ring2.rotation.y += ring2.userData.speedY;
renderer.render(scene, camera);
}
animate();
}
// --- MOCK DATA SIMULATION (The "Life" of the Dashboard) ---
// 1. Terminal Logger
const terminal = document.getElementById('terminal-content');
function addLog(message) {
if (!terminal) return;
const div = document.createElement('div');
div.className = 'text-cyan-300 opacity-80 border-l-2 border-transparent hover:border-cyan-500 pl-2 py-0.5 animate-pulse-fast';
div.innerHTML = `<span class="text-[10px] text-cyan-600 mr-2 font-bold">[${new Date().toLocaleTimeString('en-US', { hour12: false })}]</span> <span class="text-xs font-mono">${message}</span>`;
terminal.appendChild(div);
if (terminal.children.length > 20) terminal.removeChild(terminal.firstChild); // Keep DOM light
terminal.scrollTop = terminal.scrollHeight;
}
// 2. Update System Bars & Text
function updateSystemStats() {
// Server Load Bars
document.getElementById('server-1-bar').style.width = Math.floor(Math.random() * 40 + 30) + '%'; // 30-70%
document.getElementById('server-2-bar').style.width = Math.floor(Math.random() * 20 + 70) + '%'; // 70-90%
document.getElementById('server-3-bar').style.width = Math.floor(Math.random() * 10 + 5) + '%'; // 5-15%
// Temp & Power
document.getElementById('sys-temp').innerText = Math.floor(Math.random() * 5 + 40) + '°C';
document.getElementById('sys-power').innerText = Math.floor(Math.random() * 3 + 96) + '%';
// Memory PID
document.getElementById('mem-val').innerText = 'MEM:' + Math.floor(Math.random() * 10 + 20) + '%';
}
// 3. Animate Network Graph
function updateNetworkGraph() {
const bars = document.querySelectorAll('#network-graph .sys-bar');
bars.forEach(bar => {
bar.style.height = Math.floor(Math.random() * 80 + 10) + '%';
});
}
// 4. Device Status Rotation
function updateDeviceStatus() {
const statuses = document.querySelectorAll('.device-status');
const randomIdx = Math.floor(Math.random() * statuses.length);
const el = statuses[randomIdx];
const states = ['ACTIVE', 'PINGING', 'SENDING', 'IDLE'];
const newState = states[Math.floor(Math.random() * states.length)];
el.innerText = newState;
if (newState === 'ACTIVE' || newState === 'SENDING') el.className = 'device-status text-cyan-300 text-[9px]';
else el.className = 'device-status opacity-50 text-[9px]';
}
// --- START SIMULATION ---
setTimeout(() => {
const statusEl = document.getElementById('connection-status');
const aiStatusEl = document.getElementById('ai-status');
if (statusEl) {
statusEl.innerText = "SECURE LINK ESTABLISHED";
statusEl.classList.remove('text-red-500');
statusEl.classList.add('text-cyan-300', 'bg-cyan-500/20', 'border-cyan-400');
}
if (aiStatusEl) {
aiStatusEl.innerHTML = "NEURAL ENGINE: <span class='text-cyan-300 drop-shadow-[0_0_10px_rgba(0,243,255,0.8)]'>ONLINE</span>";
}
addLog(">> Handshake protocol complete.");
addLog(">> Neural weights loaded into memory.");
// Start Interval Loops
setInterval(() => {
const logs = [
"Optimizing neural pathways...", "Garbage collection active...",
"Packet sniffing: 192.168.1.X...", "Updating heuristic models...",
"Thermal throttling check... OK", "Syncing cloud replica...",
"Analyzing biometric feed...", "Background process [PID:8821] started"
];
addLog(">> " + logs[Math.floor(Math.random() * logs.length)]);
}, 2000);
setInterval(updateSystemStats, 1500);
setInterval(updateNetworkGraph, 300); // Fast update for graph
setInterval(updateDeviceStatus, 3000);
}, 1000);
})();
</script>
</body>
</html>

View File

@@ -22,6 +22,7 @@ export interface AgentConfig {
description: string
system_prompt: string
enabled: boolean
selected_skill_ids?: string[]
}
export const agentApi = {

35
frontend/src/api/goal.ts Normal file
View File

@@ -0,0 +1,35 @@
import api from './index'
export type GoalStatus = 'active' | 'done' | 'archived'
export interface Goal {
id: string
title: string
note: string | null
goal_date: string
status: GoalStatus
created_at: string
updated_at: string
}
export interface GoalListResponse {
items: Goal[]
}
export const goalApi = {
list(date: string) {
return api.get<GoalListResponse>('/api/goals', { params: { date_str: date } })
},
create(data: { title: string; goal_date: string; note?: string; status?: GoalStatus }) {
return api.post<Goal>('/api/goals', data)
},
update(id: string, data: Partial<Pick<Goal, 'title' | 'note' | 'goal_date' | 'status'>>) {
return api.patch<Goal>(`/api/goals/${id}`, data)
},
delete(id: string) {
return api.delete(`/api/goals/${id}`)
},
}

View File

@@ -18,8 +18,12 @@ function isDev() {
return Boolean(import.meta.env.DEV)
}
function isApiDebugEnabled() {
return import.meta.env.VITE_ENABLE_API_DEBUG === 'true'
}
function debugLog(stage: string, payload: Record<string, unknown>) {
if (!isDev()) return
if (!isDev() || !isApiDebugEnabled()) return
console.debug(`[api:${stage}]`, payload)
}

View File

@@ -0,0 +1,36 @@
import api from './index'
export type ReminderStatus = 'pending' | 'done'
export interface Reminder {
id: string
title: string
note: string | null
reminder_at: string
status: ReminderStatus
is_dismissed: boolean
created_at: string
updated_at: string
}
export interface ReminderListResponse {
items: Reminder[]
}
export const reminderApi = {
list(date: string) {
return api.get<ReminderListResponse>('/api/reminders', { params: { date_str: date } })
},
create(data: { title: string; reminder_at: string; note?: string }) {
return api.post<Reminder>('/api/reminders', data)
},
update(id: string, data: Partial<Pick<Reminder, 'title' | 'note' | 'reminder_at' | 'status' | 'is_dismissed'>>) {
return api.patch<Reminder>(`/api/reminders/${id}`, data)
},
delete(id: string) {
return api.delete(`/api/reminders/${id}`)
},
}

View File

@@ -0,0 +1,43 @@
import api from './index'
import type { Goal } from './goal'
import type { Reminder } from './reminder'
import type { Task } from './task'
import type { Todo } from './todo'
export interface ScheduleCenterDaySummary {
date: string
todo_total: number
todo_completed: number
task_due_total: number
high_priority_total: number
reminder_total: number
goal_total: number
}
export interface ScheduleCenterMonthResponse {
month: string
days: ScheduleCenterDaySummary[]
}
export interface ScheduleCenterDateResponse {
date: string
todos: Todo[]
tasks: Task[]
reminders: Reminder[]
goals: Goal[]
summary: ScheduleCenterDaySummary
generated_at: string
}
export const scheduleCenterApi = {
month(month: string) {
const [year, monthValue] = month.split('-')
return api.get<ScheduleCenterMonthResponse>('/api/schedule-center/month', {
params: { year: Number(year), month: Number(monthValue) },
})
},
date(date: string) {
return api.get<ScheduleCenterDateResponse>('/api/schedule-center/date', { params: { date_str: date } })
},
}

View File

@@ -5,7 +5,8 @@ export type LLMType = 'chat' | 'vlm' | 'embedding' | 'rerank'
export interface LLMModelConfig {
name: string // 模型名称/别名
provider: LLMProvider
// provider 已不再是必填字段:优先通过 base_url + model 推断
provider?: LLMProvider
model: string
base_url: string
api_key: string
@@ -60,7 +61,12 @@ export const settingsApi = {
},
// 测试 LLM 连接
testLLM(data: { type: LLMType } & Omit<LLMModelConfig, 'name' | 'enabled'>) {
testLLM(data: { type: LLMType } & {
model: string
base_url: string
api_key: string
provider?: LLMProvider
}) {
return api.post('/api/settings/llm/test', data)
},

View File

@@ -11,6 +11,7 @@ export interface Skill {
required_context: string[]
output_format: string | null
visibility: 'private' | 'team' | 'market'
is_builtin: boolean
team_id: string | null
is_active: boolean
owner_id: string
@@ -52,4 +53,5 @@ export const skillApi = {
create: (data: SkillCreate): Promise<AxiosResponse<Skill>> => api.post('/api/skills', data),
update: (id: string, data: SkillUpdate): Promise<AxiosResponse<Skill>> => api.put(`/api/skills/${id}`, data),
delete: (id: string): Promise<AxiosResponse<void>> => api.delete(`/api/skills/${id}`),
bootstrapBuiltin: (): Promise<AxiosResponse<Skill[]>> => api.post('/api/skills/bootstrap-builtin'),
}

View File

@@ -4,6 +4,18 @@ export interface SystemStatus {
cpu_percent: number
memory_percent: number
disk_percent: number
disk_used_gb: number
disk_total_gb: number
network_upload_bps: number
network_download_bps: number
system_name: string
system_version: string
hostname: string
uptime_seconds: number
gpu_name: string | null
gpu_memory_total_mb: number | null
gpu_memory_used_mb: number | null
gpu_util_percent: number | null
timestamp: string
}

View File

@@ -17,8 +17,8 @@ export interface Task {
}
export const taskApi = {
list(status?: TaskStatus) {
return api.get<Task[]>('/api/tasks', { params: status ? { status } : {} })
list(filters?: { status?: TaskStatus; due_date?: string; date_from?: string; date_to?: string }) {
return api.get<Task[]>('/api/tasks', { params: filters ?? {} })
},
create(data: { title: string; description?: string; priority?: TaskPriority; due_date?: string }) {

View File

@@ -35,11 +35,11 @@ export const todoApi = {
})
},
create(title: string) {
return api.post<Todo>('/api/todos', { title })
create(data: { title: string; todo_date?: string }) {
return api.post<Todo>('/api/todos', data)
},
update(id: string, data: { title?: string; is_completed?: boolean }) {
update(id: string, data: { title?: string; is_completed?: boolean; todo_date?: string }) {
return api.patch<Todo>(`/api/todos/${id}`, data)
},

View File

@@ -1,12 +1,9 @@
import {
Activity,
BookOpen,
Bot,
CheckSquare,
LayoutGrid,
CalendarDays,
MessageCircle,
MessageSquare,
Network,
Settings,
Star,
Terminal,
@@ -22,13 +19,10 @@ export interface NavItem {
export const navItems: NavItem[] = [
{ name: '沟通系统', path: '/chat', icon: MessageCircle },
{ name: '奥创中心', path: '/agents', icon: Bot },
{ name: '能中心', path: '/skills', icon: Star },
{ name: '能中心', path: '/skills', icon: Star },
{ name: '资料中枢', path: '/knowledge', icon: BookOpen },
{ name: '知识大脑', path: '/brain', icon: Network },
{ name: '任务矩阵', path: '/kanban', icon: LayoutGrid },
{ name: '任务调度', path: '/todo', icon: CheckSquare },
{ name: '调度中心', path: '/schedule-center', icon: CalendarDays },
{ name: '信息交易所', path: '/forum', icon: MessageSquare },
{ name: '运行状态', path: '/stats', icon: Activity },
{ name: '运行日志', path: '/logs', icon: Terminal },
{ name: '系统设置', path: '/settings', icon: Settings },
]

View File

@@ -15,20 +15,19 @@ const appChildren: RouteRecordRaw[] = [
name: 'knowledge',
component: () => import('@/pages/knowledge/index.vue'),
},
{
path: 'brain',
name: 'brain',
component: () => import('@/pages/brain/index.vue'),
},
{
path: 'graph',
name: 'graph',
component: () => import('@/pages/graph/index.vue'),
},
{
path: 'schedule-center',
name: 'schedule-center',
component: () => import('@/pages/schedule-center/index.vue'),
},
{
path: 'kanban',
name: 'kanban',
component: () => import('@/pages/kanban/index.vue'),
redirect: '/schedule-center',
},
{
path: 'forum',
@@ -40,11 +39,6 @@ const appChildren: RouteRecordRaw[] = [
name: 'agents',
component: () => import('@/pages/agents/index.vue'),
},
{
path: 'stats',
name: 'stats',
component: () => import('@/pages/stats/index.vue'),
},
{
path: 'skills',
name: 'skills',
@@ -52,8 +46,7 @@ const appChildren: RouteRecordRaw[] = [
},
{
path: 'todo',
name: 'todo',
component: () => import('@/pages/todo/index.vue'),
redirect: '/schedule-center',
},
{
path: 'settings',

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 KiB

View File

@@ -0,0 +1,360 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Group, Sprite } from 'three'
const forceGraphMocks = vi.hoisted(() => {
const graphData = vi.fn()
const onNodeClick = vi.fn()
const onBackgroundClick = vi.fn()
const nodeColor = vi.fn()
const nodeRelSize = vi.fn()
const linkColor = vi.fn()
const linkWidth = vi.fn()
const linkOpacity = vi.fn()
const cameraPosition = vi.fn()
const width = vi.fn()
const height = vi.fn()
const cooldownTicks = vi.fn()
const d3AlphaDecay = vi.fn()
const d3VelocityDecay = vi.fn()
const enableNodeDrag = vi.fn()
const nodeLabel = vi.fn()
const nodeThreeObject = vi.fn()
const linkDirectionalParticles = vi.fn()
const linkDirectionalParticleWidth = vi.fn()
const linkDirectionalParticleSpeed = vi.fn()
const linkDirectionalParticleColor = vi.fn()
const nodeVisibility = vi.fn()
const linkVisibility = vi.fn()
const pauseAnimation = vi.fn()
const resumeAnimation = vi.fn()
const graphInstance = {
graphData: vi.fn((...args) => {
graphData(...args)
return graphInstance
}),
onNodeClick: vi.fn((...args) => {
onNodeClick(...args)
return graphInstance
}),
onBackgroundClick: vi.fn((...args) => {
onBackgroundClick(...args)
return graphInstance
}),
nodeColor: vi.fn((...args) => {
nodeColor(...args)
return graphInstance
}),
nodeRelSize: vi.fn((...args) => {
nodeRelSize(...args)
return graphInstance
}),
linkColor: vi.fn((...args) => {
linkColor(...args)
return graphInstance
}),
linkWidth: vi.fn((...args) => {
linkWidth(...args)
return graphInstance
}),
linkOpacity: vi.fn((...args) => {
linkOpacity(...args)
return graphInstance
}),
cameraPosition: vi.fn((...args) => {
cameraPosition(...args)
return graphInstance
}),
width: vi.fn((...args) => {
width(...args)
return graphInstance
}),
height: vi.fn((...args) => {
height(...args)
return graphInstance
}),
cooldownTicks: vi.fn((...args) => {
cooldownTicks(...args)
return graphInstance
}),
d3AlphaDecay: vi.fn((...args) => {
d3AlphaDecay(...args)
return graphInstance
}),
d3VelocityDecay: vi.fn((...args) => {
d3VelocityDecay(...args)
return graphInstance
}),
enableNodeDrag: vi.fn((...args) => {
enableNodeDrag(...args)
return graphInstance
}),
nodeLabel: vi.fn((...args) => {
nodeLabel(...args)
return graphInstance
}),
nodeThreeObject: vi.fn((...args) => {
nodeThreeObject(...args)
return graphInstance
}),
linkDirectionalParticles: vi.fn((...args) => {
linkDirectionalParticles(...args)
return graphInstance
}),
linkDirectionalParticleWidth: vi.fn((...args) => {
linkDirectionalParticleWidth(...args)
return graphInstance
}),
linkDirectionalParticleSpeed: vi.fn((...args) => {
linkDirectionalParticleSpeed(...args)
return graphInstance
}),
linkDirectionalParticleColor: vi.fn((...args) => {
linkDirectionalParticleColor(...args)
return graphInstance
}),
nodeVisibility: vi.fn((...args) => {
nodeVisibility(...args)
return graphInstance
}),
linkVisibility: vi.fn((...args) => {
linkVisibility(...args)
return graphInstance
}),
pauseAnimation: vi.fn((...args) => {
pauseAnimation(...args)
return graphInstance
}),
resumeAnimation: vi.fn((...args) => {
resumeAnimation(...args)
return graphInstance
}),
}
const createForceGraph = vi.fn(function () {
return graphInstance
})
return {
graphData,
onNodeClick,
onBackgroundClick,
nodeColor,
nodeRelSize,
linkColor,
linkWidth,
linkOpacity,
cameraPosition,
width,
height,
cooldownTicks,
d3AlphaDecay,
d3VelocityDecay,
enableNodeDrag,
nodeLabel,
nodeThreeObject,
linkDirectionalParticles,
linkDirectionalParticleWidth,
linkDirectionalParticleSpeed,
linkDirectionalParticleColor,
nodeVisibility,
linkVisibility,
pauseAnimation,
resumeAnimation,
graphInstance,
createForceGraph,
}
})
const {
graphData,
onNodeClick,
onBackgroundClick,
nodeLabel,
nodeThreeObject,
graphInstance,
createForceGraph,
} = forceGraphMocks
vi.mock('3d-force-graph', () => ({
default: forceGraphMocks.createForceGraph,
}))
import BrainGraphCanvas from './BrainGraphCanvas.vue'
const nodes = [
{ id: 'node-1', name: 'Core Node', type: 'knowledge', importance: 0.95 },
{ id: 'node-2', name: 'Linked Node', type: 'chat', importance: 0.78 },
{ id: 'node-3', name: 'Outer Node', type: 'forum', importance: 0.52 },
]
const edges = [
{ id: 'edge-1', source: 'node-1', target: 'node-2', relation: 'links' },
{ id: 'edge-2', source: 'node-2', target: 'node-3', relation: 'extends' },
]
function mountCanvas(props: Record<string, unknown> = {}) {
return mount(BrainGraphCanvas, {
attachTo: document.body,
props: {
nodes,
edges,
getColor: (type: string) => ({
knowledge: '#7dffb2',
chat: '#18f6a8',
forum: '#4dff7a',
}[type] || '#7ea892'),
...props,
},
})
}
describe('BrainGraphCanvas', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(() => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
})
})
it('mounts the 3d force graph into the canvas container', () => {
const wrapper = mountCanvas()
expect(wrapper.get('[data-testid="brain-graph-canvas"]')).toBeTruthy()
expect(createForceGraph).toHaveBeenCalledTimes(1)
expect(graphInstance.graphData).toHaveBeenCalled()
expect(graphInstance.nodeLabel).toHaveBeenCalled()
expect(graphInstance.nodeThreeObject).toHaveBeenCalled()
const labelAccessor = nodeLabel.mock.calls[0]?.[0]
expect(typeof labelAccessor).toBe('function')
expect(labelAccessor?.(nodes[0])).toContain('Core Node')
const textObjectFactory = nodeThreeObject.mock.calls[0]?.[0]
expect(typeof textObjectFactory).toBe('function')
expect(textObjectFactory?.(nodes[0])).toBeTruthy()
wrapper.unmount()
})
it('renders smaller labels close to the node sphere', () => {
const wrapper = mountCanvas({ selectedNodeId: 'node-1' })
const textObjectFactory = nodeThreeObject.mock.calls[0]?.[0]
const defaultVisual = textObjectFactory?.(nodes[2])
const selectedVisual = textObjectFactory?.(nodes[0])
expect(defaultVisual).toBeInstanceOf(Group)
expect(selectedVisual).toBeInstanceOf(Group)
const defaultLabel = (defaultVisual as Group).children.find((child) => child instanceof Sprite) as Sprite | undefined
const selectedLabel = (selectedVisual as Group).children.find((child) => child instanceof Sprite) as Sprite | undefined
expect(defaultLabel).toBeTruthy()
expect(selectedLabel).toBeTruthy()
expect(defaultLabel?.position.y).toBeLessThanOrEqual(12)
expect(defaultLabel?.scale.y).toBeLessThanOrEqual(16)
expect(selectedLabel?.scale.y).toBeGreaterThanOrEqual(defaultLabel?.scale.y ?? 0)
wrapper.unmount()
})
it('starts from a closer default viewport and resets to it', () => {
const wrapper = mountCanvas()
const initialCamera = graphInstance.cameraPosition.mock.calls[0]?.[0]
expect(initialCamera?.z).toBeLessThan(280)
wrapper.vm.resetViewport()
const latestCamera = graphInstance.cameraPosition.mock.calls.at(-1)?.[0]
expect(latestCamera?.z).toBe(initialCamera?.z)
wrapper.unmount()
})
it('animates the viewport with subtle auto rotation and stops on unmount', () => {
vi.useFakeTimers()
const wrapper = mountCanvas()
const initialCameraCalls = graphInstance.cameraPosition.mock.calls.length
vi.advanceTimersByTime(2400)
const duringRotationCalls = graphInstance.cameraPosition.mock.calls.length
expect(duringRotationCalls).toBeGreaterThan(initialCameraCalls)
const firstAutoCall = graphInstance.cameraPosition.mock.calls[initialCameraCalls]?.[0]
const lastAutoCall = graphInstance.cameraPosition.mock.calls.at(-1)?.[0]
expect(Math.abs((lastAutoCall?.x ?? 0) - (firstAutoCall?.x ?? 0))).toBeLessThanOrEqual(40)
expect(Math.abs((lastAutoCall?.z ?? 0) - (firstAutoCall?.z ?? 0))).toBeLessThanOrEqual(40)
wrapper.unmount()
const callsAfterUnmount = graphInstance.cameraPosition.mock.calls.length
vi.advanceTimersByTime(2400)
expect(graphInstance.cameraPosition.mock.calls.length).toBe(callsAfterUnmount)
})
it('emits selectNode when the renderer node click callback fires', async () => {
const wrapper = mountCanvas()
const handler = onNodeClick.mock.calls[0]?.[0]
handler?.({ id: 'node-2' })
await wrapper.vm.$nextTick()
expect(wrapper.emitted('selectNode')).toEqual([['node-2']])
})
it('emits clearSelection when the renderer background callback fires', async () => {
const wrapper = mountCanvas()
const handler = onBackgroundClick.mock.calls[0]?.[0]
handler?.()
await wrapper.vm.$nextTick()
expect(wrapper.emitted('clearSelection')).toEqual([[]])
})
it('exposes zoomBy and resetViewport methods', () => {
const wrapper = mountCanvas()
expect(typeof wrapper.vm.zoomBy).toBe('function')
expect(typeof wrapper.vm.resetViewport).toBe('function')
wrapper.vm.zoomBy(1.15)
wrapper.vm.resetViewport()
expect(graphInstance.cameraPosition).toHaveBeenCalled()
})
it('updates graph data when props change', async () => {
const wrapper = mountCanvas()
const updatedNodes = [...nodes, { id: 'node-4', name: 'New Node', type: 'schedule', importance: 0.7 }]
await wrapper.setProps({ nodes: updatedNodes })
const latestGraphData = graphData.mock.calls.at(-1)?.[0]
expect(latestGraphData.nodes).toHaveLength(4)
expect(latestGraphData.links).toHaveLength(2)
})
it('recomputes visibility/highlight accessors when selection changes', async () => {
const wrapper = mountCanvas()
await wrapper.setProps({ selectedNodeId: 'node-1' })
expect(graphInstance.nodeVisibility).toHaveBeenCalled()
expect(graphInstance.linkVisibility).toHaveBeenCalled()
expect(graphInstance.linkColor).toHaveBeenCalled()
expect(graphInstance.linkWidth).toHaveBeenCalled()
expect(graphInstance.nodeThreeObject).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,433 @@
<script setup lang="ts">
import ForceGraph3D from '3d-force-graph'
import { CanvasTexture, Group, Mesh, MeshBasicMaterial, Sprite, SpriteMaterial, SphereGeometry } from 'three'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { KGEdge } from '@/api/graph'
interface BrainGraphNode {
id: string
name: string
type: string
importance: number
}
interface ForceGraphNode extends BrainGraphNode {
color?: string
}
interface ForceGraphLink extends KGEdge {
source: string
target: string
}
interface CameraPosition {
x: number
y: number
z: number
}
interface ForceGraphInstance {
graphData: (data: { nodes: ForceGraphNode[]; links: ForceGraphLink[] }) => ForceGraphInstance
onNodeClick: (callback: (node?: Partial<ForceGraphNode> | null) => void) => ForceGraphInstance
onBackgroundClick: (callback: () => void) => ForceGraphInstance
nodeColor: (accessor: (node: ForceGraphNode) => string) => ForceGraphInstance
nodeLabel?: (accessor: (node: ForceGraphNode) => string) => ForceGraphInstance
nodeRelSize: (size: number) => ForceGraphInstance
linkColor: (accessor: (link: ForceGraphLink) => string) => ForceGraphInstance
linkWidth: (accessor: (link: ForceGraphLink) => number) => ForceGraphInstance
linkOpacity: (opacity: number) => ForceGraphInstance
cameraPosition: (position: CameraPosition, lookAt?: { x: number; y: number; z: number }, durationMs?: number) => ForceGraphInstance
width: (value: number) => ForceGraphInstance
height: (value: number) => ForceGraphInstance
cooldownTicks: (value: number) => ForceGraphInstance
d3AlphaDecay: (value: number) => ForceGraphInstance
d3VelocityDecay: (value: number) => ForceGraphInstance
enableNodeDrag: (enabled: boolean) => ForceGraphInstance
nodeThreeObject?: (accessor: ((node: ForceGraphNode) => unknown) | null) => ForceGraphInstance
linkDirectionalParticles: (accessor: (link: ForceGraphLink) => number) => ForceGraphInstance
linkDirectionalParticleWidth: (accessor: (link: ForceGraphLink) => number) => ForceGraphInstance
linkDirectionalParticleSpeed: (accessor: (link: ForceGraphLink) => number) => ForceGraphInstance
linkDirectionalParticleColor: (accessor: (link: ForceGraphLink) => string) => ForceGraphInstance
nodeVisibility: (accessor: (node: ForceGraphNode) => boolean) => ForceGraphInstance
linkVisibility: (accessor: (link: ForceGraphLink) => boolean) => ForceGraphInstance
pauseAnimation?: () => ForceGraphInstance
resumeAnimation?: () => ForceGraphInstance
}
const DEFAULT_CAMERA: CameraPosition = { x: 0, y: 0, z: 220 }
const DEFAULT_LOOK_AT = { x: 0, y: 0, z: 0 }
const DEFAULT_WIDTH = 960
const DEFAULT_HEIGHT = 640
const LINK_OPACITY = 0.12
const BASE_NODE_SIZE = 4.4
const LABEL_TEXT_COLOR = '#e9f7ff'
const LABEL_GLOW_COLOR = '#63b8ff'
const AUTO_ROTATE_INTERVAL_MS = 500
const AUTO_ROTATE_STEP = 0.08
const AUTO_ROTATE_RADIUS_RATIO = 0.05
const props = withDefaults(defineProps<{
nodes: BrainGraphNode[]
edges: KGEdge[]
selectedNodeId?: string | null
getColor: (type: string) => string
}>(), {
selectedNodeId: null,
})
const emit = defineEmits<{
selectNode: [nodeId: string]
clearSelection: []
}>()
const stageRef = ref<HTMLDivElement | null>(null)
const graphInstance = ref<ForceGraphInstance | null>(null)
const cameraDistance = ref(DEFAULT_CAMERA.z)
const autoRotateAngle = ref(0)
let autoRotateTimer: ReturnType<typeof window.setInterval> | null = null
const adjacencyMap = computed(() => {
const adjacency = new Map<string, Set<string>>()
for (const node of props.nodes) {
adjacency.set(node.id, new Set())
}
for (const edge of props.edges) {
const sourceId = String(edge.source)
const targetId = String(edge.target)
adjacency.set(sourceId, new Set([...(adjacency.get(sourceId) || []), targetId]))
adjacency.set(targetId, new Set([...(adjacency.get(targetId) || []), sourceId]))
}
return adjacency
})
const visibleNodeIds = computed(() => new Set(props.nodes.map((node) => node.id)))
const graphData = computed(() => ({
nodes: props.nodes.map((node) => ({
...node,
color: props.getColor(node.type),
})),
links: props.edges.map((edge) => ({
...edge,
source: String(edge.source),
target: String(edge.target),
})),
}))
function syncGraphSize() {
if (!graphInstance.value || !stageRef.value) {
return
}
const width = stageRef.value.clientWidth || DEFAULT_WIDTH
const height = stageRef.value.clientHeight || DEFAULT_HEIGHT
graphInstance.value.width(width)
graphInstance.value.height(height)
}
function syncGraphData() {
graphInstance.value?.graphData(graphData.value)
}
function isSelectedNode(nodeId: string) {
return props.selectedNodeId === nodeId
}
function isAdjacentNode(nodeId: string) {
if (!props.selectedNodeId) {
return true
}
if (isSelectedNode(nodeId)) {
return true
}
return adjacencyMap.value.get(props.selectedNodeId)?.has(nodeId) ?? false
}
function isVisibleLink(link: ForceGraphLink) {
return visibleNodeIds.value.has(String(link.source)) && visibleNodeIds.value.has(String(link.target))
}
function isAdjacentLink(link: ForceGraphLink) {
if (!props.selectedNodeId) {
return true
}
return String(link.source) === props.selectedNodeId || String(link.target) === props.selectedNodeId
}
function isCoreNode(node: ForceGraphNode) {
return node.importance >= 0.86 || (adjacencyMap.value.get(node.id)?.size || 0) >= 2
}
function createLabelSprite(text: string, options: { selected?: boolean; core?: boolean } = {}) {
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
if (!context) {
return new Sprite(new SpriteMaterial({ color: LABEL_TEXT_COLOR, transparent: true, opacity: 0.56 }))
}
const fontSize = options.selected ? 16 : options.core ? 13 : 11
const horizontalPadding = options.selected ? 10 : 8
const verticalPadding = options.selected ? 6 : 5
context.font = `${fontSize}px "Segoe UI", "PingFang SC", sans-serif`
const measuredWidth = Math.ceil(context.measureText(text).width)
canvas.width = measuredWidth + horizontalPadding * 2
canvas.height = fontSize + verticalPadding * 2
context.clearRect(0, 0, canvas.width, canvas.height)
context.font = `${fontSize}px "Segoe UI", "PingFang SC", sans-serif`
context.textBaseline = 'middle'
context.textAlign = 'center'
context.shadowColor = LABEL_GLOW_COLOR
context.shadowBlur = options.selected ? 6 : 3
context.fillStyle = LABEL_TEXT_COLOR
context.globalAlpha = options.selected ? 0.92 : options.core ? 0.72 : 0.56
context.fillText(text, canvas.width / 2, canvas.height / 2)
const texture = new CanvasTexture(canvas)
texture.needsUpdate = true
const material = new SpriteMaterial({
map: texture,
transparent: true,
depthWrite: false,
opacity: options.selected ? 0.82 : options.core ? 0.62 : 0.46,
})
const sprite = new Sprite(material)
const aspectRatio = canvas.width / canvas.height
const scaleBase = options.selected ? 11 : options.core ? 9 : 8
sprite.scale.set(scaleBase * aspectRatio, scaleBase, 1)
sprite.center.set(0.5, 0)
sprite.position.set(0, options.selected ? 8 : 7, 0)
return sprite
}
function createNodeVisual(node: ForceGraphNode) {
const core = isCoreNode(node)
const selected = isSelectedNode(node.id)
const color = selected ? '#f7fbff' : props.getColor(node.type)
const group = new Group()
const sphere = new Mesh(
new SphereGeometry(selected ? 7.8 : core ? 6.6 : 5.4, 18, 18),
new MeshBasicMaterial({
color,
transparent: true,
opacity: selected ? 0.98 : core ? 0.9 : 0.82,
}),
)
group.add(sphere)
group.add(createLabelSprite(node.name, { selected, core }))
return group
}
function applySelectionStyling() {
if (!graphInstance.value) {
return
}
graphInstance.value
.nodeVisibility((node) => visibleNodeIds.value.has(node.id))
.linkVisibility((link) => isVisibleLink(link))
.linkColor((link) => (isAdjacentLink(link) ? '#cfeaff' : 'rgba(100, 160, 255, 0.1)'))
.linkWidth((link) => {
if (isAdjacentLink(link)) {
return 1.15
}
return props.selectedNodeId ? 0.22 : 0.48
})
}
function applyCameraPosition(durationMs = 300) {
const orbitRadius = cameraDistance.value * AUTO_ROTATE_RADIUS_RATIO
const position = {
x: Math.sin(autoRotateAngle.value) * orbitRadius,
y: Math.cos(autoRotateAngle.value * 0.6) * orbitRadius * 0.22,
z: cameraDistance.value,
}
graphInstance.value?.cameraPosition(position, DEFAULT_LOOK_AT, durationMs)
}
function startAutoRotate() {
if (autoRotateTimer || !graphInstance.value || typeof window === 'undefined') {
return
}
autoRotateTimer = window.setInterval(() => {
autoRotateAngle.value += AUTO_ROTATE_STEP
applyCameraPosition(AUTO_ROTATE_INTERVAL_MS)
}, AUTO_ROTATE_INTERVAL_MS)
}
function stopAutoRotate() {
if (!autoRotateTimer || typeof window === 'undefined') {
return
}
window.clearInterval(autoRotateTimer)
autoRotateTimer = null
}
function zoomBy(multiplier: number) {
cameraDistance.value = Math.max(120, Math.min(960, cameraDistance.value / multiplier))
applyCameraPosition(250)
}
function resetViewport() {
cameraDistance.value = DEFAULT_CAMERA.z
autoRotateAngle.value = 0
applyCameraPosition(300)
}
function applyNodePresentation() {
if (!graphInstance.value) {
return
}
graphInstance.value
.nodeColor((node) => {
if (isSelectedNode(node.id)) {
return '#f8fbff'
}
if (isAdjacentNode(node.id)) {
return props.getColor(node.type)
}
return 'rgba(118, 156, 196, 0.22)'
})
.nodeLabel?.((node) => `${node.name} · ${node.id}`)
graphInstance.value
.nodeThreeObject?.((node) => createNodeVisual(node))
}
function configureGraph() {
if (!graphInstance.value) {
return
}
applyNodePresentation()
graphInstance.value
.nodeRelSize(BASE_NODE_SIZE)
.linkOpacity(LINK_OPACITY)
.cooldownTicks(90)
.d3AlphaDecay(0.06)
.d3VelocityDecay(0.26)
.enableNodeDrag(true)
.linkDirectionalParticles((link) => (isAdjacentLink(link) ? 1 : 0))
.linkDirectionalParticleWidth((link) => (isAdjacentLink(link) ? 0.48 : 0))
.linkDirectionalParticleSpeed((link) => (isAdjacentLink(link) ? 0.0038 : 0))
.linkDirectionalParticleColor((link) => (isAdjacentLink(link) ? '#d6ecff' : 'rgba(0,0,0,0)'))
applySelectionStyling()
syncGraphData()
syncGraphSize()
resetViewport()
startAutoRotate()
}
function createGraphInstance(element: HTMLElement) {
const candidate = ForceGraph3D as unknown as {
new (target: HTMLElement): ForceGraphInstance
(target?: HTMLElement): ForceGraphInstance | ((target: HTMLElement) => ForceGraphInstance)
}
try {
return new candidate(element)
}
catch {
const directResult = candidate(element)
if (directResult && typeof directResult === 'object' && 'graphData' in directResult) {
return directResult as ForceGraphInstance
}
if (typeof directResult === 'function') {
return directResult(element)
}
const factoryResult = candidate()
if (typeof factoryResult === 'function') {
return factoryResult(element)
}
return factoryResult as ForceGraphInstance
}
}
onMounted(() => {
if (!stageRef.value) {
return
}
graphInstance.value = createGraphInstance(stageRef.value)
graphInstance.value
.onNodeClick((node) => {
if (!node?.id) {
return
}
emit('selectNode', String(node.id))
})
.onBackgroundClick(() => {
emit('clearSelection')
})
configureGraph()
graphInstance.value.resumeAnimation?.()
startAutoRotate()
if (typeof window !== 'undefined') {
window.addEventListener('resize', syncGraphSize)
}
})
watch(graphData, () => {
syncGraphData()
}, { deep: true })
watch(() => props.selectedNodeId, () => {
applyNodePresentation()
applySelectionStyling()
})
onBeforeUnmount(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', syncGraphSize)
}
stopAutoRotate()
graphInstance.value?.pauseAnimation?.()
graphInstance.value = null
})
defineExpose({
zoomBy,
resetViewport,
})
</script>
<template>
<div ref="stageRef" class="brain-graph-canvas" data-testid="brain-graph-canvas"></div>
</template>
<style scoped>
.brain-graph-canvas {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 24px;
}
</style>

View File

@@ -0,0 +1,305 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi, type Mocked } from 'vitest'
vi.mock('@/api/graph', () => ({
graphApi: {
get: vi.fn(),
build: vi.fn(),
getEntityContext: vi.fn(),
},
}))
vi.mock('./BrainGraphCanvas.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'BrainGraphCanvas',
props: {
nodes: { type: Array, required: true },
edges: { type: Array, required: true },
selectedNodeId: { type: String, default: null },
getColor: { type: Function, required: true },
},
emits: ['select-node', 'clear-selection'],
setup(props: { nodes: Array<{ id?: string }>; edges: unknown[]; selectedNodeId?: string | null }, { emit, expose }) {
const zoomBy = vi.fn()
const resetViewport = vi.fn()
expose({ zoomBy, resetViewport })
return () => h('div', { 'data-testid': 'brain-canvas-stub' }, [
h('button', {
'data-testid': 'brain-canvas-select-first',
onClick: () => emit('select-node', props.nodes[0]?.id),
}, 'select-first'),
h('button', {
'data-testid': 'brain-canvas-clear',
onClick: () => emit('clear-selection'),
}, 'clear-selection'),
h('span', { 'data-testid': 'brain-canvas-node-count' }, String(props.nodes.length)),
h('span', { 'data-testid': 'brain-canvas-edge-count' }, String(props.edges.length)),
h('span', { 'data-testid': 'brain-canvas-selected' }, props.selectedNodeId || 'none'),
])
},
}),
}
})
import GraphProjection from './GraphProjection.vue'
import { graphApi } from '@/api/graph'
const mockedGraphApi = graphApi as Mocked<typeof graphApi>
const liveGraphPayload = {
data: {
nodes: [
{
id: 'node-1',
name: 'Jarvis 架构手册',
type: 'knowledge',
description: '系统架构文档',
importance: 0.95,
created_at: '2026-03-21T09:30:00Z',
},
{
id: 'node-2',
name: '改版需求确认会话',
type: 'chat',
description: '需求澄清',
importance: 0.82,
created_at: '2026-03-26T03:18:00Z',
},
{
id: 'node-3',
name: '知识工作台案例观察',
type: 'forum',
description: '案例观察',
importance: 0.62,
created_at: '2026-03-19T11:00:00Z',
},
],
edges: [
{
id: 'edge-1',
source: 'node-1',
target: 'node-2',
relation: '支撑讨论',
},
{
id: 'edge-2',
source: 'node-2',
target: 'node-3',
relation: '关联观察',
},
],
stats: {
node_count: 3,
edge_count: 2,
},
},
}
function mountProjection(props: Record<string, unknown> = {}) {
return mount(GraphProjection, {
props,
global: {
stubs: {
RouterLink: {
template: '<a><slot /></a>',
},
},
},
})
}
describe('GraphProjection', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedGraphApi.get.mockResolvedValue(liveGraphPayload as never)
mockedGraphApi.build.mockResolvedValue({ data: { message: 'ok' } } as never)
mockedGraphApi.getEntityContext.mockResolvedValue({ data: { context: 'context' } } as never)
})
it('renders the whiteboard shell with graph stage and idle lock prompt', async () => {
const wrapper = mountProjection({
fullscreen: true,
showOpenFullView: false,
})
await flushPromises()
expect(wrapper.get('[data-testid="brain-projection-shell"]')).toBeTruthy()
expect(wrapper.get('[data-testid="brain-telemetry-rail"]')).toBeTruthy()
expect(wrapper.get('[data-testid="brain-graph-card"]')).toBeTruthy()
expect(wrapper.get('[data-testid="brain-whiteboard-stage"]')).toBeTruthy()
expect(wrapper.get('[data-testid="brain-detail-empty"]')).toBeTruthy()
expect(wrapper.find('[data-testid="brain-detail-panel"]').exists()).toBe(false)
expect(wrapper.get('[data-testid="brain-canvas-stub"]')).toBeTruthy()
})
it('shows the open full view action only when enabled', async () => {
const hiddenWrapper = mountProjection({ showOpenFullView: false })
await flushPromises()
expect(hiddenWrapper.text()).not.toContain('全屏查看')
const visibleWrapper = mountProjection({ showOpenFullView: true })
await flushPromises()
expect(visibleWrapper.text()).toContain('全屏查看')
})
it('falls back to the demo graph without rendering a mock warning ribbon', async () => {
mockedGraphApi.get.mockResolvedValue({
data: {
nodes: [],
edges: [],
stats: {
node_count: 0,
edge_count: 0,
},
},
} as never)
const wrapper = mountProjection()
await flushPromises()
expect(wrapper.text()).not.toContain('MOCK SIGNAL')
expect(wrapper.text()).not.toContain('Mock')
expect(wrapper.get('[data-testid="brain-telemetry-clusters"]')).toBeTruthy()
})
it('selects a node from the renderer and opens the floating inspector', async () => {
const wrapper = mountProjection()
await flushPromises()
await wrapper.get('[data-testid="brain-canvas-select-first"]').trigger('click')
await flushPromises()
expect(mockedGraphApi.getEntityContext).toHaveBeenCalledWith('Jarvis 架构手册')
expect(wrapper.find('[data-testid="brain-detail-empty"]').exists()).toBe(false)
expect(wrapper.get('[data-testid="brain-detail-panel"]').text()).toContain('Jarvis 架构手册')
expect(wrapper.text()).toContain('INGEST SIGNAL')
expect(wrapper.text()).toContain('ENTITY BRIEF')
expect(wrapper.get('[data-testid="brain-canvas-selected"]').text()).toContain('node-1')
})
it('filters visible graph data by importance and shows empty state when no nodes match', async () => {
const wrapper = mountProjection()
await flushPromises()
await wrapper.get('[data-testid="brain-filter-importance-high"]').trigger('click')
await flushPromises()
expect(wrapper.get('[data-testid="brain-canvas-node-count"]').text()).toContain('1')
expect(wrapper.get('[data-testid="brain-canvas-edge-count"]').text()).toContain('0')
mockedGraphApi.get.mockResolvedValue({
data: {
nodes: [
{
id: 'node-low',
name: '低优先级线索',
type: 'forum',
description: 'low',
importance: 0.4,
created_at: '2026-03-19T11:00:00Z',
},
],
edges: [],
stats: {
node_count: 1,
edge_count: 0,
},
},
} as never)
const lowOnlyWrapper = mountProjection()
await flushPromises()
await lowOnlyWrapper.get('[data-testid="brain-filter-importance-high"]').trigger('click')
await flushPromises()
expect(lowOnlyWrapper.get('[data-testid="brain-empty-state"]')).toBeTruthy()
})
it('switches to demo preview mode and renders richer simulated graph data', async () => {
const wrapper = mountProjection()
await flushPromises()
expect(wrapper.get('[data-testid="brain-canvas-node-count"]').text()).toContain('3')
expect(wrapper.get('[data-testid="brain-preview-badge"]').text()).toContain('LIVE GRID')
await wrapper.get('[data-testid="brain-preview-toggle"]').trigger('click')
await flushPromises()
expect(wrapper.get('[data-testid="brain-preview-badge"]').text()).toContain('DEMO GRID')
expect(wrapper.get('[data-testid="brain-canvas-node-count"]').text()).toContain('50')
expect(wrapper.get('[data-testid="brain-canvas-edge-count"]').text()).toContain('76')
})
it('uses local summary when selecting a demo preview node', async () => {
const wrapper = mountProjection()
await flushPromises()
await wrapper.get('[data-testid="brain-preview-toggle"]').trigger('click')
await flushPromises()
await wrapper.get('[data-testid="brain-canvas-select-first"]').trigger('click')
await flushPromises()
expect(mockedGraphApi.getEntityContext).not.toHaveBeenCalledWith('Orchestrator Prime')
expect(wrapper.get('[data-testid="brain-detail-panel"]').text()).toContain('Orchestrator Prime')
expect(wrapper.text()).toContain('指挥中枢')
expect(wrapper.text()).toContain('负责汇总知识、会话、论坛与排程信号')
expect(wrapper.get('[data-testid="brain-canvas-selected"]').text()).toContain('demo-core-orchestrator')
})
it('returns from demo preview mode back to live graph data', async () => {
const wrapper = mountProjection()
await flushPromises()
expect(wrapper.get('[data-testid="brain-canvas-node-count"]').text()).toContain('3')
await wrapper.get('[data-testid="brain-preview-toggle"]').trigger('click')
await flushPromises()
expect(wrapper.get('[data-testid="brain-preview-badge"]').text()).toContain('DEMO GRID')
expect(wrapper.get('[data-testid="brain-canvas-node-count"]').text()).toContain('50')
await wrapper.get('[data-testid="brain-preview-toggle"]').trigger('click')
await flushPromises()
expect(wrapper.get('[data-testid="brain-preview-badge"]').text()).toContain('LIVE GRID')
expect(wrapper.get('[data-testid="brain-canvas-node-count"]').text()).toContain('3')
expect(wrapper.get('[data-testid="brain-canvas-edge-count"]').text()).toContain('2')
})
it('clears the lock when the inspector close button is pressed', async () => {
const wrapper = mountProjection()
await flushPromises()
await wrapper.get('[data-testid="brain-canvas-select-first"]').trigger('click')
await flushPromises()
expect(wrapper.get('[data-testid="brain-detail-panel"]').text()).toContain('Jarvis 架构手册')
await wrapper.get('[data-testid="brain-inspector-close"]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-testid="brain-detail-panel"]').exists()).toBe(false)
expect(wrapper.get('[data-testid="brain-detail-empty"]').text()).toContain('WAITING FOR TARGET LOCK')
})
it('clears selection when the renderer emits clear-selection', async () => {
const wrapper = mountProjection()
await flushPromises()
await wrapper.get('[data-testid="brain-canvas-select-first"]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-testid="brain-detail-panel"]').exists()).toBe(true)
await wrapper.get('[data-testid="brain-canvas-clear"]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-testid="brain-detail-panel"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="brain-detail-empty"]').exists()).toBe(true)
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,91 @@
import { reactive } from 'vue'
const MIN_SCALE = 0.65
const MAX_SCALE = 2.4
const DEFAULT_SCALE = 1
const DEFAULT_TILT_X = 0.18
const DEFAULT_TILT_Y = -0.1
const DEFAULT_ORBIT_ANGLE = 0
const DEFAULT_DEPTH_STRENGTH = 1
export interface BrainViewportState {
scale: number
translateX: number
translateY: number
tiltX: number
tiltY: number
orbitAngle: number
depthStrength: number
}
export function useBrainGraphViewport() {
const viewport = reactive<BrainViewportState>({
scale: DEFAULT_SCALE,
translateX: 0,
translateY: 0,
tiltX: DEFAULT_TILT_X,
tiltY: DEFAULT_TILT_Y,
orbitAngle: DEFAULT_ORBIT_ANGLE,
depthStrength: DEFAULT_DEPTH_STRENGTH,
})
function clampScale(nextScale: number) {
return Math.min(MAX_SCALE, Math.max(MIN_SCALE, nextScale))
}
function clampTilt(value: number) {
return Math.min(0.42, Math.max(-0.42, value))
}
function setScale(nextScale: number) {
viewport.scale = clampScale(nextScale)
}
function zoomBy(multiplier: number) {
setScale(viewport.scale * multiplier)
}
function panBy(deltaX: number, deltaY: number) {
viewport.translateX += deltaX
viewport.translateY += deltaY
}
function setTilt(nextTiltX: number, nextTiltY: number) {
viewport.tiltX = clampTilt(nextTiltX)
viewport.tiltY = clampTilt(nextTiltY)
}
function nudgeTilt(deltaX: number, deltaY: number) {
setTilt(viewport.tiltX + deltaX, viewport.tiltY + deltaY)
}
function setOrbitAngle(nextAngle: number) {
viewport.orbitAngle = nextAngle
}
function driftOrbit(delta: number) {
viewport.orbitAngle += delta
}
function resetViewport() {
viewport.scale = DEFAULT_SCALE
viewport.translateX = 0
viewport.translateY = 0
viewport.tiltX = DEFAULT_TILT_X
viewport.tiltY = DEFAULT_TILT_Y
viewport.orbitAngle = DEFAULT_ORBIT_ANGLE
viewport.depthStrength = DEFAULT_DEPTH_STRENGTH
}
return {
viewport,
driftOrbit,
nudgeTilt,
panBy,
resetViewport,
setOrbitAngle,
setScale,
setTilt,
zoomBy,
}
}

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { X, Loader, Terminal, ShieldCheck, Activity } from 'lucide-vue-next'
import { useKnowledgeView } from '@/pages/knowledge/composables/useKnowledgeView'
import './KnowledgeHud.css'
import { watch } from 'vue'
const props = defineProps<{
doc: any
}>()
const emit = defineEmits(['close'])
const {
activeDocumentContent,
isLoadingDocumentContent,
openDocument,
getFileTypeColor,
formatFileSize,
} = useKnowledgeView()
watch(() => props.doc, (newDoc) => {
if (newDoc) {
openDocument(newDoc)
}
}, { immediate: true })
</script>
<template>
<div class="knowledge-hud-preview" @click.self="emit('close')">
<div class="hud-frame-jarvis">
<!-- Outer scan decoration -->
<div class="tech-corner tl"></div>
<div class="tech-corner tr"></div>
<div class="tech-corner bl"></div>
<div class="tech-corner br"></div>
<header class="hud-header-jarvis">
<div class="header-left-tech">
<Terminal :size="16" class="glow-cyan" />
<div class="title-stack">
<span class="sub-kicker">SECURE_DATA_PROJECTOR // V3.0</span>
<h3 class="main-title-jarvis">{{ props.doc?.title.toUpperCase() }}</h3>
</div>
</div>
<div class="header-right-tech">
<div class="meta-pillar">
<span class="meta-label">STATUS</span>
<span class="meta-val cyan">ENCRYPTED</span>
</div>
<div class="meta-pillar">
<span class="meta-label">TYPE</span>
<span class="meta-val amber">{{ props.doc?.file_type.toUpperCase() }}</span>
</div>
<button class="close-jarvis-btn" @click="emit('close')">ABORT_X</button>
</div>
</header>
<div class="hud-body-jarvis slim-scroll">
<div v-if="isLoadingDocumentContent" class="decoding-screen">
<Activity :size="40" class="pulse-tech" />
<span class="decoding-text">ANALYZING_BYTESTREAM...</span>
<div class="progress-bar-tech"><div class="progress-fill"></div></div>
</div>
<div v-else class="content-matrix-wrap">
<div class="line-numbers">
<div v-for="n in 20" :key="n" class="ln">{{ (n * 100).toString(16).toUpperCase() }}</div>
</div>
<pre class="preview-content-jarvis">{{ activeDocumentContent || '--- NO_DATA_STREAMS_DETECTED ---' }}</pre>
</div>
</div>
<footer class="hud-footer-jarvis">
<div class="footer-left">
<ShieldCheck :size="12" />
<span>AUTHORIZED_ACCESS_ONLY // UID_0x88219</span>
</div>
<div class="footer-right">
<span>PARSING_COMPLETE</span>
<div class="bit-dot"></div>
</div>
</footer>
</div>
</div>
</template>
<style scoped>
.hud-header-jarvis { padding: 30px; display: flex; justify-content: space-between; align-items: flex-start; background: rgba(0, 245, 212, 0.05); border-bottom: 1px solid rgba(0, 245, 212, 0.2); }
.header-left-tech { display: flex; gap: 20px; align-items: center; }
.title-stack { display: flex; flex-direction: column; gap: 4px; }
.sub-kicker { font-family: var(--font-mono); font-size: 9px; color: var(--jarvis-cyan); letter-spacing: 0.3em; }
.main-title-jarvis { font-family: var(--font-display); font-size: 26px; color: #fff; margin: 0; text-shadow: 0 0 20px rgba(0, 245, 212, 0.4); }
.header-right-tech { display: flex; gap: 30px; align-items: center; }
.meta-pillar { display: flex; flex-direction: column; gap: 4px; }
.meta-label { font-family: var(--font-mono); font-size: 8px; color: var(--text-dim); }
.meta-val { font-family: var(--font-display); font-size: 11px; font-weight: bold; }
.meta-val.cyan { color: var(--jarvis-cyan); }
.meta-val.amber { color: var(--jarvis-amber); }
.close-jarvis-btn { background: var(--jarvis-amber); color: #000; border: none; font-family: var(--font-display); font-size: 12px; font-weight: 900; padding: 12px 25px; cursor: pointer; transition: 0.3s; }
.close-jarvis-btn:hover { background: #fff; box-shadow: 0 0 20px #fff; }
.hud-body-jarvis { flex: 1; position: relative; overflow-y: auto; background: rgba(0,0,0,0.4); }
.content-matrix-wrap { display: flex; height: 100%; }
.line-numbers { padding: 30px 15px; border-right: 1px solid rgba(255,255,255,0.05); background: rgba(0,0,0,0.2); }
.ln { font-family: var(--font-mono); font-size: 9px; color: var(--text-muted); margin-bottom: 15px; }
.preview-content-jarvis { flex: 1; margin: 0; padding: 30px; font-family: 'Share Tech Mono', monospace; font-size: 16px; line-height: 1.8; color: var(--jarvis-cyan); opacity: 0.8; }
.hud-footer-jarvis { padding: 15px 30px; border-top: 1px solid rgba(255,255,255,0.05); display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 9px; color: var(--text-dim); letter-spacing: 0.1em; }
.footer-right { display: flex; align-items: center; gap: 10px; color: var(--jarvis-cyan); }
.bit-dot { width: 6px; height: 6px; background: var(--jarvis-cyan); box-shadow: 0 0 8px var(--jarvis-cyan); animation: pulse 1s infinite; }
.decoding-screen { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 25px; color: var(--jarvis-cyan); }
.decoding-text { font-family: var(--font-mono); font-size: 14px; letter-spacing: 0.4em; }
.progress-bar-tech { width: 300px; height: 2px; background: rgba(0, 245, 212, 0.1); position: relative; overflow: hidden; }
.progress-fill { position: absolute; left: 0; top: 0; height: 100%; width: 50%; background: var(--jarvis-cyan); animation: progress-move 2s infinite; }
@keyframes progress-move { 0% { left: -50%; } 100% { left: 100%; } }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
</style>

View File

@@ -0,0 +1,199 @@
/* Knowledge Hud - Jarvis Integrated Station */
:root {
--jarvis-cyan: #00f5d4;
--jarvis-amber: #f9a825;
--jarvis-bg: rgba(2, 10, 20, 0.98);
--jarvis-border: 1px solid rgba(0, 245, 212, 0.4);
--hex-pattern: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMzQuNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMTAgMEwwIDUuN3YxMS41TDEwIDIzbDEwLTUuN1Y1LjdMMTAgMHpNMTAgMjJsLTgtNC42VjYuOEwxMCAyLjJsOCA0LjZ2MTAuNkwxMCAyMnoiIGZpbGw9IiMwMGY1ZDQiIGZpbGwtb3BhY2l0eT0iMC4wNSIvPjwvc3ZnPg==');
}
/* 全屏遮罩层 */
.jarvis-hud-overlay {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 5, 10, 0.85);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
/* 一体化 HUD 主容器 */
.jarvis-integrated-station {
width: 100%;
max-width: 1100px;
height: 700px;
background: var(--jarvis-bg);
background-image: var(--hex-pattern);
border: var(--jarvis-border);
display: flex;
flex-direction: column;
position: relative;
box-shadow: 0 0 100px rgba(0, 245, 212, 0.15);
}
/* 装饰性科技支架 */
.jarvis-integrated-station::before,
.jarvis-integrated-station::after {
content: '';
position: absolute;
width: 30px;
height: 30px;
border: 3px solid var(--jarvis-cyan);
pointer-events: none;
}
.jarvis-integrated-station::before { top: -1px; left: -1px; border-right: none; border-bottom: none; }
.jarvis-integrated-station::after { bottom: -1px; right: -1px; border-left: none; border-top: none; }
/* 顶部标题栏 */
.station-header {
padding: 20px 30px;
background: linear-gradient(90deg, rgba(0, 245, 212, 0.1), transparent);
border-bottom: 1px solid rgba(0, 245, 212, 0.2);
display: flex;
justify-content: space-between;
align-items: center;
}
.station-title-group {
display: flex;
align-items: center;
gap: 15px;
}
.station-title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 900;
color: #fff;
letter-spacing: 0.1em;
margin: 0;
}
/* 主体内容区布局 */
.station-body {
flex: 1;
display: flex;
overflow: hidden;
}
/* 左侧栏:目录树 */
.station-sidebar {
width: 280px;
border-right: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
}
.sidebar-label {
padding: 15px 25px;
font-family: var(--font-mono);
font-size: 9px;
color: var(--jarvis-cyan);
opacity: 0.6;
letter-spacing: 0.2em;
}
.folder-nav-list {
flex: 1;
overflow-y: auto;
padding: 10px;
}
.nav-item-jarvis {
width: 100%;
padding: 12px 15px;
display: flex;
align-items: center;
gap: 12px;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.7);
font-family: var(--font-display);
font-size: 13px;
text-align: left;
cursor: pointer;
transition: 0.2s;
}
.nav-item-jarvis:hover, .nav-item-jarvis.active {
background: rgba(0, 245, 212, 0.1);
color: var(--jarvis-cyan);
padding-left: 20px;
}
/* 右侧内容区 */
.station-main-view {
flex: 1;
display: flex;
flex-direction: column;
background: rgba(0, 0, 0, 0.1);
}
.view-toolbar {
padding: 15px 25px;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
display: flex;
justify-content: space-between;
align-items: center;
}
.view-content-scroll {
flex: 1;
overflow-y: auto;
padding: 25px;
}
/* 文件网格 */
.data-grid-jarvis {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 15px;
}
.data-card-jarvis {
padding: 15px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
gap: 15px;
cursor: pointer;
transition: 0.2s;
}
.data-card-jarvis:hover {
border-color: var(--jarvis-cyan);
background: rgba(0, 245, 212, 0.05);
transform: translateY(-2px);
}
/* 底部状态栏 */
.station-footer {
padding: 10px 30px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
display: flex;
justify-content: space-between;
}
/* 按钮通用 */
.jarvis-btn {
background: transparent;
border: 1px solid var(--jarvis-cyan);
color: var(--jarvis-cyan);
padding: 6px 12px;
font-family: var(--font-display);
font-size: 10px;
text-transform: uppercase;
cursor: pointer;
}
.jarvis-btn:hover { background: var(--jarvis-cyan); color: #000; }
.jarvis-btn.amber { border-color: var(--jarvis-amber); color: var(--jarvis-amber); }
.jarvis-btn.amber:hover { background: var(--jarvis-amber); color: #000; }

View File

@@ -0,0 +1,260 @@
<script setup lang="ts">
import { ChevronRight, Database, FileText, FolderOpen, FolderPlus, Loader, Upload, X } from 'lucide-vue-next'
import FolderTree from '@/components/FolderTree.vue'
import { useKnowledgeView } from '@/pages/knowledge/composables/useKnowledgeView'
const {
folders, documents, currentFolderId, uploadError, uploadSuccess, highlightedDocumentId, uploadInput,
showNewFolderDialog, newFolderName, showRenameDialog, renameFolderName, showDeleteDialog, deletingFolder,
showDocumentDialog, activeDocument, activeDocumentContent, isLoadingDocumentContent, isLoadingDocuments,
isRoot, visibleFolders, breadcrumbs, currentFolder, enterFolder, goToFolder, triggerUpload, handleUpload,
handleDeleteDocument, openNewFolderDialog, createFolder, openRenameDialog, renameFolder, openDeleteDialog,
deleteFolder, openDocument, closeDocumentDialog, getFileTypeColor, formatFileSize, formatDate, getStatusLabel,
} = useKnowledgeView()
</script>
<template>
<div class="knowledge-hud-panel">
<input ref="uploadInput" type="file" class="hidden-upload" accept=".pdf,.md,.txt,.doc,.docx,.csv,.xlsx" @change="handleUpload" />
<div class="hud-toolbar">
<div class="hud-toolbar-left">
<div class="hud-badge"><Database :size="14" /><span>ARCHIVE CORE</span></div>
<div class="hud-breadcrumbs">
<button
v-for="(crumb, index) in breadcrumbs"
:key="`${crumb.id ?? 'root'}-${index}`"
class="hud-crumb"
:class="{ active: crumb.id === currentFolderId || (crumb.id === null && isRoot) }"
@click="goToFolder(crumb.id)"
>
<span>{{ crumb.name }}</span>
<ChevronRight v-if="index < breadcrumbs.length - 1" :size="12" />
</button>
</div>
</div>
<div class="hud-toolbar-actions">
<button class="hud-btn" @click="openNewFolderDialog(currentFolderId)"><FolderPlus :size="14" /><span>NEW FOLDER</span></button>
<button class="hud-btn primary" :disabled="isRoot" @click="triggerUpload"><Upload :size="14" /><span>UPLOAD FILE</span></button>
</div>
</div>
<div v-if="uploadError" class="hud-alert error">{{ uploadError }}</div>
<div v-if="uploadSuccess" class="hud-alert success">{{ uploadSuccess }}</div>
<div class="explorer-shell">
<aside class="folder-sidebar">
<div class="pane-head">
<div class="pane-title">DIRECTORIES</div>
<button class="sidebar-action" @click="openNewFolderDialog(null)">ROOT NODE</button>
</div>
<div v-if="folders.length" class="folder-list tree-host">
<FolderTree
:folders="folders"
:selected-id="currentFolderId"
:on-select="enterFolder"
:on-create="openNewFolderDialog"
:on-rename="openRenameDialog"
:on-delete="openDeleteDialog"
/>
</div>
<div v-else class="pane-empty">
<FolderOpen :size="22" />
<span>NO DIRECTORIES</span>
</div>
</aside>
<section class="content-pane">
<div class="pane-head content-head">
<div>
<div class="pane-title">CONTENTS</div>
<div class="content-subtitle">{{ currentFolder?.name || 'ROOT DIRECTORY' }}</div>
</div>
<div class="content-meta">
<span>{{ visibleFolders.length }} folders</span>
<span>{{ documents.length }} files</span>
</div>
</div>
<div class="content-table">
<div class="content-table-head">
<span>NAME</span>
<span>TYPE</span>
<span>DATE</span>
<span>SIZE</span>
<span>STATUS</span>
<span>ACTIONS</span>
</div>
<div v-if="isLoadingDocuments" class="pane-empty loading-state">
<Loader :size="16" class="spin" />
<span>LOADING DIRECTORY...</span>
</div>
<div v-else-if="visibleFolders.length === 0 && documents.length === 0" class="pane-empty">
<FolderOpen :size="24" />
<span>DIRECTORY EMPTY</span>
</div>
<div v-else class="content-table-body">
<button v-for="folder in visibleFolders" :key="folder.id" class="content-row folder-row" @click="enterFolder(folder)">
<span class="cell name-cell">
<span class="glyph folder"><FolderOpen :size="14" /></span>
<span class="entry-name">{{ folder.name }}</span>
</span>
<span class="cell">FOLDER</span>
<span class="cell">--</span>
<span class="cell">--</span>
<span class="cell"><span class="status-chip ready">READY</span></span>
<span class="cell row-actions">
<button class="inline-btn" @click.stop="openRenameDialog(folder)">RENAME</button>
<button class="inline-btn danger" @click.stop="openDeleteDialog(folder)">DELETE</button>
</span>
</button>
<button
v-for="doc in documents"
:key="doc.id"
class="content-row"
:class="{ highlight: highlightedDocumentId === doc.id }"
@click="openDocument(doc)"
>
<span class="cell name-cell">
<span class="glyph" :style="{ color: getFileTypeColor(doc.file_type) }"><FileText :size="14" /></span>
<span class="entry-name">{{ doc.title }}</span>
</span>
<span class="cell">{{ doc.file_type.toUpperCase() }}</span>
<span class="cell">{{ formatDate(doc.created_at) }}</span>
<span class="cell">{{ formatFileSize(doc.file_size) }}</span>
<span class="cell">
<span class="status-chip" :class="(doc.ingestion_status ?? (doc.is_indexed ? 'ready' : 'uploaded')).toLowerCase()">
{{ getStatusLabel(doc.ingestion_status, doc.is_indexed) }}
</span>
</span>
<span class="cell row-actions">
<button class="inline-btn danger" @click.stop="handleDeleteDocument(doc.id)">DELETE</button>
</span>
</button>
</div>
</div>
</section>
</div>
<div v-if="showNewFolderDialog" class="dialog-overlay" @click.self="showNewFolderDialog = false">
<div class="dialog-card">
<div class="dialog-head"><span>CREATE DIRECTORY</span><button class="dialog-close" @click="showNewFolderDialog = false"><X :size="14" /></button></div>
<input v-model="newFolderName" class="dialog-input" placeholder="DIRECTORY NAME" @keyup.enter="createFolder" />
<div class="dialog-actions"><button class="hud-btn" @click="showNewFolderDialog = false">CANCEL</button><button class="hud-btn primary" @click="createFolder">CREATE</button></div>
</div>
</div>
<div v-if="showRenameDialog" class="dialog-overlay" @click.self="showRenameDialog = false">
<div class="dialog-card">
<div class="dialog-head"><span>RENAME DIRECTORY</span><button class="dialog-close" @click="showRenameDialog = false"><X :size="14" /></button></div>
<input v-model="renameFolderName" class="dialog-input" placeholder="DIRECTORY NAME" @keyup.enter="renameFolder" />
<div class="dialog-actions"><button class="hud-btn" @click="showRenameDialog = false">CANCEL</button><button class="hud-btn primary" @click="renameFolder">SAVE</button></div>
</div>
</div>
<div v-if="showDeleteDialog && deletingFolder" class="dialog-overlay" @click.self="showDeleteDialog = false">
<div class="dialog-card">
<div class="dialog-head"><span>DELETE DIRECTORY</span><button class="dialog-close" @click="showDeleteDialog = false"><X :size="14" /></button></div>
<div class="dialog-copy">DELETE <strong>{{ deletingFolder.name }}</strong> AND ITS CONTENTS?</div>
<div class="dialog-actions"><button class="hud-btn" @click="showDeleteDialog = false">CANCEL</button><button class="hud-btn danger" @click="deleteFolder">DELETE</button></div>
</div>
</div>
<div v-if="showDocumentDialog && activeDocument" class="dialog-overlay hud-preview" @click.self="closeDocumentDialog()">
<div class="dialog-card document-card">
<div class="dialog-head"><span>{{ activeDocument.title }}</span><button class="dialog-close" @click="closeDocumentDialog()"><X :size="14" /></button></div>
<div class="document-meta">
<span>{{ activeDocument.file_type.toUpperCase() }}</span>
<span>{{ formatFileSize(activeDocument.file_size) }}</span>
<span>{{ formatDate(activeDocument.created_at) }}</span>
</div>
<div v-if="isLoadingDocumentContent" class="pane-empty loading-state"><Loader :size="16" class="spin" /><span>LOADING DOCUMENT...</span></div>
<pre v-else class="document-content">{{ activeDocumentContent || 'NO PREVIEW AVAILABLE.' }}</pre>
</div>
</div>
</div>
</template>
<style scoped>
.knowledge-hud-panel{height:100%;display:flex;flex-direction:column;gap:14px;padding:14px 16px 16px;color:var(--text-primary);position:relative}
.knowledge-hud-panel::before,.knowledge-hud-panel::after{content:'';position:absolute;inset:0;pointer-events:none}
.knowledge-hud-panel::before{background:repeating-linear-gradient(180deg,rgba(125,211,252,.01) 0,rgba(125,211,252,.01) 1px,transparent 1px,transparent 10px);opacity:.12}
.knowledge-hud-panel::after{background:radial-gradient(circle at 14% 18%,rgba(34,211,238,.05),transparent 24%),radial-gradient(circle at 82% 14%,rgba(245,158,11,.04),transparent 22%);opacity:.72}
.hidden-upload{display:none}
.hud-toolbar,.explorer-shell,.dialog-card{position:relative;z-index:1;border:1px solid rgba(110,231,255,.18);background:linear-gradient(180deg,rgba(8,15,28,.78),rgba(5,10,18,.64));box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 18px 48px rgba(2,6,14,.26);backdrop-filter:blur(14px) saturate(115%)}
.hud-toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:12px 14px;overflow:hidden}
.hud-toolbar-left,.hud-toolbar-actions,.hud-badge,.hud-breadcrumbs,.hud-crumb,.pane-head,.dialog-head,.dialog-actions,.document-meta{display:flex;align-items:center}
.hud-toolbar-left{gap:14px;min-width:0}
.hud-toolbar-actions{gap:10px}
.hud-badge,.pane-title,.content-meta,.sidebar-action,.content-table-head,.status-chip,.inline-btn,.document-meta,.hud-crumb{font-family:'Share Tech Mono','Orbitron',monospace}
.content-subtitle,.dialog-head,.entry-name,.folder-list :deep(.folder-name){font-family:'Orbitron','Share Tech Mono',monospace}
.hud-badge{gap:8px;padding:6px 10px;border:1px solid rgba(34,211,238,.18);background:rgba(12,26,40,.54);color:#d8fbff;font-size:10px;letter-spacing:.16em}
.hud-breadcrumbs{gap:6px;min-width:0;flex-wrap:wrap}
.hud-crumb{gap:6px;border:none;background:transparent;color:var(--text-dim);font-size:10px;cursor:pointer;letter-spacing:.1em}
.hud-crumb.active{color:#d8fbff}
.hud-btn,.sidebar-action,.inline-btn{display:inline-flex;align-items:center;justify-content:center;cursor:pointer;transition:border-color var(--transition-fast),background var(--transition-fast),transform var(--transition-fast)}
.hud-btn{gap:8px;padding:8px 12px;border:1px solid rgba(148,163,184,.18);background:rgba(9,16,30,.42);color:var(--text-primary);font-family:'Share Tech Mono','Orbitron',monospace;letter-spacing:.12em}
.hud-btn.primary{border-color:rgba(34,211,238,.18);color:#d8fbff}
.hud-btn.danger,.inline-btn.danger{border-color:rgba(255,71,87,.24);color:#fda4af}
.hud-btn:hover:not(:disabled),.sidebar-action:hover,.inline-btn:hover{transform:translateY(-1px);border-color:rgba(34,211,238,.24);background:rgba(14,24,40,.58)}
.hud-btn:disabled{opacity:.45;cursor:not-allowed}
.hud-alert{position:relative;z-index:1;padding:9px 12px;font-size:12px;font-family:'Share Tech Mono','Orbitron',monospace;letter-spacing:.08em}
.hud-alert.error{background:rgba(127,29,29,.28);color:#fecaca}
.hud-alert.success{background:rgba(20,83,45,.28);color:#bbf7d0}
.explorer-shell{display:grid;grid-template-columns:280px 1fr;gap:0;flex:1;min-height:0;overflow:hidden}
.folder-sidebar{border-right:1px solid rgba(110,231,255,.1);background:linear-gradient(180deg,rgba(8,14,24,.62),rgba(6,10,16,.4));display:flex;flex-direction:column;min-height:0}
.content-pane{display:flex;flex-direction:column;min-height:0;background:linear-gradient(180deg,rgba(8,16,28,.28),rgba(5,10,18,.14))}
.pane-head{justify-content:space-between;gap:12px;padding:14px 16px 10px}
.pane-title{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:rgba(186,230,253,.78)}
.content-subtitle{margin-top:6px;color:#d8fbff;font-size:14px;letter-spacing:.12em;text-transform:uppercase}
.content-meta{display:inline-flex;gap:10px;color:var(--text-dim);font-size:10px;letter-spacing:.08em}
.sidebar-action{padding:0;border:none;background:transparent;color:#93f7ff;font-size:10px;letter-spacing:.14em}
.folder-list{padding:0 12px 14px;overflow:auto}
.tree-host :deep(.folder-tree){font-size:12px}
.tree-host :deep(.folder-row){padding:8px 10px;gap:8px;border-radius:0}
.tree-host :deep(.folder-row:hover){background:rgba(34,211,238,.06)}
.tree-host :deep(.folder-row.selected){background:rgba(34,211,238,.08);border:1px solid rgba(34,211,238,.28);box-shadow:inset 3px 0 0 #22d3ee}
.tree-host :deep(.folder-icon){color:#fbbf24}
.tree-host :deep(.folder-name){font-family:'Orbitron','Share Tech Mono',monospace;letter-spacing:.06em}
.tree-host :deep(.folder-actions button){font-family:'Share Tech Mono','Orbitron',monospace}
.tree-host :deep(.folder-children){padding-left:18px}
.glyph,.tree-host :deep(.folder-icon){flex-shrink:0}
.glyph{width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;background:rgba(10,22,36,.68);border:1px solid rgba(34,211,238,.16);color:#93f7ff}
.entry-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;letter-spacing:.06em}
.content-table{flex:1;min-height:0;display:flex;flex-direction:column;padding:0 16px 16px}
.content-table-head,.content-row{display:grid;grid-template-columns:minmax(220px,2fr) 90px 110px 90px 110px 110px;gap:12px;align-items:center}
.content-table-head{padding:10px 14px;border-bottom:1px solid rgba(148,163,184,.12);color:rgba(186,230,253,.78);font-size:10px;letter-spacing:.14em;text-transform:uppercase}
.content-table-body{flex:1;overflow:auto}
.content-row{width:100%;padding:12px 14px;border:none;border-bottom:1px solid rgba(148,163,184,.08);background:rgba(8,14,26,.08);color:var(--text-secondary);text-align:left;cursor:pointer;transition:background var(--transition-fast),box-shadow var(--transition-fast)}
.content-row:hover,.content-row.highlight{background:rgba(34,211,238,.06);box-shadow:inset 3px 0 0 #22d3ee,0 0 0 1px rgba(34,211,238,.08)}
.cell{min-width:0;font-size:12px}
.name-cell{display:flex;align-items:center;gap:10px}
.glyph.folder{color:#fbbf24}
.status-chip{display:inline-flex;padding:4px 8px;border:1px solid rgba(148,163,184,.12);background:rgba(8,14,26,.24);font-size:9px;letter-spacing:.12em}
.status-chip.ready{color:#86efac}.status-chip.failed{color:#fda4af}.status-chip.warning,.status-chip.uploaded,.status-chip.parsing,.status-chip.indexing{color:#fde68a}
.row-actions{display:inline-flex;gap:10px}
.inline-btn{padding:0;border:none;background:transparent;color:#93f7ff;font-size:10px;letter-spacing:.14em}
.pane-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--text-dim);text-align:center;font-family:'Share Tech Mono','Orbitron',monospace;letter-spacing:.14em}
.loading-state{min-height:180px}
.dialog-overlay{position:absolute;inset:0;z-index:8;display:flex;align-items:center;justify-content:center;background:rgba(2,6,14,.34);backdrop-filter:blur(10px)}
.dialog-overlay.hud-preview{background:radial-gradient(circle at center,rgba(34,211,238,.08),transparent 24%),rgba(2,6,14,.48)}
.dialog-card{width:min(520px,calc(100vw - 48px));padding:16px;overflow:hidden}
.document-card{width:min(900px,calc(100vw - 72px))}
.dialog-head{justify-content:space-between;gap:12px;margin-bottom:14px;letter-spacing:.14em;color:#d8fbff;text-transform:uppercase}
.dialog-close{border:none;background:transparent;color:var(--text-dim);cursor:pointer}
.dialog-input,.document-content{width:100%;border:1px solid rgba(148,163,184,.16);background:rgba(7,13,24,.34);color:var(--text-primary);backdrop-filter:blur(12px)}
.dialog-input{padding:12px 14px;margin-bottom:14px;font-family:'Share Tech Mono','Orbitron',monospace;letter-spacing:.1em}
.dialog-copy{margin-bottom:16px;color:var(--text-secondary);font-family:'Share Tech Mono','Orbitron',monospace;letter-spacing:.08em}
.dialog-actions{gap:14px;justify-content:flex-end;padding-top:4px}
.dialog-actions .hud-btn{min-width:124px;padding:10px 16px}
.document-meta{justify-content:flex-start;gap:12px;margin-bottom:12px;color:var(--text-dim);font-size:10px;letter-spacing:.12em}
.document-content{min-height:360px;max-height:min(60vh,680px);overflow:auto;padding:14px;white-space:pre-wrap;line-height:1.6;font-family:'Share Tech Mono','Orbitron',monospace}
.spin{animation:spin 1s linear infinite}
@media (max-width:900px){.explorer-shell{grid-template-columns:1fr}.folder-sidebar{max-height:220px;border-right:none;border-bottom:1px solid rgba(34,211,238,.12)}.content-table-head,.content-row{grid-template-columns:minmax(180px,2fr) 80px 90px 90px}.content-table-head span:nth-child(5),.content-table-head span:nth-child(6),.content-row .cell:nth-child(5),.content-row .cell:nth-child(6){display:none}}
</style>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { ChevronRight, Loader, Hash, Activity, Plus } from 'lucide-vue-next'
import { useKnowledgeView } from '@/pages/knowledge/composables/useKnowledgeView'
import './KnowledgeHud.css'
const { folders, isLoadingDocuments, openNewFolderDialog } = useKnowledgeView()
const emit = defineEmits<{
(e: 'select-folder', folder: any): void
}>()
function handleSelect(folder: any) {
emit('select-folder', folder)
}
</script>
<template>
<div class="knowledge-launcher">
<div class="launcher-header-jarvis">
<div class="header-main">
<Hash :size="10" class="tech-icon" />
<span class="launcher-title">DIRECTORY_0x{{ folders.length }}</span>
</div>
<button class="action-btn-jarvis" @click="openNewFolderDialog(null)">
<Plus :size="10" />
<span>Init_Node</span>
</button>
</div>
<div class="folder-scroll">
<button
v-for="(folder, index) in folders"
:key="folder.id"
class="folder-node-jarvis"
@click="handleSelect(folder)"
>
<span class="node-coord">[{{ index.toString().padStart(2, '0') }}]</span>
<span class="node-name">{{ folder.name }}</span>
<Activity :size="10" class="tech-activity" />
</button>
</div>
<div class="launcher-footer-tech">
<div class="status-pulse"></div>
<span>SYSTEM_STREAM_ACTIVE</span>
</div>
</div>
</template>
<style scoped>
.header-main { display: flex; align-items: center; gap: 6px; }
.tech-icon { color: var(--jarvis-cyan); }
.node-coord { font-family: var(--font-mono); font-size: 8px; color: var(--jarvis-cyan); opacity: 0.5; }
.node-name { flex: 1; font-family: var(--font-display); font-size: 12px; letter-spacing: 0.05em; }
.tech-activity { opacity: 0.3; color: var(--jarvis-cyan); }
.launcher-footer-tech { padding: 6px 12px; display: flex; align-items: center; gap: 8px; font-family: var(--font-mono); font-size: 7px; color: var(--text-dim); border-top: 1px solid rgba(255,255,255,0.05); }
.status-pulse { width: 4px; height: 4px; border-radius: 50%; background: var(--jarvis-cyan); box-shadow: 0 0 5px var(--jarvis-cyan); animation: pulse 2s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
</style>

View File

@@ -0,0 +1,155 @@
<script setup lang="ts">
import {
X,
ChevronRight,
ArrowLeft,
FileCode,
Box,
Activity,
Upload,
FolderPlus,
Compass
} from 'lucide-vue-next'
import { useKnowledgeView } from '@/pages/knowledge/composables/useKnowledgeView'
import './KnowledgeHud.css'
import { watch } from 'vue'
const props = defineProps<{
folder: any
}>()
const emit = defineEmits(['close', 'open-preview', 'trigger-new-folder', 'trigger-upload'])
const {
documents,
visibleFolders,
isLoadingDocuments,
isRoot,
enterFolder,
goBack,
getFileTypeColor,
formatFileSize,
} = useKnowledgeView()
watch(() => props.folder, (newFolder) => {
if (newFolder) {
enterFolder(newFolder)
}
}, { immediate: true })
function handleFileClick(doc: any) {
emit('open-preview', doc)
}
</script>
<template>
<div class="knowledge-slide-panel">
<header class="slide-header-jarvis">
<div class="header-top-tech">
<div class="coord-tag">
<Compass :size="10" />
<span>SEC_0x{{ props.folder?.id.substring(0,6).toUpperCase() }}</span>
</div>
<button class="close-btn-jarvis" @click="emit('close')"><X :size="16" /></button>
</div>
<div class="title-main-jarvis">
<button v-if="!isRoot" class="back-btn-jarvis" @click="goBack"><ArrowLeft :size="14" /></button>
<h3 class="slide-title-jarvis">{{ props.folder?.name.toUpperCase() }}</h3>
</div>
</header>
<div class="action-row-jarvis">
<button class="action-btn-jarvis" @click="emit('trigger-new-folder', props.folder?.id)">
<FolderPlus :size="10" />
<span>New_Sub</span>
</button>
<button class="action-btn-jarvis amber" @click="emit('trigger-upload')">
<Upload :size="10" />
<span>Upload_Data</span>
</button>
</div>
<div class="slide-content slim-scroll">
<!-- Sub-sectors -->
<section v-if="visibleFolders.length" class="hud-section-jarvis">
<div class="matrix-label">
<span class="label-text">CHILD_NODES</span>
<div class="label-line"></div>
</div>
<div class="jarvis-grid">
<div
v-for="(sub, idx) in visibleFolders"
:key="sub.id"
class="hud-item-jarvis"
@click="enterFolder(sub)"
>
<span class="item-idx">{{ idx.toString(16).padStart(2, '0') }}</span>
<Box :size="14" class="item-icon-cyan" />
<span class="item-name-jarvis">{{ sub.name }}</span>
<ChevronRight :size="12" class="item-arrow-cyan" />
</div>
</div>
</section>
<!-- Data Objects -->
<section v-if="documents.length || isLoadingDocuments" class="hud-section-jarvis">
<div class="matrix-label">
<span class="label-text">DATA_STREAMS</span>
<div class="label-line"></div>
</div>
<div v-if="isLoadingDocuments" class="jarvis-loader">
<Activity :size="16" class="spin-tech" />
<span>QUERYING_BUFFER...</span>
</div>
<div v-else class="jarvis-grid">
<div
v-for="(doc, idx) in documents"
:key="doc.id"
class="hud-item-jarvis doc-style"
@click="handleFileClick(doc)"
>
<span class="item-idx">{{ (idx + 10).toString(16).toUpperCase() }}</span>
<FileCode :size="14" :style="{ color: getFileTypeColor(doc.file_type) }" />
<div class="item-info-jarvis">
<span class="item-name-jarvis">{{ doc.title }}</span>
<span class="item-meta-jarvis">{{ formatFileSize(doc.file_size) }} // {{ doc.file_type.toUpperCase() }}</span>
</div>
</div>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.header-top-tech { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.coord-tag { display: flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: 8px; color: var(--jarvis-cyan); opacity: 0.6; }
.title-main-jarvis { display: flex; align-items: center; gap: 12px; }
.close-btn-jarvis, .back-btn-jarvis { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: #fff; cursor: pointer; padding: 6px; transition: 0.2s; }
.close-btn-jarvis:hover { background: var(--accent-red); border-color: var(--accent-red); }
.back-btn-jarvis:hover { border-color: var(--jarvis-cyan); color: var(--jarvis-cyan); }
.action-btn-jarvis.amber { border-color: var(--jarvis-amber); color: var(--jarvis-amber); }
.action-btn-jarvis.amber:hover { background: var(--jarvis-amber); color: #000; }
.hud-section-jarvis { margin-bottom: 25px; }
.matrix-label { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.label-text { font-family: var(--font-display); font-size: 9px; color: var(--jarvis-cyan); letter-spacing: 0.2em; }
.label-line { flex: 1; height: 1px; background: linear-gradient(90deg, rgba(0, 245, 212, 0.3), transparent); }
.jarvis-grid { display: flex; flex-direction: column; gap: 6px; }
.item-idx { font-family: var(--font-mono); font-size: 8px; opacity: 0.3; color: var(--jarvis-cyan); width: 15px; }
.item-icon-cyan { color: var(--jarvis-cyan); }
.item-name-jarvis { flex: 1; font-family: var(--font-mono); font-size: 12px; color: rgba(255,255,255,0.9); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item-arrow-cyan { color: var(--jarvis-cyan); opacity: 0.5; }
.item-info-jarvis { display: flex; flex-direction: column; min-width: 0; }
.item-meta-jarvis { font-size: 8px; font-family: var(--font-mono); color: var(--jarvis-cyan); opacity: 0.4; margin-top: 2px; }
.jarvis-loader { display: flex; align-items: center; gap: 12px; color: var(--jarvis-cyan); font-family: var(--font-mono); font-size: 10px; padding: 20px; }
.spin-tech { animation: spin 2s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
</style>

View File

@@ -0,0 +1,68 @@
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MonitorEcgStrip from './MonitorEcgStrip.vue'
describe('MonitorEcgStrip', () => {
beforeEach(() => {
vi.useFakeTimers()
})
it('renders an ECG svg shell with a waveform path', () => {
const wrapper = mount(MonitorEcgStrip, {
props: {
mode: 'ready',
},
})
expect(wrapper.get('[data-testid="monitor-ecg-strip"]')).toBeTruthy()
expect(wrapper.get('[data-testid="monitor-ecg-svg"]')).toBeTruthy()
const path = wrapper.get('[data-testid="monitor-ecg-line"]')
expect(path.attributes('d')).toBeTruthy()
expect(path.attributes('d')?.length).toBeGreaterThan(50)
})
it('switches visual mode between ready and processing', async () => {
const wrapper = mount(MonitorEcgStrip, {
props: {
mode: 'ready',
},
})
expect(wrapper.classes()).toContain('mode-ready')
expect((wrapper.element as HTMLElement).style.getPropertyValue('--monitor-ecg-duration')).toBe('3s')
await wrapper.setProps({ mode: 'processing' })
await nextTick()
expect(wrapper.classes()).toContain('mode-processing')
expect((wrapper.element as HTMLElement).style.getPropertyValue('--monitor-ecg-duration')).toBe('1.6s')
})
it('animates with irregular heartbeat pulses', async () => {
const wrapper = mount(MonitorEcgStrip, {
props: {
mode: 'processing',
random: () => 0,
beatRangeMs: { min: 100, max: 100 },
},
})
const track = wrapper.get('.monitor-ecg-track')
expect(track.attributes('style') || '').toContain('translateY(0px) scaleY(1)')
vi.advanceTimersByTime(100)
await nextTick()
expect(track.attributes('style') || '').toContain('translateY(-1.4px) scaleY(1.12)')
vi.advanceTimersByTime(120)
await nextTick()
expect(track.attributes('style') || '').toContain('translateY(0.6px) scaleY(1.03)')
vi.advanceTimersByTime(110)
await nextTick()
expect(track.attributes('style') || '').toContain('translateY(0px) scaleY(1)')
})
})

View File

@@ -0,0 +1,251 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { buildMonitorEcgPath } from './monitorEcg'
type BeatRangeMs = {
min: number
max: number
}
let patternIdCounter = 0
const props = withDefaults(defineProps<{
mode: 'ready' | 'processing'
width?: number
height?: number
ariaLabel?: string
beatEnabled?: boolean
beatRangeMs?: BeatRangeMs
random?: () => number
}>(), {
width: 120,
height: 30,
ariaLabel: 'System monitor ECG',
beatEnabled: true,
beatRangeMs: undefined,
random: undefined,
})
const randomFn = computed(() => props.random ?? Math.random)
const totalWidth = computed(() => props.width * 2)
const waveformPath = computed(() => buildMonitorEcgPath({
segmentWidth: props.width,
height: props.height,
beatsPerSegment: props.mode === 'processing' ? 3 : 2,
segments: 2,
samplesPerBeat: 96,
}))
const styleVars = computed(() => ({
'--monitor-ecg-width': `${props.width}px`,
'--monitor-ecg-height': `${props.height}px`,
'--monitor-ecg-duration': props.mode === 'processing' ? '1.6s' : '3s',
'--monitor-ecg-stroke-opacity': props.mode === 'processing' ? '1' : '0.72',
'--monitor-ecg-glow-opacity': props.mode === 'processing' ? '0.9' : '0.45',
'--monitor-ecg-grid-opacity': props.mode === 'processing' ? '0.26' : '0.16',
}))
const patternId = `monitor-ecg-grid-${patternIdCounter += 1}`
const beatPulse = ref(0)
const beatLift = ref(0)
const beatStyle = computed(() => ({
transform: `translateY(${beatLift.value}px) scaleY(${1 + beatPulse.value})`,
}))
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function resolveBeatRange(mode: 'ready' | 'processing', override?: BeatRangeMs): BeatRangeMs {
if (override) {
return {
min: clamp(override.min, 1, 60000),
max: clamp(override.max, clamp(override.min, 1, 60000), 60000),
}
}
if (mode === 'processing') {
return { min: 420, max: 980 }
}
return { min: 780, max: 1600 }
}
let beatTimer: ReturnType<typeof setTimeout> | null = null
let beatPhaseTimer: ReturnType<typeof setTimeout> | null = null
function clearBeatTimers() {
if (beatTimer) {
clearTimeout(beatTimer)
beatTimer = null
}
if (beatPhaseTimer) {
clearTimeout(beatPhaseTimer)
beatPhaseTimer = null
}
}
function scheduleNextBeat() {
if (!props.beatEnabled) return
const range = resolveBeatRange(props.mode, props.beatRangeMs)
const mix = randomFn.value()
const delay = range.min + (range.max - range.min) * clamp(mix, 0, 1)
beatTimer = setTimeout(() => {
const strengthBase = props.mode === 'processing' ? 0.12 : 0.07
const strengthJitter = props.mode === 'processing' ? 0.06 : 0.04
const strength = strengthBase + strengthJitter * clamp(randomFn.value(), 0, 1)
beatPulse.value = strength
beatLift.value = -(1.4 + 1.6 * clamp(randomFn.value(), 0, 1))
beatPhaseTimer = setTimeout(() => {
beatPulse.value = Math.min(strength * 0.25, 0.05)
beatLift.value = 0.6
beatPhaseTimer = setTimeout(() => {
beatPulse.value = 0
beatLift.value = 0
}, 110)
}, 120)
scheduleNextBeat()
}, delay)
}
watch(() => props.mode, () => {
clearBeatTimers()
scheduleNextBeat()
})
watch(() => props.beatEnabled, (enabled) => {
clearBeatTimers()
if (enabled) scheduleNextBeat()
})
onMounted(() => {
scheduleNextBeat()
})
onUnmounted(() => {
clearBeatTimers()
})
</script>
<template>
<div
class="monitor-ecg-strip"
:class="`mode-${mode}`"
:style="styleVars"
data-testid="monitor-ecg-strip"
>
<svg
class="monitor-ecg-svg"
:viewBox="`0 0 ${totalWidth} ${height}`"
preserveAspectRatio="none"
role="img"
:aria-label="ariaLabel"
data-testid="monitor-ecg-svg"
>
<defs>
<pattern
:id="patternId"
patternUnits="userSpaceOnUse"
width="10"
height="10"
>
<path d="M 10 0 L 0 0 0 10" class="monitor-ecg-grid-line" />
</pattern>
</defs>
<rect class="monitor-ecg-grid" :width="totalWidth" :height="height" :fill="`url(#${patternId})`" />
<g class="monitor-ecg-track" :style="beatStyle">
<path class="monitor-ecg-glow" :d="waveformPath" />
<path class="monitor-ecg-line" :d="waveformPath" data-testid="monitor-ecg-line" />
</g>
</svg>
</div>
</template>
<style scoped>
.monitor-ecg-strip {
width: var(--monitor-ecg-width);
height: var(--monitor-ecg-height);
overflow: hidden;
margin-left: 8px;
border-radius: 4px;
background:
linear-gradient(180deg, rgba(0, 245, 212, 0.06), rgba(0, 245, 212, 0.015)),
rgba(0, 245, 212, 0.03);
border: 1px solid rgba(0, 245, 212, 0.08);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.monitor-ecg-svg {
width: 100%;
height: 100%;
display: block;
}
.monitor-ecg-grid {
opacity: 1;
}
.monitor-ecg-grid-line {
fill: none;
stroke: rgba(0, 245, 212, var(--monitor-ecg-grid-opacity));
stroke-width: 0.4;
}
.monitor-ecg-track {
transform-origin: center;
transform-box: fill-box;
animation: monitor-ecg-scroll var(--monitor-ecg-duration) linear infinite;
will-change: transform;
}
.monitor-ecg-glow,
.monitor-ecg-line {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.monitor-ecg-glow {
stroke: rgba(0, 245, 212, var(--monitor-ecg-glow-opacity));
stroke-width: 2.8;
filter: blur(1.8px);
}
.monitor-ecg-line {
stroke: rgba(0, 245, 212, var(--monitor-ecg-stroke-opacity));
stroke-width: 1.35;
filter: drop-shadow(0 0 4px rgba(0, 245, 212, 0.55));
}
.mode-processing {
border-color: rgba(0, 245, 212, 0.18);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03), 0 0 12px rgba(0, 245, 212, 0.12);
}
@keyframes monitor-ecg-scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.monitor-ecg-track {
animation-duration: calc(var(--monitor-ecg-duration) * 2.2);
}
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
const props = defineProps<{
defineProps<{
visible: boolean
status: 'idle' | 'active' | 'complete' | 'error'
insight: {
@@ -11,8 +11,14 @@ const props = defineProps<{
visitedAgents: string[]
events: Array<{
id: string
label: string
kind: 'info' | 'tool' | 'success' | 'error'
startedAt: string
status: 'active' | 'success' | 'error'
items: Array<{
id: string
time: string
label: string
kind: 'info' | 'tool' | 'success' | 'error'
}>
}>
systemTelemetry: {
cpu: { current: number | null; series: number[]; online: boolean }
@@ -26,81 +32,30 @@ const props = defineProps<{
agentCount: number
}
}>()
const agentLabels: Record<string, string> = {
master: 'JARVIS',
planner: 'planner',
executor: 'executor',
analyst: 'analyst',
librarian: 'librarian',
}
const busAgents = ['planner', 'executor', 'analyst', 'librarian'] as const
function agentState(agent: string) {
if (props.activeAgent === agent) return 'active'
if (props.visitedAgents.includes(agent)) return 'visited'
return 'idle'
}
function statusLabel(status: 'idle' | 'active' | 'complete' | 'error') {
if (status === 'complete') return 'COMPLETE'
if (status === 'error') return 'ERROR'
return 'ACTIVE'
}
</script>
<template>
<aside class="orchestration-panel" :class="[`is-${status}`, { visible }]">
<aside class="orchestration-panel" :class="{ visible }">
<div class="panel-frame">
<div class="panel-header">
<div>
<div class="panel-title">JARVIS CONTROL</div>
<div class="panel-subtitle">{{ activeAgent ? `${agentLabels[activeAgent] || activeAgent} engaged` : 'Awaiting request' }}</div>
</div>
<div class="panel-status" :class="status">
<span class="status-dot"></span>
<span>{{ statusLabel(status) }}</span>
</div>
<div class="panel-title">RECENT EVENTS</div>
<div class="panel-count">{{ events.length }}</div>
</div>
<div class="panel-analysis hud-block">
<div class="block-badge">THINKING LAYER</div>
<div class="analysis-title">{{ insight.statusTitle }}</div>
<div class="analysis-system">{{ insight.systemSummary }}</div>
<div class="analysis-note">{{ insight.jarvisNote }}</div>
</div>
<div class="agent-bus">
<div class="bus-node core" :class="{ active: visible }">
<div class="node-line"></div>
<div class="node-body">
<div class="node-name">{{ agentLabels.master }}</div>
<div class="node-caption">Central Router</div>
</div>
</div>
<div
v-for="agent in busAgents"
:key="agent"
class="bus-node"
:class="agentState(agent)"
>
<div class="node-line"></div>
<div class="node-body">
<div class="node-name">{{ agentLabels[agent] }}</div>
<div class="node-caption">{{ activeAgent === agent ? 'Active Task' : 'Standby' }}</div>
</div>
</div>
</div>
<div class="event-feed">
<div class="feed-title">Recent Events</div>
<div v-if="events.length === 0" class="feed-empty">Awaiting orchestration signal</div>
<div v-else class="feed-list">
<div v-for="event in events" :key="event.id" class="feed-item" :class="event.kind">
<span class="feed-marker"></span>
<span class="feed-label">{{ event.label }}</span>
<div v-if="events.length === 0" class="feed-empty">No events yet</div>
<div v-else class="feed-list">
<div v-for="event in events" :key="event.id" class="feed-item" :class="event.status">
<div class="feed-copy">
<div class="feed-bubble-head">
<span class="feed-time">[{{ event.startedAt }}]</span>
<span class="feed-status">{{ event.status }}</span>
</div>
<div class="feed-steps">
<div v-for="item in event.items" :key="item.id" class="feed-step" :class="item.kind">
<span class="feed-step-time">{{ item.time }}</span>
<span class="feed-label">{{ item.label }}</span>
</div>
</div>
</div>
</div>
</div>
@@ -112,6 +67,7 @@ function statusLabel(status: 'idle' | 'active' | 'complete' | 'error') {
.orchestration-panel {
width: 340px;
min-width: 340px;
height: 100%;
padding: 18px 18px 18px 0;
opacity: 1;
}
@@ -120,18 +76,18 @@ function statusLabel(status: 'idle' | 'active' | 'complete' | 'error') {
height: 100%;
border-radius: 18px;
border: 1px solid rgba(34, 211, 238, 0.18);
background: linear-gradient(180deg, rgba(8, 14, 28, 0.94), rgba(6, 10, 20, 0.9));
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 0 20px rgba(34, 211, 238, 0.08);
background: linear-gradient(180deg, rgba(8, 14, 28, 0.96), rgba(6, 10, 20, 0.92));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 0 20px rgba(34, 211, 238, 0.08);
backdrop-filter: blur(14px);
display: flex;
flex-direction: column;
padding: 18px;
gap: 18px;
gap: 14px;
}
.panel-header {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 12px;
}
@@ -143,413 +99,130 @@ function statusLabel(status: 'idle' | 'active' | 'complete' | 'error') {
color: var(--accent-cyan);
}
.panel-subtitle {
margin-top: 4px;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.12em;
color: var(--text-dim);
text-transform: uppercase;
}
.panel-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
.panel-count {
min-width: 28px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid rgba(34, 211, 238, 0.16);
background: rgba(34, 211, 238, 0.08);
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.14em;
}
.panel-status.complete {
border-color: rgba(74, 222, 128, 0.2);
background: rgba(74, 222, 128, 0.08);
color: var(--accent-green);
}
.panel-status.error {
border-color: rgba(255, 71, 87, 0.24);
background: rgba(255, 71, 87, 0.08);
color: var(--accent-red);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 10px currentColor;
}
.metrics-section,
.activity-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.section-heading,
.feed-title {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.14em;
color: var(--text-dim);
text-transform: uppercase;
}
.metrics-grid {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.hud-card,
.hud-block,
.node-body {
position: relative;
overflow: hidden;
}
.hud-card::before,
.hud-block::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255,255,255,0.03), transparent 42%);
pointer-events: none;
}
.metric-card,
.activity-card,
.panel-analysis {
border-radius: 14px;
border: 1px solid rgba(34, 211, 238, 0.12);
background: rgba(10, 16, 30, 0.72);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 0 16px rgba(34, 211, 238, 0.08);
}
.metric-card {
min-height: 96px;
padding: 14px;
}
.activity-card,
.panel-analysis {
min-height: 114px;
padding: 24px 14px 14px;
}
.cpu-card {
color: #22d3ee;
border-color: rgba(34, 211, 238, 0.2);
}
.mem-card {
color: #a78bfa;
border-color: rgba(167, 139, 250, 0.22);
}
.disk-card {
color: #4ade80;
border-color: rgba(74, 222, 128, 0.22);
}
.activity-hud-card {
color: #f59e0b;
border-color: rgba(245, 158, 11, 0.22);
}
.metric-card::after,
.activity-card::after,
.panel-analysis::after {
content: '';
position: absolute;
left: 14px;
right: 14px;
top: 12px;
height: 1px;
background: linear-gradient(90deg, currentColor, transparent);
opacity: 0.18;
}
.block-badge {
position: absolute;
top: 10px;
right: 14px;
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 0.14em;
color: rgba(34, 211, 238, 0.42);
}
.activity-badge {
color: rgba(245, 158, 11, 0.65);
}
.metric-topline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.metric-label,
.activity-key {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.14em;
color: var(--text-dim);
}
.metric-value,
.activity-number {
font-family: var(--font-display);
font-size: 11px;
letter-spacing: 0.1em;
font-size: 10px;
text-align: center;
color: var(--text-primary);
}
.metric-value {
text-shadow: 0 0 12px rgba(255, 255, 255, 0.08);
}
.activity-key {
color: rgba(245, 158, 11, 0.72);
}
.activity-number {
color: var(--accent-amber);
text-shadow: 0 0 12px rgba(245, 158, 11, 0.18);
}
.activity-stats {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.activity-stat {
display: flex;
flex-direction: column;
gap: 4px;
}
.analysis-title {
font-family: var(--font-display);
font-size: 11px;
letter-spacing: 0.14em;
color: var(--accent-cyan);
}
.analysis-system {
margin-top: 8px;
font-size: 13px;
line-height: 1.6;
color: var(--text-primary);
}
.analysis-note {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(34, 211, 238, 0.08);
font-size: 12px;
line-height: 1.6;
color: var(--text-dim);
}
.agent-bus {
display: flex;
flex-direction: column;
gap: 10px;
}
.bus-node {
position: relative;
display: flex;
gap: 12px;
align-items: stretch;
}
.node-line {
position: relative;
width: 14px;
flex-shrink: 0;
}
.node-line::before {
content: '';
position: absolute;
left: 6px;
top: 0;
bottom: -10px;
width: 1px;
background: linear-gradient(180deg, rgba(34, 211, 238, 0.4), rgba(34, 211, 238, 0.04));
}
.bus-node:last-child .node-line::before {
bottom: 50%;
}
.node-line::after {
content: '';
position: absolute;
left: 2px;
top: 18px;
width: 9px;
height: 9px;
border-radius: 50%;
border: 1px solid rgba(34, 211, 238, 0.24);
background: rgba(34, 211, 238, 0.08);
}
.node-body {
flex: 1;
min-width: 0;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(34, 211, 238, 0.12);
background: rgba(12, 18, 34, 0.78);
transition: border-color 0.24s ease, box-shadow 0.24s ease, transform 0.24s ease;
}
.node-name {
font-family: var(--font-display);
font-size: 11px;
letter-spacing: 0.14em;
color: var(--text-secondary);
}
.node-caption {
margin-top: 4px;
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
letter-spacing: 0.08em;
}
.bus-node.core .node-body {
background: linear-gradient(135deg, rgba(10, 18, 34, 0.95), rgba(22, 18, 42, 0.85));
border-color: rgba(34, 211, 238, 0.18);
}
.bus-node.core .node-name {
color: var(--accent-cyan);
}
.bus-node.active .node-body {
border-color: rgba(34, 211, 238, 0.42);
box-shadow: 0 0 18px rgba(34, 211, 238, 0.14);
transform: translateX(-2px);
}
.bus-node.active .node-name,
.bus-node.active .node-caption {
color: var(--accent-cyan);
}
.bus-node.active .node-line::after {
background: var(--accent-cyan);
box-shadow: 0 0 10px var(--accent-cyan);
}
.bus-node.visited .node-body {
border-color: rgba(34, 211, 238, 0.18);
box-shadow: 0 0 10px rgba(34, 211, 238, 0.06);
}
.bus-node.visited .node-name {
color: var(--text-primary);
}
.event-feed {
margin-top: auto;
border-top: 1px solid rgba(34, 211, 238, 0.1);
padding-top: 14px;
}
.feed-title {
margin-bottom: 12px;
}
.feed-empty {
font-size: 12px;
color: var(--text-muted);
color: var(--text-dim);
}
.feed-list {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
padding-right: 4px;
}
.feed-item {
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(34, 211, 238, 0.14);
background: linear-gradient(180deg, rgba(10, 18, 32, 0.86), rgba(8, 14, 26, 0.72));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
font-size: 12px;
color: var(--text-secondary);
}
.feed-copy {
display: flex;
flex-direction: column;
gap: 10px;
}
.feed-item {
.feed-bubble-head {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.feed-time {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
color: var(--text-dim);
}
.feed-status {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-dim);
}
.feed-steps {
display: flex;
flex-direction: column;
gap: 8px;
}
.feed-step {
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
font-size: 12px;
color: var(--text-secondary);
align-items: start;
padding-top: 8px;
border-top: 1px solid rgba(148, 163, 184, 0.1);
}
.feed-marker {
width: 7px;
height: 7px;
margin-top: 5px;
border-radius: 50%;
background: rgba(34, 211, 238, 0.5);
box-shadow: 0 0 8px rgba(34, 211, 238, 0.2);
flex-shrink: 0;
.feed-step:first-child {
padding-top: 0;
border-top: none;
}
.feed-item.tool .feed-marker {
background: var(--accent-amber);
box-shadow: 0 0 8px rgba(249, 168, 37, 0.24);
.feed-step-time {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--text-muted);
white-space: nowrap;
}
.feed-item.success .feed-marker {
background: var(--accent-green);
box-shadow: 0 0 8px rgba(74, 222, 128, 0.24);
.feed-step.tool .feed-label {
color: #fde68a;
}
.feed-item.error .feed-marker {
background: var(--accent-red);
box-shadow: 0 0 8px rgba(255, 71, 87, 0.24);
.feed-step.success .feed-label {
color: #86efac;
}
.feed-step.error .feed-label {
color: #fda4af;
}
.feed-item.success {
border-color: rgba(74, 222, 128, 0.22);
background: linear-gradient(180deg, rgba(10, 34, 22, 0.8), rgba(8, 18, 14, 0.72));
}
.feed-item.error {
border-color: rgba(255, 71, 87, 0.22);
background: linear-gradient(180deg, rgba(42, 16, 22, 0.82), rgba(24, 10, 14, 0.72));
}
.feed-label {
line-height: 1.5;
line-height: 1.6;
}
@media (max-width: 1280px) {
@media (max-width: 960px) {
.orchestration-panel {
width: 320px;
min-width: 320px;
}
}
@media (max-width: 1120px) {
.orchestration-panel {
width: 300px;
min-width: 300px;
}
}
@media (max-width: 980px) {
.orchestration-panel {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.hud-card,
.node-body {
transition: none;
width: 100%;
min-width: 0;
padding: 12px 12px 12px 0;
}
}
</style>

View File

@@ -2,8 +2,10 @@
import { computed } from 'vue'
const props = withDefaults(defineProps<{
points: number[]
points?: number[]
data?: number[]
stroke?: string
color?: string
fill?: string
grid?: boolean
}>(), {
@@ -16,8 +18,11 @@ const width = 220
const height = 52
const padding = 4
const sourcePoints = computed(() => props.points ?? props.data ?? [])
const strokeColor = computed(() => props.stroke ?? props.color ?? '#22d3ee')
const normalizedPoints = computed(() => {
const source = props.points.length ? props.points : [0, 0]
const source = sourcePoints.value.length ? sourcePoints.value : [0, 0]
const max = Math.max(...source, 100)
const min = 0
return source.map((value, index) => {
@@ -46,7 +51,7 @@ const areaPath = computed(() => {
<svg class="sparkline" :viewBox="`0 0 ${width} ${height}`" preserveAspectRatio="none">
<path v-if="grid" class="sparkline-grid" d="M 0 13 H 220 M 0 26 H 220 M 0 39 H 220" />
<path :d="areaPath" class="sparkline-area" :fill="fill" />
<path :d="linePath" class="sparkline-line" :stroke="stroke" />
<path :d="linePath" class="sparkline-line" :stroke="strokeColor" />
</svg>
</template>

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { buildMonitorEcgPath } from './monitorEcg'
describe('monitorEcg', () => {
it('builds a deterministic tiled ECG path', () => {
const first = buildMonitorEcgPath({
segmentWidth: 100,
height: 30,
beatsPerSegment: 2,
segments: 2,
samplesPerBeat: 80,
})
const second = buildMonitorEcgPath({
segmentWidth: 100,
height: 30,
beatsPerSegment: 2,
segments: 2,
samplesPerBeat: 80,
})
expect(first).toBe(second)
expect(first.length).toBeGreaterThan(50)
})
it('starts and ends on the baseline', () => {
const path = buildMonitorEcgPath({
segmentWidth: 100,
height: 30,
beatsPerSegment: 2,
segments: 2,
samplesPerBeat: 80,
})
expect(path).toMatch(/^M\s*0\s*,\s*15(\s|,)/)
expect(path).toContain('200,15')
})
})

View File

@@ -0,0 +1,79 @@
export type MonitorEcgPathOptions = {
segmentWidth: number
height: number
beatsPerSegment?: number
segments?: number
samplesPerBeat?: number
}
type Point = {
x: number
y: number
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function beatAmplitude(progress: number) {
const pWave = 1.2 * Math.exp(-Math.pow((progress - 0.18) / 0.035, 2))
const qWave = -1.4 * Math.exp(-Math.pow((progress - 0.39) / 0.014, 2))
const rWave = 7.6 * Math.exp(-Math.pow((progress - 0.43) / 0.01, 2))
const sWave = -2.8 * Math.exp(-Math.pow((progress - 0.47) / 0.018, 2))
const tWave = 2.3 * Math.exp(-Math.pow((progress - 0.7) / 0.07, 2))
return pWave + qWave + rWave + sWave + tWave
}
function toPath(points: Point[]) {
return points
.map((point, index) => `${index === 0 ? 'M' : 'L'}${Number(point.x.toFixed(3))},${Number(point.y.toFixed(3))}`)
.join(' ')
}
export function buildMonitorEcgPath(options: MonitorEcgPathOptions) {
const segmentWidth = clamp(options.segmentWidth, 1, Number.MAX_SAFE_INTEGER)
const height = clamp(options.height, 1, Number.MAX_SAFE_INTEGER)
const beatsPerSegment = clamp(options.beatsPerSegment ?? 2, 1, 12)
const segments = clamp(options.segments ?? 2, 1, 8)
const samplesPerBeat = clamp(options.samplesPerBeat ?? 96, 12, 240)
const baseline = height / 2
const amplitudeScale = height / 16
const points: Point[] = []
for (let segmentIndex = 0; segmentIndex < segments; segmentIndex += 1) {
for (let beatIndex = 0; beatIndex < beatsPerSegment; beatIndex += 1) {
const beatStart = segmentIndex * segmentWidth + (beatIndex * segmentWidth) / beatsPerSegment
const beatWidth = segmentWidth / beatsPerSegment
for (let sampleIndex = 0; sampleIndex <= samplesPerBeat; sampleIndex += 1) {
const progress = sampleIndex / samplesPerBeat
const x = beatStart + beatWidth * progress
const y = baseline - beatAmplitude(progress) * amplitudeScale
if (points.length > 0) {
const previous = points[points.length - 1]
if (Math.abs(previous.x - x) < 0.0001) {
points[points.length - 1] = { x, y }
continue
}
}
points.push({ x, y })
}
}
}
if (points.length === 0) {
return `M0,${baseline} L${segmentWidth * segments},${baseline}`
}
const last = points[points.length - 1]
points[points.length - 1] = { x: segmentWidth * segments, y: baseline }
if (last.x < segmentWidth * segments) {
points.push({ x: segmentWidth * segments, y: baseline })
}
return toPath(points)
}

View File

@@ -0,0 +1,66 @@
import { mount, flushPromises } from '@vue/test-utils'
import { beforeEach, describe, expect, it } from 'vitest'
import { createMemoryHistory, createRouter } from 'vue-router'
import NavShortcutRow from './NavShortcutRow.vue'
import { navItems } from '@/app/navigation/nav'
async function mountNavShortcutRow(path = '/chat') {
const router = createRouter({
history: createMemoryHistory(),
routes: navItems.map((item) => ({
path: item.path,
name: item.path,
component: { template: '<div />' },
})),
})
await router.push(path)
await router.isReady()
return {
router,
wrapper: mount(NavShortcutRow, {
global: {
plugins: [router],
},
}),
}
}
async function expandShortcuts(wrapper: ReturnType<typeof mount>) {
await wrapper.get('.nav-trigger').trigger('click')
await flushPromises()
}
describe('NavShortcutRow', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
it('renders shortcuts only after expanding from the trigger', async () => {
const { wrapper } = await mountNavShortcutRow()
expect(wrapper.findAll('[data-testid="nav-shortcut"]')).toHaveLength(0)
await expandShortcuts(wrapper)
const shortcuts = wrapper.findAll('[data-testid="nav-shortcut"]')
expect(shortcuts).toHaveLength(navItems.length)
expect(shortcuts[0].attributes('title')).toBe(navItems[0].name)
expect(shortcuts[0].attributes('aria-label')).toBe(navItems[0].name)
})
it('marks the current route as active after expansion', async () => {
const { wrapper } = await mountNavShortcutRow('/skills')
await expandShortcuts(wrapper)
const shortcuts = wrapper.findAll('[data-testid="nav-shortcut"]')
const activeShortcut = shortcuts[2]
expect(activeShortcut.classes()).toContain('active')
expect(activeShortcut.attributes('aria-current')).toBe('page')
expect(shortcuts[0].classes()).not.toContain('active')
})
})

View File

@@ -0,0 +1,669 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import chestImage from '@/assets/chest.png'
import { navItems } from '@/app/navigation/nav'
import KnowledgeLauncher from '@/components/chat/KnowledgeLauncher.vue'
const emit = defineEmits<{
(e: 'select-folder', folder: any): void
(e: 'open-knowledge-hud'): void
}>()
const router = useRouter()
const route = useRoute()
const isExpanded = ref(false)
const showKnowledgeLauncher = ref(false)
const visibleShortcutCount = ref(0)
const panelId = 'chat-hud-nav-panel'
let shortcutRevealTimer: ReturnType<typeof setTimeout> | null = null
const visibleNavItems = computed(() => navItems.slice(0, visibleShortcutCount.value))
function isActive(path: string) {
return route.path === path || route.path.startsWith(`${path}/`)
}
function clearShortcutRevealTimer() {
if (shortcutRevealTimer) {
clearTimeout(shortcutRevealTimer)
shortcutRevealTimer = null
}
}
function revealNextShortcut(index = 1) {
visibleShortcutCount.value = index
if (index >= navItems.length) {
shortcutRevealTimer = null
return
}
shortcutRevealTimer = setTimeout(() => {
revealNextShortcut(index + 1)
}, 68)
}
function toggleExpanded() {
isExpanded.value = !isExpanded.value
if (!isExpanded.value) {
showKnowledgeLauncher.value = false
}
}
function handleNavigate(path?: string) {
if (path === '/knowledge') {
showKnowledgeLauncher.value = false
isExpanded.value = false
emit('open-knowledge-hud')
return
}
// Reset states for other paths
isExpanded.value = false
showKnowledgeLauncher.value = false
if (!path) return
void router.push(path)
}
function handleSelectFolder(folder: any) {
showKnowledgeLauncher.value = false
isExpanded.value = false
emit('select-folder', folder)
}
watch(isExpanded, (expanded) => {
clearShortcutRevealTimer()
if (!expanded) {
visibleShortcutCount.value = 0
showKnowledgeLauncher.value = false
return
}
revealNextShortcut()
})
onUnmounted(() => {
clearShortcutRevealTimer()
})
</script>
<template>
<nav
class="nav-shortcut-row"
:class="{ expanded: isExpanded }"
aria-label="Primary navigation"
data-testid="nav-shortcut-row"
>
<button
class="nav-trigger"
type="button"
:class="{ expanded: isExpanded }"
:aria-expanded="isExpanded"
:aria-controls="panelId"
aria-label="鎵撳紑瀵艰埅"
@click="toggleExpanded"
>
<span class="trigger-shell">
<span class="trigger-scan"></span>
<img class="trigger-image" :src="chestImage" alt="" aria-hidden="true" />
</span>
<span class="trigger-label" :class="{ hidden: isExpanded }" aria-hidden="true">Menu</span>
</button>
<div :id="panelId" class="nav-panel" :class="{ expanded: isExpanded }">
<template v-if="isExpanded">
<span class="panel-scan"></span>
<span class="panel-line panel-line-top"></span>
<span class="panel-line panel-line-bottom"></span>
<TransitionGroup name="shortcut-stagger" tag="div" class="shortcut-list">
<div
v-for="(item, index) in visibleNavItems"
:key="item.path"
class="shortcut-wrapper"
>
<button
type="button"
class="shortcut"
:class="{
active: isActive(item.path) || (item.path === '/knowledge' && showKnowledgeLauncher)
}"
:style="{ '--shortcut-delay': `${index * 58}ms` }"
:title="item.name"
:aria-label="item.name"
:aria-current="isActive(item.path) ? 'page' : undefined"
data-testid="nav-shortcut"
@click="handleNavigate(item.path)"
>
<span class="shortcut-core">
<span class="shortcut-frame"></span>
<span class="shortcut-edge shortcut-edge-top"></span>
<span class="shortcut-edge shortcut-edge-bottom"></span>
<span class="shortcut-beam"></span>
<span class="shortcut-pulse"></span>
<component :is="item.icon" :size="14" class="shortcut-icon" aria-hidden="true" />
</span>
</button>
<Transition name="launcher">
<KnowledgeLauncher
v-if="item.path === '/knowledge' && showKnowledgeLauncher"
@select-folder="handleSelectFolder"
/>
</Transition>
</div>
</TransitionGroup>
</template>
</div>
</nav>
</template>
<style scoped>
.nav-shortcut-row {
position: relative;
display: inline-flex;
align-items: center;
gap: 10px;
isolation: isolate;
}
.shortcut-wrapper {
position: relative;
display: flex;
align-items: center;
}
.nav-trigger {
position: relative;
display: inline-flex;
align-items: center;
gap: 10px;
width: auto;
height: 36px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
color: inherit;
overflow: visible;
}
.trigger-shell {
position: relative;
display: flex;
width: 36px;
height: 36px;
flex: 0 0 36px;
align-items: center;
justify-content: center;
background: transparent;
box-shadow: none;
overflow: visible;
}
.trigger-shell::before {
content: '';
position: absolute;
inset: 5px;
border-radius: 999px;
background: radial-gradient(circle, rgba(103, 232, 249, 0.22), rgba(103, 232, 249, 0.06) 48%, transparent 74%);
opacity: 0.52;
filter: blur(4px);
animation: arc-reactor-glow 4.8s linear infinite;
}
.trigger-scan {
position: absolute;
inset: -25% 42% -25% -42%;
background: linear-gradient(90deg, transparent, rgba(103, 232, 249, 0.22), transparent);
transform: translateX(-120%) skewX(-20deg);
opacity: 0.75;
animation: trigger-scan 3.2s linear infinite;
}
.trigger-image {
position: relative;
z-index: 1;
width: 28px;
height: 28px;
object-fit: contain;
filter:
drop-shadow(0 0 8px rgba(103, 232, 249, 0.24))
drop-shadow(0 0 16px rgba(103, 232, 249, 0.14));
animation: arc-reactor-flicker 4.8s linear infinite;
}
.nav-trigger:hover .trigger-image,
.nav-trigger:focus-visible .trigger-image,
.nav-shortcut-row.expanded .trigger-image {
filter:
drop-shadow(0 0 9px rgba(103, 232, 249, 0.34))
drop-shadow(0 0 16px rgba(103, 232, 249, 0.16));
}
.trigger-label {
display: inline-block;
flex: 0 0 auto;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(186, 230, 253, 0.92);
text-shadow: 0 0 8px rgba(103, 232, 249, 0.16);
opacity: 1;
transform: translateX(0);
transition:
opacity 0.22s ease,
transform 0.22s ease,
max-width 0.22s ease;
max-width: 72px;
overflow: hidden;
white-space: nowrap;
}
.trigger-label.hidden {
opacity: 0;
transform: translateX(-6px);
max-width: 0;
}
.nav-trigger:focus-visible {
outline: 2px solid rgba(103, 232, 249, 0.66);
outline-offset: 2px;
}
.nav-panel {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
max-width: 0;
opacity: 0;
overflow: hidden;
pointer-events: none;
transform: translateX(-8px);
transform-origin: left center;
white-space: nowrap;
transition:
max-width 0.42s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.24s ease,
transform 0.42s cubic-bezier(0.22, 1, 0.36, 1);
}
.shortcut-list {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
}
.nav-panel.expanded {
max-width: 420px;
opacity: 1;
pointer-events: auto;
transform: translateX(0);
overflow: visible;
z-index: 100;
}
.panel-scan,
.panel-line {
position: absolute;
pointer-events: none;
}
.panel-scan {
inset: 1px auto 1px -18%;
width: 20%;
background: linear-gradient(90deg, transparent, rgba(103, 232, 249, 0.18), transparent);
transform: skewX(-18deg);
opacity: 0;
}
.nav-panel.expanded .panel-scan {
opacity: 1;
animation: panel-scan 1.2s ease-out 1;
}
.panel-line {
left: 0;
right: 0;
height: 1px;
}
.panel-line-top {
top: 1px;
background: linear-gradient(90deg, rgba(103, 232, 249, 0), rgba(103, 232, 249, 0.4), rgba(103, 232, 249, 0));
}
.panel-line-bottom {
bottom: 1px;
background: linear-gradient(90deg, rgba(245, 158, 11, 0), rgba(245, 158, 11, 0.28), rgba(245, 158, 11, 0));
}
.shortcut {
--shortcut-delay: 0ms;
position: relative;
flex: 0 0 auto;
padding: 0;
border: none;
background: transparent;
text-decoration: none;
color: rgba(148, 163, 184, 0.84);
cursor: pointer;
transition:
transform var(--transition-fast),
color var(--transition-fast),
filter var(--transition-fast);
}
.shortcut-stagger-enter-active {
transition:
opacity 0.3s ease,
transform 0.36s cubic-bezier(0.22, 1, 0.36, 1);
transition-delay: var(--shortcut-delay);
}
.shortcut-stagger-enter-from {
opacity: 0;
transform: translateX(-10px) scale(0.92);
}
.shortcut-stagger-enter-to {
opacity: 1;
transform: translateX(0) scale(1);
}
.shortcut::after {
content: '';
position: absolute;
top: 8px;
bottom: 8px;
right: -4px;
width: 1px;
background: linear-gradient(180deg, transparent, rgba(56, 189, 248, 0.16), rgba(245, 158, 11, 0.14), transparent);
}
.shortcut:last-of-type::after {
display: none;
}
.shortcut-core {
position: relative;
width: 29px;
height: 29px;
display: inline-flex;
align-items: center;
justify-content: center;
clip-path: polygon(20% 0, 100% 0, 100% 80%, 80% 100%, 0 100%, 0 20%);
background: linear-gradient(180deg, rgba(11, 19, 32, 0.94), rgba(7, 11, 19, 0.98));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03),
0 0 0 1px rgba(56, 189, 248, 0.06);
overflow: hidden;
}
.shortcut-frame,
.shortcut-edge,
.shortcut-beam,
.shortcut-pulse {
position: absolute;
pointer-events: none;
}
.shortcut-frame {
inset: 0;
clip-path: inherit;
border: 1px solid rgba(56, 189, 248, 0.14);
}
.shortcut-edge {
height: 1px;
background: linear-gradient(90deg, transparent, rgba(56, 189, 248, 0.5), transparent);
opacity: 0.55;
}
.shortcut-edge-top {
top: 4px;
left: 5px;
right: 9px;
}
.shortcut-edge-bottom {
bottom: 4px;
left: 9px;
right: 5px;
background: linear-gradient(90deg, transparent, rgba(245, 158, 11, 0.44), transparent);
}
.shortcut-beam {
inset: -30% 46% -30% -38%;
background: linear-gradient(90deg, transparent, rgba(56, 189, 248, 0.22), transparent);
opacity: 0;
transform: translateX(-130%) skewX(-22deg);
transition: opacity var(--transition-fast), transform 0.45s ease;
}
.shortcut-pulse {
inset: 6px;
background: radial-gradient(circle, rgba(56, 189, 248, 0.18), transparent 72%);
opacity: 0;
transform: scale(0.82);
transition: opacity var(--transition-fast), transform var(--transition-fast);
}
.shortcut-icon {
position: relative;
z-index: 1;
transition: transform var(--transition-fast), filter var(--transition-fast);
}
.shortcut:hover,
.shortcut:focus-visible {
color: rgba(241, 245, 249, 0.98);
transform: translateY(-1px);
}
.shortcut:hover .shortcut-frame,
.shortcut:focus-visible .shortcut-frame {
border-color: rgba(56, 189, 248, 0.24);
}
.shortcut:hover .shortcut-beam,
.shortcut:focus-visible .shortcut-beam,
.shortcut.active .shortcut-beam {
opacity: 1;
transform: translateX(155%) skewX(-22deg);
}
.shortcut:hover .shortcut-pulse,
.shortcut:focus-visible .shortcut-pulse,
.shortcut.active .shortcut-pulse {
opacity: 1;
transform: scale(1);
}
.shortcut:hover .shortcut-icon,
.shortcut:focus-visible .shortcut-icon,
.shortcut.active .shortcut-icon {
transform: scale(1.05);
filter: drop-shadow(0 0 8px rgba(56, 189, 248, 0.34));
}
.shortcut.active {
color: #67e8f9;
}
.shortcut.active .shortcut-core {
background:
radial-gradient(circle at top center, rgba(56, 189, 248, 0.16), transparent 54%),
linear-gradient(180deg, rgba(11, 23, 38, 0.98), rgba(7, 11, 18, 1));
box-shadow:
inset 0 0 0 1px rgba(56, 189, 248, 0.08),
0 0 14px rgba(56, 189, 248, 0.12);
}
.shortcut.active .shortcut-frame {
border-color: rgba(56, 189, 248, 0.32);
}
.shortcut.active::before {
content: '';
position: absolute;
left: 5px;
right: 5px;
bottom: -2px;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(56, 189, 248, 0.96), rgba(245, 158, 11, 0.72), transparent);
box-shadow: 0 0 9px rgba(56, 189, 248, 0.24);
}
.shortcut:focus-visible {
outline: 2px solid rgba(56, 189, 248, 0.66);
outline-offset: 2px;
}
@keyframes trigger-scan {
0% {
transform: translateX(-120%) skewX(-20deg);
}
100% {
transform: translateX(175%) skewX(-20deg);
}
}
@keyframes arc-reactor-flicker {
0%, 54%, 100% {
opacity: 0.94;
transform: scale(1);
filter:
drop-shadow(0 0 8px rgba(103, 232, 249, 0.24))
drop-shadow(0 0 16px rgba(103, 232, 249, 0.14));
}
56% {
opacity: 1;
transform: scale(1.06);
filter:
drop-shadow(0 0 16px rgba(103, 232, 249, 0.44))
drop-shadow(0 0 30px rgba(103, 232, 249, 0.24));
}
57% {
opacity: 0.74;
transform: scale(0.985);
}
58% {
opacity: 1;
transform: scale(1.08);
filter:
drop-shadow(0 0 18px rgba(103, 232, 249, 0.54))
drop-shadow(0 0 36px rgba(103, 232, 249, 0.28));
}
60% {
opacity: 0.86;
transform: scale(1.01);
}
62% {
opacity: 1;
transform: scale(1.05);
filter:
drop-shadow(0 0 15px rgba(103, 232, 249, 0.4))
drop-shadow(0 0 28px rgba(103, 232, 249, 0.2));
}
66% {
opacity: 0.94;
transform: scale(1);
filter:
drop-shadow(0 0 8px rgba(103, 232, 249, 0.24))
drop-shadow(0 0 16px rgba(103, 232, 249, 0.14));
}
}
@keyframes arc-reactor-glow {
0%, 54%, 100% {
opacity: 0.4;
transform: scale(1);
}
56% {
opacity: 0.8;
transform: scale(1.18);
}
58% {
opacity: 1;
transform: scale(1.26);
}
62% {
opacity: 0.72;
transform: scale(1.14);
}
66% {
opacity: 0.4;
transform: scale(1);
}
}
@keyframes panel-scan {
0% {
transform: translateX(0) skewX(-18deg);
}
100% {
transform: translateX(560%) skewX(-18deg);
}
}
@media (prefers-reduced-motion: reduce) {
.nav-panel,
.shortcut,
.shortcut-icon,
.shortcut-beam,
.shortcut-pulse,
.trigger-image,
.trigger-scan,
.panel-scan,
.shortcut-stagger-enter-active {
transition: none;
animation: none;
}
.trigger-shell::before {
animation: none;
}
.shortcut-stagger-enter-from,
.shortcut-stagger-enter-to {
opacity: 1;
transform: none;
}
.shortcut:hover,
.shortcut:focus-visible,
.nav-trigger:hover {
transform: none;
}
}
@media (max-width: 900px) {
.nav-shortcut-row {
gap: 8px;
}
.nav-panel.expanded {
gap: 6px;
max-width: min(100vw - 72px, 420px);
}
.shortcut-core {
width: 27px;
height: 27px;
}
}
</style>

View File

@@ -55,17 +55,6 @@ const statusConfig = computed(() => ({
empty: { icon: '⚠', color: '#ef4444', label: '未配置' }
}[status.value]))
function onProviderChange() {
const defaults: Record<string, string> = {
ollama: 'http://localhost:11434',
openai: 'https://api.openai.com/v1',
claude: 'https://api.anthropic.com',
deepseek: 'https://api.deepseek.com/v1'
}
if (!editingModel.value.base_url || Object.values(defaults).includes(editingModel.value.base_url)) {
editingModel.value.base_url = defaults[editingModel.value.provider] || ''
}
}
</script>
<template>
@@ -77,7 +66,6 @@ function onProviderChange() {
<ChevronRight v-else :size="14" />
</div>
<div class="cell cell-name">{{ model.name || '未命名' }}</div>
<div class="cell cell-provider">{{ model.provider }}</div>
<div class="cell cell-model">{{ model.model || '-' }}</div>
<div class="cell cell-status" :style="{ color: statusConfig.color }">
{{ statusConfig.icon }} {{ statusConfig.label }}
@@ -96,16 +84,6 @@ function onProviderChange() {
<label>// NAME</label>
<input v-model="editingModel.name" type="text" placeholder="模型名称" />
</div>
<div class="form-group">
<label>// PROVIDER</label>
<select v-model="editingModel.provider" @change="onProviderChange">
<option value="openai">OpenAI</option>
<option value="claude">Claude</option>
<option value="ollama">Ollama</option>
<option value="deepseek">DeepSeek</option>
<option value="custom">Custom</option>
</select>
</div>
<div class="form-group">
<label>// MODEL</label>
<input v-model="editingModel.model" type="text" placeholder="gpt-4o" />
@@ -172,7 +150,6 @@ function onProviderChange() {
.cell-toggle { width: 30px; }
.cell-name { flex: 1; min-width: 120px; }
.cell-provider { width: 80px; }
.cell-model { width: 120px; }
.cell-status { width: 80px; }
.cell-actions { width: 40px; text-align: right; }

View File

@@ -28,7 +28,7 @@ export interface CommanderSkill {
stateLabel: string
}
export type MainAgentId = 'master' | 'planner' | 'executor' | 'librarian' | 'analyst'
export type MainAgentId = 'master' | 'schedule_planner' | 'executor' | 'librarian' | 'analyst'
export const DEFAULT_AGENTS: Agent[] = [
{
@@ -42,12 +42,12 @@ export const DEFAULT_AGENTS: Agent[] = [
isMaster: true,
},
{
id: 'planner',
name: 'PLANNER',
role: '路径规划师',
roleKey: 'planner',
description: '负责拆解复杂目标、安排顺序、收束执行路径,让事情变得清楚可做',
systemPrompt: '你是 Jarvis 的路径规划师。面对复杂目标时,先识别约束和优先级,再把任务拆成清晰、可执行的步骤,帮助用户迅速看清最短可行路径。',
id: 'schedule_planner',
name: 'SCHEDULE PLANNER',
role: '日程规划师',
roleKey: 'schedule_planner',
description: '负责分析当前安排,并在你明确要求时直接创建 reminder、task、todo、goal把日程建议真正落到系统里',
systemPrompt: '你是 Jarvis 的日程规划师。基于对话历史、任务看板和论坛动态,先判断当前重点与风险,再给出今天和近期可执行的日程安排;当用户明确要求创建安排时,直接调用工具完成落库。',
enabled: true,
},
{
@@ -79,23 +79,23 @@ export const DEFAULT_AGENTS: Agent[] = [
},
]
export const MAIN_AGENT_ORDER: MainAgentId[] = ['planner', 'executor', 'librarian', 'analyst']
export const MAIN_AGENT_ORDER: MainAgentId[] = ['schedule_planner', 'executor', 'librarian', 'analyst']
export const AGENT_RELATIONS: Record<MainAgentId, string[]> = {
master: ['planner', 'executor', 'librarian', 'analyst'],
planner: ['planner_scope', 'planner_steps'],
master: ['schedule_planner', 'executor', 'librarian', 'analyst'],
schedule_planner: ['schedule_analysis', 'schedule_planning'],
executor: ['executor_tasks', 'executor_forum'],
librarian: ['librarian_retrieval', 'librarian_graph'],
analyst: ['analyst_progress', 'analyst_insights'],
}
export const RELATION_LABELS: Record<string, string> = {
'master-planner': '调度任务',
'master-schedule_planner': '安排日程',
'master-executor': '执行指令',
'master-librarian': '查询知识',
'master-analyst': '请求分析',
'planner-planner_scope': '收束目标',
'planner-planner_steps': '拆解路径',
'schedule_planner-schedule_analysis': '分析优先级',
'schedule_planner-schedule_planning': '编排日程',
'executor-executor_tasks': '调度任务工具',
'executor-executor_forum': '调度论坛工具',
'librarian-librarian_retrieval': '检索知识',
@@ -116,10 +116,10 @@ export const COMMANDER_SKILLS: CommanderSkill[] = [
{
id: 'skill_planner',
label: 'CORE-02',
title: '路径拆解',
description: '把复杂目标压缩成清晰路径、优先级与执行步骤。',
relatedAgentIds: ['planner', 'planner_scope', 'planner_steps'],
stateLabel: 'STRUCTURE',
title: '日程规划',
description: '分析当前安排、生成建议,并在用户明确要求时直接创建提醒、任务、待办与目标。',
relatedAgentIds: ['schedule_planner', 'schedule_analysis', 'schedule_planning'],
stateLabel: 'SCHEDULE',
},
{
id: 'skill_executor',
@@ -149,31 +149,31 @@ export const COMMANDER_SKILLS: CommanderSkill[] = [
export const SUB_COMMANDERS: SubCommander[] = [
{
id: 'planner_scope',
parentId: 'planner',
name: 'SCOPE',
role: '目标收束官',
description: '负责澄清目标、识别边界与约束,让规划前提先变清楚。',
relationLabel: RELATION_LABELS['planner-planner_scope'],
toolScopeLabel: '约束/需求',
id: 'schedule_analysis',
parentId: 'schedule_planner',
name: 'ANALYSIS',
role: '日程分析员',
description: '负责分析对话历史、任务看板、Schedule Center 数据与论坛动态,识别优先级、冲突与压力点。',
relationLabel: RELATION_LABELS['schedule_planner-schedule_analysis'],
toolScopeLabel: 'Schedule Read',
},
{
id: 'planner_steps',
parentId: 'planner',
name: 'STEPS',
role: '步骤拆解官',
description: '负责把目标拆成步骤、优先级与依赖关系,形成执行路径。',
relationLabel: RELATION_LABELS['planner-planner_steps'],
toolScopeLabel: '路径/排序',
id: 'schedule_planning',
parentId: 'schedule_planner',
name: 'PLANNING',
role: '日程编排员',
description: '负责把分析结果转成今天与近期可执行安排;当你明确要求时,直接创建提醒/任务/待办/目标并返回结果。',
relationLabel: RELATION_LABELS['schedule_planner-schedule_planning'],
toolScopeLabel: 'Schedule Write',
},
{
id: 'executor_tasks',
parentId: 'executor',
name: 'TASK OPS',
role: '任务执行官',
description: '负责调用任务相关工具,推进创建、更新与状态执行。',
description: '负责调用任务、提醒、待办、目标相关工具,推进创建、更新与状态执行。',
relationLabel: RELATION_LABELS['executor-executor_tasks'],
toolScopeLabel: 'Task Tools',
toolScopeLabel: 'Task / Schedule',
},
{
id: 'executor_forum',
@@ -223,7 +223,7 @@ export const SUB_COMMANDERS: SubCommander[] = [
]
export const SUB_COMMANDERS_BY_PARENT: Record<Exclude<MainAgentId, 'master'>, SubCommander[]> = {
planner: SUB_COMMANDERS.filter((item) => item.parentId === 'planner'),
schedule_planner: SUB_COMMANDERS.filter((item) => item.parentId === 'schedule_planner'),
executor: SUB_COMMANDERS.filter((item) => item.parentId === 'executor'),
librarian: SUB_COMMANDERS.filter((item) => item.parentId === 'librarian'),
analyst: SUB_COMMANDERS.filter((item) => item.parentId === 'analyst'),

View File

@@ -0,0 +1,505 @@
.agent-view {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
background: var(--bg-void);
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(0,245,212,0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,245,212,0.04) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
.bg-glow {
position: absolute;
inset: 0;
background: radial-gradient(ellipse 80% 60% at 50% 40%, rgba(0,245,212,0.05) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
}
.bg-particles {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.bg-particle {
position: absolute;
border-radius: 50%;
background: var(--accent-cyan);
box-shadow: 0 0 4px rgba(0,245,212,0.6), 0 0 8px rgba(0,245,212,0.2);
animation: star-twinkle var(--d, 4s) ease-in-out infinite var(--delay, 0s);
}
@keyframes star-twinkle {
0%, 100% { opacity: var(--o, 0.4); transform: scale(1); }
50% { opacity: calc(var(--o, 0.4) * 0.3); transform: scale(0.5); }
}
.view-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 24px;
border-bottom: 1px solid var(--border-dim);
position: relative;
z-index: 10;
background: rgba(5,8,16,0.6);
backdrop-filter: blur(8px);
}
.header-title { font-family: var(--font-display); font-size: 13px; letter-spacing: 0.2em; color: var(--text-primary); }
.title-bracket { color: var(--accent-cyan); opacity: 0.6; }
.header-actions { display: flex; align-items: center; gap: 12px; }
.btn-icon {
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
background: var(--bg-card); border: 1px solid var(--border-mid); border-radius: var(--radius-sm);
color: var(--text-secondary); cursor: pointer; transition: all var(--transition-fast);
}
.btn-icon:hover { border-color: var(--accent-cyan); color: var(--accent-cyan); box-shadow: var(--glow-cyan); }
.btn-icon.spinning svg { animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.status-bar { display: flex; align-items: center; gap: 6px; font-size: 10px; color: var(--text-dim); letter-spacing: 0.1em; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; }
.status-dot.connected { background: var(--accent-cyan); box-shadow: 0 0 6px var(--accent-cyan); animation: status-pulse-soft 2.6s ease-in-out infinite; }
.status-dot.disconnected { background: var(--text-dim); }
@keyframes status-pulse-soft {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.55; transform: scale(0.82); }
}
.nodes-canvas {
flex: 1;
position: relative;
overflow: hidden;
isolation: isolate;
cursor: grab;
}
.nodes-canvas.panning {
cursor: grabbing;
user-select: none;
}
.hud-panels {
position: absolute;
top: 18px;
right: 20px;
z-index: 12;
display: flex;
flex-direction: column;
gap: 12px;
width: 260px;
}
.hud-panel {
border: 1px solid rgba(0,245,212,0.12);
border-radius: 16px;
background: linear-gradient(180deg, rgba(8, 13, 22, 0.92), rgba(5, 9, 18, 0.86));
backdrop-filter: blur(14px);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.24), inset 0 1px 0 rgba(255,255,255,0.04);
padding: 14px;
}
.hud-title {
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.16em;
color: var(--accent-cyan);
margin-bottom: 10px;
}
.route-main {
font-family: var(--font-display);
font-size: 18px;
letter-spacing: 0.08em;
color: var(--text-primary);
}
.route-child {
margin-top: 6px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--accent-cyan);
letter-spacing: 0.08em;
}
.canvas-controls {
position: absolute;
right: 20px;
bottom: 18px;
z-index: 12;
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border: 1px solid rgba(0,245,212,0.12);
border-radius: 22px;
background: linear-gradient(180deg, rgba(8, 13, 22, 0.92), rgba(5, 9, 18, 0.86));
backdrop-filter: blur(14px);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.32), inset 0 1px 0 rgba(255,255,255,0.04);
}
.control-chip {
height: 36px;
border: 1px solid rgba(0,245,212,0.12);
background: rgba(9, 16, 28, 0.9);
color: var(--text-secondary);
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.18s ease;
}
.control-chip:hover {
border-color: rgba(0,245,212,0.3);
color: var(--accent-cyan);
box-shadow: 0 0 18px rgba(0,245,212,0.08);
}
.zoom-chip { width: 36px; flex-shrink: 0; }
.chip-symbol { font-family: var(--font-display); font-size: 18px; line-height: 1; }
.zoom-readout { min-width: 72px; padding: 0 14px; }
.chip-value { font-family: var(--font-display); font-size: 11px; letter-spacing: 0.08em; color: var(--text-primary); }
.nodes-viewport {
position: absolute;
inset: 0;
z-index: 1;
will-change: transform;
}
.nodes-stage {
position: absolute;
inset: 0;
will-change: auto;
}
.canvas-aura,
.canvas-scan {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
}
.canvas-aura {
background:
radial-gradient(circle at 50% 18%, rgba(0,245,212,0.1) 0%, rgba(0,245,212,0.05) 20%, transparent 46%),
radial-gradient(circle at 50% 62%, rgba(0,245,212,0.035) 0%, transparent 54%);
filter: blur(12px);
opacity: 0.72;
}
.canvas-scan {
inset: -20% 0;
background: linear-gradient(180deg, transparent 0%, rgba(0,245,212,0.018) 42%, rgba(0,245,212,0.045) 50%, rgba(0,245,212,0.018) 58%, transparent 100%);
animation: canvas-scan 11s linear infinite;
opacity: 0.5;
}
@keyframes canvas-scan {
from { transform: translateY(-18%); }
to { transform: translateY(18%); }
}
.conn-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
overflow: visible;
}
.conn-path {
fill: none;
stroke: rgba(0,245,212,0.22);
stroke-width: 1.5;
stroke-dasharray: 5 7;
stroke-linecap: round;
filter: drop-shadow(0 0 6px rgba(0,245,212,0.06));
animation: dash-flow 5.5s linear infinite;
}
.conn-path-sub {
stroke-width: 1.2;
stroke-opacity: 0.7;
}
@keyframes dash-flow { to { stroke-dashoffset: -48; } }
.conn-path.energized {
stroke: color-mix(in srgb, var(--accent-cyan) 72%, var(--accent-amber) 28%);
stroke-opacity: 0.62;
stroke-width: 1.9;
stroke-dasharray: none;
filter: url(#lineGlow) drop-shadow(0 0 8px rgba(0,245,212,0.16));
animation: line-flare 2.2s ease-in-out infinite alternate;
}
.conn-current {
fill: none;
stroke: rgba(232, 255, 255, 0.98);
stroke-width: 3.2;
stroke-linecap: round;
stroke-dasharray: 18 220;
filter: drop-shadow(0 0 10px rgba(0,245,212,0.36)) drop-shadow(0 0 18px rgba(255,255,255,0.24));
animation: current-flow 1.2s linear infinite;
}
.conn-current-sub {
stroke-width: 2.6;
stroke-dasharray: 14 180;
animation-duration: 1s;
}
@keyframes line-flare {
from { stroke-opacity: 0.38; }
to { stroke-opacity: 0.72; }
}
@keyframes current-flow {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: -238; }
}
.node-card {
position: absolute;
z-index: 2;
cursor: pointer;
transition: transform 0.22s ease;
}
.node-sub.disabled { opacity: 0.35; cursor: not-allowed; }
.node-child { z-index: 2; }
.node-child .node-name,
.node-child .node-role,
.node-child .node-label {
word-break: break-word;
}
.node-inner {
width: 100%;
height: 100%;
background: rgba(13,21,37,0.92);
border: 1px solid rgba(0,245,212,0.2);
border-radius: var(--radius-md);
padding: var(--node-padding-y, 14px) var(--node-padding-x, 16px);
display: flex;
flex-direction: column;
gap: calc(3px * var(--node-scale, 1));
position: relative;
overflow: hidden;
backdrop-filter: blur(12px);
transition: border-color 0.2s, box-shadow 0.2s, background 0.25s ease;
}
.node-inner::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(115deg, transparent 20%, rgba(255,255,255,0.045) 32%, transparent 44%);
transform: translateX(-150%);
opacity: 0;
pointer-events: none;
}
.node-master .node-inner::after {
content: '';
position: absolute;
inset: -18%;
background: radial-gradient(circle, rgba(0,245,212,0.1) 0%, rgba(0,245,212,0.045) 28%, transparent 62%);
opacity: 0.72;
animation: core-breathe 5.6s ease-in-out infinite;
pointer-events: none;
}
.node-master .node-inner {
background: linear-gradient(135deg, rgba(0,245,212,0.06) 0%, rgba(13,21,37,0.95) 100%);
border-color: rgba(0,245,212,0.3);
}
.node-card:hover .node-inner {
border-color: rgba(0,245,212,0.42);
box-shadow: 0 8px 28px rgba(0,245,212,0.11), 0 0 0 1px rgba(0,245,212,0.08);
}
.node-card:hover .node-inner::before {
opacity: 0.9;
animation: node-sheen 1.45s ease;
}
.node-card.selected .node-inner {
border-color: var(--accent-cyan);
box-shadow: 0 0 0 1px rgba(0,245,212,0.26), 0 0 18px rgba(0,245,212,0.14);
}
@keyframes node-sheen {
0% { transform: translateX(-150%); }
100% { transform: translateX(150%); }
}
@keyframes core-breathe {
0%, 100% { opacity: 0.46; transform: scale(0.98); }
50% { opacity: 0.8; transform: scale(1.01); }
}
.node-corner { position: absolute; width: var(--node-corner-size, 10px); height: var(--node-corner-size, 10px); opacity: 0.6; }
.node-corner.tl { top: calc(6px * var(--node-scale, 1)); left: calc(6px * var(--node-scale, 1)); border-top: calc(1.5px * var(--node-scale, 1)) solid var(--accent-cyan); border-left: calc(1.5px * var(--node-scale, 1)) solid var(--accent-cyan); }
.node-corner.tr { top: calc(6px * var(--node-scale, 1)); right: calc(6px * var(--node-scale, 1)); border-top: calc(1.5px * var(--node-scale, 1)) solid var(--accent-cyan); border-right: calc(1.5px * var(--node-scale, 1)) solid var(--accent-cyan); }
.node-corner.bl { bottom: calc(6px * var(--node-scale, 1)); left: calc(6px * var(--node-scale, 1)); border-bottom: calc(1.5px * var(--node-scale, 1)) solid var(--accent-cyan); border-left: calc(1.5px * var(--node-scale, 1)) solid var(--accent-cyan); }
.node-corner.br { bottom: calc(6px * var(--node-scale, 1)); right: calc(6px * var(--node-scale, 1)); border-bottom: calc(1.5px * var(--node-scale, 1)) solid var(--accent-cyan); border-right: calc(1.5px * var(--node-scale, 1)) solid var(--accent-cyan); }
.node-status { position: absolute; top: calc(10px * var(--node-scale, 1)); right: calc(10px * var(--node-scale, 1)); width: var(--node-status-size, 10px); height: var(--node-status-size, 10px); border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.status-ring { width: var(--node-status-ring-size, 8px); height: var(--node-status-ring-size, 8px); border-radius: 50%; }
.node-status.active::before {
content: '';
position: absolute;
inset: -6px;
border: 1px solid rgba(0,245,212,0.22);
border-radius: 999px;
animation: status-orbit 2.3s ease-out infinite;
}
.node-status.active .status-ring { background: var(--accent-cyan); box-shadow: 0 0 8px var(--accent-cyan); animation: status-pulse 1.8s ease-in-out infinite; }
.node-status.idle .status-ring { background: var(--text-secondary); }
.node-status.disabled .status-ring { background: var(--text-dim); opacity: 0.4; }
@keyframes status-pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
@keyframes status-orbit {
0% { transform: scale(0.5); opacity: 0.65; }
100% { transform: scale(1.3); opacity: 0; }
}
.node-label { font-family: var(--font-display); font-size: calc(8px * var(--node-scale, 1)); letter-spacing: 0.2em; color: var(--text-dim); margin-bottom: 1px; }
.node-master .node-label { color: rgba(0,245,212,0.5); }
.node-name { font-family: var(--font-display); font-size: calc(15px * var(--node-scale, 1)); font-weight: 700; letter-spacing: 0.08em; color: var(--accent-cyan); line-height: 1.2; }
.node-master .node-name { font-size: calc(18px * var(--node-scale, 1)); }
.node-role { font-family: var(--font-mono); font-size: calc(10px * var(--node-scale, 1)); color: var(--accent-amber); letter-spacing: 0.05em; }
.node-desc {
font-family: var(--font-mono); font-size: calc(10px * var(--node-scale, 1)); color: var(--text-secondary);
line-height: 1.5; flex: 1; overflow: hidden; display: -webkit-box;
-webkit-line-clamp: 3; -webkit-box-orient: vertical; text-overflow: ellipsis;
}
.node-child .node-desc { -webkit-line-clamp: 2; }
.node-footer { display: flex; align-items: center; gap: calc(8px * var(--node-scale, 1)); flex-wrap: wrap; margin-top: 2px; }
.node-stat { display: flex; align-items: center; gap: calc(4px * var(--node-scale, 1)); font-family: var(--font-mono); font-size: calc(9px * var(--node-scale, 1)); }
.stat-label { color: var(--text-dim); }
.stat-val { color: var(--accent-cyan); font-weight: 600; }
.node-task-tag {
font-family: var(--font-mono); font-size: calc(9px * var(--node-scale, 1)); color: var(--accent-amber);
background: rgba(249,168,37,0.08); border: 1px solid rgba(249,168,37,0.18);
border-radius: 3px; padding: calc(1px * var(--node-scale, 1)) calc(6px * var(--node-scale, 1)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: calc(120px * var(--node-scale, 1));
box-shadow: 0 0 10px rgba(249,168,37,0.05);
animation: task-tag-glow 3.4s ease-in-out infinite;
}
.node-idle { font-family: var(--font-mono); font-size: calc(9px * var(--node-scale, 1)); color: var(--text-dim); font-style: italic; }
.rel-label {
position: absolute; font-family: var(--font-mono); font-size: calc(8px * var(--node-scale, 1)); color: var(--text-dim);
letter-spacing: 0.05em; pointer-events: none; left: 50%; transform: translateX(-50%);
bottom: var(--node-rel-offset, -20px); white-space: nowrap;
}
@keyframes task-tag-glow {
0%, 100% { box-shadow: 0 0 8px rgba(249,168,37,0.04); }
50% { box-shadow: 0 0 14px rgba(249,168,37,0.1); }
}
.config-drawer {
position: fixed; top: 0; right: 0; width: 420px; height: 100%;
background: rgba(5,8,16,0.97); border-left: 1px solid var(--border-mid);
backdrop-filter: blur(20px); z-index: 100; display: flex; flex-direction: column;
box-shadow: -10px 0 40px rgba(0,0,0,0.5);
}
.drawer-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 99; }
.drawer-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border-dim); }
.drawer-title { font-family: var(--font-display); font-size: 11px; letter-spacing: 0.15em; color: var(--accent-cyan); }
.btn-close {
width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
background: transparent; border: 1px solid var(--border-dim); border-radius: var(--radius-sm);
color: var(--text-dim); cursor: pointer; transition: all var(--transition-fast);
}
.btn-close:hover { border-color: var(--accent-red); color: var(--accent-red); }
.drawer-body { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 16px; }
.drawer-body::-webkit-scrollbar { width: 4px; }
.drawer-body::-webkit-scrollbar-thumb { background: var(--border-mid); border-radius: 2px; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group.flex-1 { flex: 1; display: flex; flex-direction: column; }
.form-label { font-family: var(--font-mono); font-size: 9px; letter-spacing: 0.15em; color: var(--text-dim); }
.form-input {
background: var(--bg-card); border: 1px solid var(--border-mid); border-radius: var(--radius-sm);
padding: 10px 12px; color: var(--text-primary); font-family: var(--font-mono); font-size: 12px; outline: none;
transition: border-color var(--transition-fast);
}
.form-input:focus { border-color: var(--accent-cyan); box-shadow: 0 0 0 1px rgba(0,245,212,.1); }
.form-textarea {
background: var(--bg-card); border: 1px solid var(--border-mid); border-radius: var(--radius-sm);
padding: 10px 12px; color: var(--text-primary); font-family: var(--font-mono); font-size: 11px;
outline: none; resize: none; line-height: 1.5; transition: border-color var(--transition-fast);
}
.form-textarea:focus { border-color: var(--accent-cyan); box-shadow: 0 0 0 1px rgba(0,245,212,.1); }
.code-textarea { font-size: 10px; flex: 1; }
.toggle-row { display: flex; align-items: center; gap: 12px; }
.toggle-label { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.1em; color: var(--accent-cyan); transition: color .2s; }
.toggle-label.dim { color: var(--text-dim); }
.toggle-btn { width: 44px; height: 22px; background: var(--bg-card); border: 1px solid var(--border-mid); border-radius: 11px; padding: 2px; cursor: pointer; transition: all .25s; }
.toggle-btn.active { background: rgba(0,245,212,.15); border-color: var(--accent-cyan); }
.toggle-knob { display: block; width: 16px; height: 16px; border-radius: 50%; background: var(--text-dim); transition: all .25s; }
.toggle-btn.active .toggle-knob { background: var(--accent-cyan); box-shadow: 0 0 8px var(--accent-cyan); transform: translateX(22px); }
.drawer-actions { display: flex; gap: 12px; padding-top: 8px; }
.linked-skills-group {
padding: 12px;
border: 1px solid rgba(0,245,212,0.12);
border-radius: 12px;
background: rgba(10, 18, 30, 0.6);
}
.linked-skill-packages {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.linked-skill-package {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(0,245,212,0.12);
background: rgba(0,245,212,0.06);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 10px;
}
.linked-skill-package strong { color: var(--accent-cyan); font-weight: 600; }
.linked-skills-state {
padding: 12px;
border-radius: 10px;
background: rgba(255,255,255,0.03);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
}
.linked-skills-error { color: var(--accent-red); }
.linked-skill-list { display: flex; flex-direction: column; gap: 10px; }
.linked-skill-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px;
border: 1px solid rgba(0,245,212,0.1);
border-radius: 12px;
background: rgba(255,255,255,0.02);
}
.linked-skill-checkbox {
margin-top: 2px;
accent-color: var(--accent-cyan);
}
.linked-skill-copy { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.linked-skill-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.linked-skill-name { color: var(--text-primary); font-family: var(--font-display); font-size: 12px; letter-spacing: 0.05em; }
.linked-skill-agent-type {
color: var(--accent-amber);
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
}
.linked-skill-desc { color: var(--text-secondary); font-family: var(--font-mono); font-size: 11px; line-height: 1.5; }
.linked-skill-tools { display: flex; flex-wrap: wrap; gap: 6px; }
.linked-skill-tool {
padding: 3px 8px;
border-radius: 999px;
background: rgba(249,168,37,0.08);
border: 1px solid rgba(249,168,37,0.16);
color: var(--accent-amber);
font-family: var(--font-mono);
font-size: 10px;
}
.btn-secondary,.btn-primary {
flex: 1; padding: 10px 16px; border-radius: var(--radius-sm); font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.1em; cursor: pointer; transition: all var(--transition-fast);
display: flex; align-items: center; justify-content: center; gap: 6px;
}
.btn-secondary { background: transparent; border: 1px solid var(--border-mid); color: var(--text-secondary); }
.btn-secondary:hover { border-color: var(--accent-cyan); color: var(--accent-cyan); }
.btn-primary { background: rgba(0,245,212,.1); border: 1px solid var(--accent-cyan); color: var(--accent-cyan); }
.btn-primary:hover { background: rgba(0,245,212,.2); box-shadow: var(--glow-cyan); }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-loader { width: 12px; height: 12px; border: 1.5px solid transparent; border-top-color: var(--accent-cyan); border-radius: 50%; animation: spin .6s linear infinite; }
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.7); backdrop-filter: blur(4px);
z-index: 200; display: flex; align-items: center; justify-content: center;
}
.modal-card {
width: 480px; max-height: 80vh; background: rgba(10,15,26,.98); border: 1px solid var(--border-mid);
border-radius: var(--radius-lg); display: flex; flex-direction: column;
box-shadow: 0 20px 60px rgba(0,0,0,.6), 0 0 0 1px rgba(0,245,212,.05);
}
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border-dim); }
.modal-title { font-family: var(--font-display); font-size: 11px; letter-spacing: 0.15em; color: var(--accent-cyan); }
.modal-body { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 14px; }
.modal-body::-webkit-scrollbar { width: 4px; }
.modal-body::-webkit-scrollbar-thumb { background: var(--border-mid); border-radius: 2px; }
.modal-footer { display: flex; gap: 12px; padding: 16px 20px; border-top: 1px solid var(--border-dim); }

View File

@@ -3,28 +3,37 @@ import { mount } from '@vue/test-utils'
const mocks = vi.hoisted(() => ({
getHierarchyStats: vi.fn(),
getConfig: vi.fn(),
updateConfig: vi.fn(),
listSkills: vi.fn(),
}))
vi.mock('@/api/agent', () => ({
agentApi: {
getHierarchyStats: mocks.getHierarchyStats,
getConfig: mocks.getConfig,
updateConfig: mocks.updateConfig,
},
}))
vi.mock('@/api/skill', () => ({
skillApi: {
list: mocks.listSkills,
},
}))
import AgentsPage from './index.vue'
const hierarchyStats = {
main_agents: [
{
agent_id: 'planner',
agent_id: 'schedule_planner',
call_count: 12,
current_task: null,
status: 'idle',
sub_commanders: [
{ agent_id: 'planner_scope', call_count: 4, current_task: null, status: 'idle' },
{ agent_id: 'planner_steps', call_count: 9, current_task: '拆解执行步骤', status: 'active' },
{ agent_id: 'schedule_analysis', call_count: 4, current_task: null, status: 'idle' },
{ agent_id: 'schedule_planning', call_count: 9, current_task: '生成今日排期建议', status: 'active' },
],
},
{
@@ -60,6 +69,57 @@ const hierarchyStats = {
],
}
const skillFixtures = [
{
id: 'skill-schedule-1',
name: 'Priority Router',
description: 'Aligns planner priorities.',
instructions: 'Prioritize schedule risks.',
agent_type: 'schedule_planner',
tools: ['calendar', 'tasks'],
required_context: [],
output_format: null,
visibility: 'private' as const,
team_id: null,
is_active: true,
owner_id: 'user-1',
created_at: '2026-03-26T00:00:00Z',
updated_at: '2026-03-26T00:00:00Z',
},
{
id: 'skill-schedule-2',
name: 'Planning Synthesizer',
description: 'Builds the next schedule plan.',
instructions: 'Synthesize a practical plan.',
agent_type: 'schedule_planner',
tools: ['planning'],
required_context: [],
output_format: null,
visibility: 'private' as const,
team_id: null,
is_active: true,
owner_id: 'user-1',
created_at: '2026-03-26T00:00:00Z',
updated_at: '2026-03-26T00:00:00Z',
},
{
id: 'skill-executor-1',
name: 'Task Runner',
description: 'Executes tool actions.',
instructions: 'Run execution tasks.',
agent_type: 'executor',
tools: ['shell'],
required_context: [],
output_format: null,
visibility: 'private' as const,
team_id: null,
is_active: true,
owner_id: 'user-1',
created_at: '2026-03-26T00:00:00Z',
updated_at: '2026-03-26T00:00:00Z',
},
]
describe('agents page pcb command center', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -83,31 +143,53 @@ describe('agents page pcb command center', () => {
})),
})
mocks.getHierarchyStats.mockResolvedValue(hierarchyStats)
mocks.updateConfig.mockResolvedValue({})
mocks.getConfig.mockImplementation(async (id: string) => ({
id,
name: id === 'schedule_planner' ? 'SCHEDULE PLANNER' : id.toUpperCase(),
role: id,
description: 'config description',
system_prompt: 'config prompt',
enabled: true,
selected_skill_ids: id === 'schedule_planner' ? ['skill-schedule-1', 'skill-schedule-2'] : [],
}))
mocks.updateConfig.mockResolvedValue({ selected_skill_ids: ['skill-schedule-1'] })
mocks.listSkills.mockResolvedValue({ data: skillFixtures })
})
it('shows commander skills and active route telemetry for an active sub commander path', async () => {
it('shows active route telemetry only when hierarchy stats report an active sub commander path', async () => {
const wrapper = mount(AgentsPage)
await Promise.resolve()
await Promise.resolve()
const skillsPanel = wrapper.get('[data-testid="commander-skills"]')
expect(skillsPanel.text()).toContain('指挥官技能')
expect(skillsPanel.text()).toContain('路径拆解')
expect(wrapper.find('[data-testid="commander-skills"]').exists()).toBe(false)
const activeSkill = wrapper.get('[data-testid="commander-skill-skill_planner"]')
expect(activeSkill.classes()).toContain('active')
const plannerBus = wrapper.get('[data-testid="bus-link-planner"]')
const plannerBus = wrapper.get('[data-testid="bus-link-schedule_planner"]')
expect(plannerBus.classes()).toContain('energized')
const plannerStepsBranch = wrapper.get('[data-testid="sub-link-planner_steps"]')
const plannerStepsBranch = wrapper.get('[data-testid="sub-link-schedule_planning"]')
expect(plannerStepsBranch.classes()).toContain('energized')
const routeTelemetry = wrapper.get('[data-testid="route-telemetry"]')
expect(routeTelemetry.text()).toContain('ACTIVE ROUTE')
expect(routeTelemetry.text()).toContain('PLANNER')
expect(routeTelemetry.text()).toContain('STEPS')
expect(routeTelemetry.text()).toContain('SCHEDULE PLANNER')
expect(routeTelemetry.text()).toContain('PLANNING')
})
it('keeps route telemetry in standby when hierarchy stats contain no active path', async () => {
mocks.getHierarchyStats.mockResolvedValue({
main_agents: hierarchyStats.main_agents.map((main) => ({
...main,
status: 'idle',
sub_commanders: main.sub_commanders.map((child) => ({ ...child, status: 'idle' })),
})),
})
const wrapper = mount(AgentsPage)
await Promise.resolve()
await Promise.resolve()
expect(wrapper.get('[data-testid="route-telemetry"]').text()).toContain('STANDBY')
expect(wrapper.get('[data-testid="bus-link-schedule_planner"]').classes()).not.toContain('energized')
expect(wrapper.get('[data-testid="sub-link-schedule_planning"]').classes()).not.toContain('energized')
})
it('renders child agents beneath the four main roles in the same hierarchy canvas', async () => {
@@ -115,8 +197,8 @@ describe('agents page pcb command center', () => {
await Promise.resolve()
await Promise.resolve()
expect(wrapper.get('[data-testid="agent-chip-planner_scope"]').text()).toContain('SCOPE')
expect(wrapper.get('[data-testid="agent-chip-planner_steps"]').text()).toContain('STEPS')
expect(wrapper.get('[data-testid="agent-chip-schedule_analysis"]').text()).toContain('ANALYSIS')
expect(wrapper.get('[data-testid="agent-chip-schedule_planning"]').text()).toContain('PLANNING')
expect(wrapper.get('[data-testid="agent-chip-executor_tasks"]').text()).toContain('TASK OPS')
expect(wrapper.get('[data-testid="agent-chip-analyst_insights"]').text()).toContain('INSIGHTS')
})
@@ -126,7 +208,9 @@ describe('agents page pcb command center', () => {
await Promise.resolve()
await Promise.resolve()
await wrapper.get('[data-testid="agent-chip-planner"]').trigger('click')
await wrapper.get('[data-testid="agent-chip-schedule_planner"]').trigger('click')
await Promise.resolve()
await Promise.resolve()
expect(wrapper.text()).toContain('AGENT CONFIGURATION')
expect(wrapper.find('input').exists()).toBe(true)
@@ -138,13 +222,150 @@ describe('agents page pcb command center', () => {
await Promise.resolve()
await Promise.resolve()
expect(wrapper.get('[data-testid="bus-link-planner"]').classes()).toContain('energized')
expect(wrapper.get('[data-testid="sub-link-planner_scope"]').classes()).toContain('energized')
expect(wrapper.get('[data-testid="bus-link-schedule_planner"]').classes()).toContain('energized')
expect(wrapper.get('[data-testid="sub-link-schedule_analysis"]').classes()).toContain('energized')
await vi.advanceTimersByTimeAsync(1700)
expect(wrapper.get('[data-testid="sub-link-planner_scope"]').classes()).not.toContain('energized')
expect(wrapper.get('[data-testid="sub-link-planner_steps"]').classes()).toContain('energized')
expect(wrapper.get('[data-testid="sub-link-schedule_analysis"]').classes()).not.toContain('energized')
expect(wrapper.get('[data-testid="sub-link-schedule_planning"]').classes()).toContain('energized')
})
it('loads related skills into the configuration drawer for the selected node', async () => {
const wrapper = mount(AgentsPage)
await Promise.resolve()
await Promise.resolve()
await wrapper.get('[data-testid="agent-chip-schedule_planner"]').trigger('click')
await Promise.resolve()
await Promise.resolve()
expect(mocks.listSkills).toHaveBeenCalled()
expect(wrapper.get('[data-testid="linked-skills-section"]').text()).toContain('Priority Router')
expect(wrapper.get('[data-testid="linked-skills-section"]').text()).toContain('Planning Synthesizer')
expect(wrapper.get('[data-testid="linked-skills-package-skill_planner"]').text()).toContain('日程规划')
})
it('keeps linked skill selections after save and restores draft changes on reset', async () => {
let persistedSelections = ['skill-schedule-1', 'skill-schedule-2']
mocks.getConfig.mockImplementation(async (id: string) => ({
id,
name: id === 'schedule_planner' ? 'SCHEDULE PLANNER' : id.toUpperCase(),
role: id,
description: 'config description',
system_prompt: 'config prompt',
enabled: true,
selected_skill_ids: id === 'schedule_planner' ? [...persistedSelections] : [],
}))
mocks.updateConfig.mockImplementation(async (_id: string, payload: { selected_skill_ids?: string[] }) => {
persistedSelections = payload.selected_skill_ids ? [...payload.selected_skill_ids] : persistedSelections
return { selected_skill_ids: [...persistedSelections] }
})
const wrapper = mount(AgentsPage)
await Promise.resolve()
await Promise.resolve()
await wrapper.get('[data-testid="agent-chip-schedule_planner"]').trigger('click')
await Promise.resolve()
await Promise.resolve()
const firstCheckbox = wrapper.get('[data-testid="linked-skill-checkbox-skill-schedule-1"]')
const secondCheckbox = wrapper.get('[data-testid="linked-skill-checkbox-skill-schedule-2"]')
expect((firstCheckbox.element as HTMLInputElement).checked).toBe(true)
expect((secondCheckbox.element as HTMLInputElement).checked).toBe(true)
await firstCheckbox.setValue(false)
expect((firstCheckbox.element as HTMLInputElement).checked).toBe(false)
await wrapper.get('[data-testid="linked-skills-reset"]').trigger('click')
expect((wrapper.get('[data-testid="linked-skill-checkbox-skill-schedule-1"]').element as HTMLInputElement).checked).toBe(true)
await wrapper.get('[data-testid="linked-skill-checkbox-skill-schedule-2"]').setValue(false)
await wrapper.get('[data-testid="linked-skills-save"]').trigger('click')
await Promise.resolve()
await wrapper.get('[data-testid="agent-chip-schedule_planner"]').trigger('click')
await Promise.resolve()
await Promise.resolve()
expect((wrapper.get('[data-testid="linked-skill-checkbox-skill-schedule-1"]').element as HTMLInputElement).checked).toBe(true)
expect((wrapper.get('[data-testid="linked-skill-checkbox-skill-schedule-2"]').element as HTMLInputElement).checked).toBe(false)
})
it('sends selected skill ids when saving agent config', async () => {
const wrapper = mount(AgentsPage)
await Promise.resolve()
await Promise.resolve()
await wrapper.get('[data-testid="agent-chip-schedule_planner"]').trigger('click')
await Promise.resolve()
await Promise.resolve()
await wrapper.get('[data-testid="linked-skill-checkbox-skill-schedule-2"]').setValue(false)
await wrapper.get('[data-testid="linked-skills-save"]').trigger('click')
await Promise.resolve()
expect(mocks.updateConfig).toHaveBeenCalledWith(
'schedule_planner',
expect.objectContaining({ selected_skill_ids: ['skill-schedule-1'] }),
)
})
it('restores persisted selected skill ids after remount', async () => {
let persistedSelections = ['skill-schedule-1', 'skill-schedule-2']
mocks.getConfig.mockImplementation(async (id: string) => ({
id,
name: id === 'schedule_planner' ? 'SCHEDULE PLANNER' : id.toUpperCase(),
role: id,
description: 'config description',
system_prompt: 'config prompt',
enabled: true,
selected_skill_ids: id === 'schedule_planner' ? [...persistedSelections] : [],
}))
mocks.updateConfig.mockImplementation(async (_id: string, payload: { selected_skill_ids?: string[] }) => {
persistedSelections = payload.selected_skill_ids ? [...payload.selected_skill_ids] : persistedSelections
return { selected_skill_ids: [...persistedSelections] }
})
const firstWrapper = mount(AgentsPage)
await Promise.resolve()
await Promise.resolve()
await firstWrapper.get('[data-testid="agent-chip-schedule_planner"]').trigger('click')
await Promise.resolve()
await Promise.resolve()
await firstWrapper.get('[data-testid="linked-skill-checkbox-skill-schedule-2"]').setValue(false)
await firstWrapper.get('[data-testid="linked-skills-save"]').trigger('click')
await Promise.resolve()
firstWrapper.unmount()
const secondWrapper = mount(AgentsPage)
await Promise.resolve()
await Promise.resolve()
await secondWrapper.get('[data-testid="agent-chip-schedule_planner"]').trigger('click')
await Promise.resolve()
await Promise.resolve()
expect((secondWrapper.get('[data-testid="linked-skill-checkbox-skill-schedule-1"]').element as HTMLInputElement).checked).toBe(true)
expect((secondWrapper.get('[data-testid="linked-skill-checkbox-skill-schedule-2"]').element as HTMLInputElement).checked).toBe(false)
})
it('shows an empty linked skills state when no matching skills are available', async () => {
mocks.listSkills.mockResolvedValue({ data: [] })
const wrapper = mount(AgentsPage)
await Promise.resolve()
await Promise.resolve()
await wrapper.get('[data-testid="agent-chip-schedule_planner"]').trigger('click')
await Promise.resolve()
await Promise.resolve()
expect(wrapper.get('[data-testid="linked-skills-empty"]').text()).toContain('暂无可关联技能')
})
})

View File

@@ -0,0 +1,743 @@
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { COMMANDER_SKILLS, DEFAULT_AGENTS, MAIN_AGENT_ORDER, RELATION_LABELS, SUB_COMMANDERS } from '@/data/agents'
import type { Agent, CommanderSkill, MainAgentId, SubCommander } from '@/data/agents'
import { agentApi, type AgentHierarchyStats, type AgentStats } from '@/api/agent'
import { skillApi, type Skill } from '@/api/skill'
export function useAgentsPage() {
const NODE_W = 200
const NODE_H = 170
const CHILD_W = 140
const CHILD_H = 150
const MASTER_TOP = 48
const MAIN_TOP = 350
const CHILD_TOP = 640
const MAIN_XS: Record<Exclude<MainAgentId, 'master'>, number> = {
schedule_planner: 12.5,
executor: 37.5,
librarian: 62.5,
analyst: 87.5,
}
const CHILD_LANE_OFFSET = 6
const motionEnabled = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia('(prefers-reduced-motion: no-preference)').matches
: false
const MIN_ZOOM = 0.8
const MAX_ZOOM = 1.6
const ZOOM_STEP = 0.1
const CRISP_ZOOM_THRESHOLD = 1.12
const OFFLINE_ROUTE_INTERVAL = 1700
type PlaybackHandle = ReturnType<typeof window.setTimeout>
type PollHandle = ReturnType<typeof setInterval>
interface AgentRuntimeState {
callCount: number
currentTask: string | null
status: string
}
interface AgentDraft {
name: string
role: string
description: string
systemPrompt: string
enabled: boolean
selectedSkillIds: string[]
}
const mainAgents = computed(() => MAIN_AGENT_ORDER.map(id => localAgents[id]))
const childAgents = SUB_COMMANDERS
const relationLabels = RELATION_LABELS
const childLabelMap = Object.fromEntries(childAgents.map(child => [child.id, child.name])) as Record<string, string>
const childMetaMap = Object.fromEntries(childAgents.map(child => [child.id, child])) as Record<string, SubCommander>
const canvasRef = ref<HTMLElement | null>(null)
const svgRef = ref<SVGElement | null>(null)
const masterCardRef = ref<HTMLElement | null>(null)
const nodeRefs: Record<string, HTMLElement> = {}
const cleanupFns: Array<() => void> = []
const hoverResetTimers: Record<string, PlaybackHandle | null> = {}
const bgParticles = Array.from({ length: 60 }, (_, i) => {
const d = 3 + Math.random() * 5
const delay = Math.random() * 4
const o = 0.25 + Math.random() * 0.5
const size = 1 + Math.random() * 2.5
return {
id: i,
style: {
left: `${Math.random() * 98}%`,
top: `${Math.random() * 95}%`,
width: `${size}px`,
height: `${size}px`,
'--d': `${d}s`,
'--delay': `${delay}s`,
'--o': String(o),
opacity: o,
},
}
})
let resizeObserver: ResizeObserver | null = null
let pollInterval: PollHandle | null = null
let demoInterval: PollHandle | null = null
const selectedAgentId = ref<string | null>(null)
const drawerOpen = ref(false)
const addModalOpen = ref(false)
const editAgent = ref<AgentDraft | null>(null)
const newAgent = reactive({ name: '', roleKey: '', role: '', description: '', systemPrompt: '' })
const saving = ref(false)
const loading = ref(false)
const skillsLoading = ref(false)
const skillsError = ref('')
const saveError = ref('')
const availableSkillsByNode = reactive<Record<string, Skill[]>>({})
const agentSkillSelections = reactive<Record<string, string[]>>({})
const activeSkillRequestId = ref(0)
const zoom = ref(1)
const pan = reactive({ x: 0, y: 0 })
const basePan = reactive({ x: 0, y: 0 })
const isPanning = ref(false)
const panStart = reactive({ x: 0, y: 0 })
const panOrigin = reactive({ x: 0, y: 0 })
const connectionStatus = ref<'connected' | 'disconnected'>('disconnected')
const connectionLabel = computed(() => connectionStatus.value === 'connected' ? '瀹炴椂鍚屾' : '绂荤嚎妯″紡')
const zoomPercent = computed(() => `${Math.round(zoom.value * 100)}%`)
const activeMainId = ref<string | null>(null)
const activeChildId = ref<string | null>(null)
const agentData = reactive<Record<string, AgentRuntimeState>>({})
const localAgents = reactive<Record<string, Agent>>(
Object.fromEntries([
...DEFAULT_AGENTS,
...SUB_COMMANDERS.map((child) => ({
id: child.id,
name: child.name,
role: child.role,
roleKey: child.id,
description: child.description,
systemPrompt: `${child.role}锛?{child.description}`,
enabled: true,
})),
].map(agent => [agent.id, { ...agent }]))
)
const nodePackagesMap = COMMANDER_SKILLS.reduce<Record<string, CommanderSkill[]>>((acc, skillPkg) => {
skillPkg.relatedAgentIds.forEach((nodeId) => {
acc[nodeId] = [...(acc[nodeId] || []), skillPkg]
})
return acc
}, {})
const layoutZoom = computed(() => Math.min(zoom.value, CRISP_ZOOM_THRESHOLD))
const stageScale = computed(() => zoom.value / layoutZoom.value)
const viewportStyle = computed(() => ({
transform: `translate(${roundPx(basePan.x + pan.x)}px, ${roundPx(basePan.y + pan.y)}px)`,
}))
const stageStyle = computed(() => ({
width: '100%',
minHeight: `${roundPx((CHILD_TOP + CHILD_H + 120) * layoutZoom.value)}px`,
left: '0px',
transform: `scale(${stageScale.value})`,
transformOrigin: '50% 0%',
'--node-scale': String(layoutZoom.value),
}))
const activeMainAgents = computed(() => mainAgents.value.filter(agent => agent.id === activeMainId.value))
const activeChildAgents = computed(() => childAgents.filter(child => child.id === activeChildId.value))
const selectedNodePackages = computed(() => selectedAgentId.value ? nodePackagesMap[selectedAgentId.value] || [] : [])
const selectedNodeSkills = computed(() => {
if (!selectedAgentId.value) return []
return (availableSkillsByNode[selectedAgentId.value] || []).filter(skill => matchesNodeSkill(selectedAgentId.value as string, skill))
})
const activeMainRouteLabel = computed(() => {
if (!activeMainId.value) return 'STANDBY'
return getAgentName(activeMainId.value)
})
const activeChildRouteLabel = computed(() => activeChildId.value ? childLabelMap[activeChildId.value] || 'STANDBY' : 'STANDBY')
function roundPx(value: number) {
return Math.round(value)
}
function getCanvasMetrics() {
const canvas = canvasRef.value
if (!canvas) return { width: 0, height: 0 }
return { width: canvas.clientWidth, height: canvas.clientHeight }
}
function pxToSvg(pctX: number) {
const canvas = canvasRef.value
if (!canvas) return 0
return (pctX / 100) * canvas.clientWidth
}
function updateBasePan() {
const { width, height } = getCanvasMetrics()
const scaledWidth = width * zoom.value
const scaledHeight = height * zoom.value
basePan.x = width ? roundPx((width - scaledWidth) / 2) : 0
basePan.y = height ? roundPx((height - scaledHeight) / 2) : 0
}
function getNodeMetrics(width = NODE_W, height = NODE_H) {
return {
width: roundPx(width * layoutZoom.value),
height: roundPx(height * layoutZoom.value),
paddingX: roundPx(16 * layoutZoom.value),
paddingY: roundPx(14 * layoutZoom.value),
corner: Math.max(6, roundPx(10 * layoutZoom.value)),
status: Math.max(8, roundPx(10 * layoutZoom.value)),
statusRing: Math.max(6, roundPx(8 * layoutZoom.value)),
relOffset: roundPx(-20 * layoutZoom.value),
}
}
function buildNodeStyle(centerPct: number, top: number, width = NODE_W, height = NODE_H) {
const x = pxToSvg(centerPct)
const metrics = getNodeMetrics(width, height)
return {
left: `${roundPx(x - metrics.width / 2)}px`,
top: `${roundPx(top)}px`,
width: `${metrics.width}px`,
height: `${metrics.height}px`,
'--node-padding-x': `${metrics.paddingX}px`,
'--node-padding-y': `${metrics.paddingY}px`,
'--node-corner-size': `${metrics.corner}px`,
'--node-status-size': `${metrics.status}px`,
'--node-status-ring-size': `${metrics.statusRing}px`,
'--node-rel-offset': `${metrics.relOffset}px`,
}
}
const masterNodeStyle = computed(() => buildNodeStyle(50, MASTER_TOP, NODE_W, NODE_H))
function getMainNodeStyle(id: string) {
return buildNodeStyle(getMainLaneX(id as Exclude<MainAgentId, 'master'>), MAIN_TOP, NODE_W, NODE_H)
}
function getChildNodeStyle(id: string) {
const child = childMetaMap[id]
const parentX = getMainLaneX(child.parentId as Exclude<MainAgentId, 'master'>)
const siblingIndex = childAgents.filter(item => item.parentId === child.parentId).findIndex(item => item.id === id)
const offset = siblingIndex === 0 ? -CHILD_LANE_OFFSET : CHILD_LANE_OFFSET
return buildNodeStyle(parentX + offset, CHILD_TOP, CHILD_W, CHILD_H)
}
function getCurvePath(fromX: number, fromY: number, toX: number, toY: number) {
const midY = (fromY + toY) / 2
return `M ${roundPx(fromX)},${roundPx(fromY)} C ${roundPx(fromX)},${roundPx(midY)} ${roundPx(toX)},${roundPx(midY)} ${roundPx(toX)},${roundPx(toY)}`
}
function getBusLinePath(mainId: string) {
const metrics = getNodeMetrics()
return getCurvePath(pxToSvg(50), MASTER_TOP + metrics.height / 2, pxToSvg(getMainLaneX(mainId as Exclude<MainAgentId, 'master'>)), MAIN_TOP + metrics.height / 2)
}
function getSubLinePath(childId: string) {
const child = childMetaMap[childId]
const mainMetrics = getNodeMetrics()
const childMetrics = getNodeMetrics(CHILD_W, CHILD_H)
const parentX = pxToSvg(getMainLaneX(child.parentId as Exclude<MainAgentId, 'master'>))
const siblingIndex = childAgents.filter(item => item.parentId === child.parentId).findIndex(item => item.id === childId)
const childX = pxToSvg(getMainLaneX(child.parentId as Exclude<MainAgentId, 'master'>) + (siblingIndex === 0 ? -CHILD_LANE_OFFSET : CHILD_LANE_OFFSET))
return getCurvePath(parentX, MAIN_TOP + mainMetrics.height / 2, childX, CHILD_TOP + childMetrics.height / 2)
}
function updateSvgSize() {
const canvas = canvasRef.value
const svg = svgRef.value
if (!canvas || !svg) return
svg.setAttribute('width', String(canvas.clientWidth))
svg.setAttribute('height', String(Math.max(canvas.clientHeight, CHILD_TOP + CHILD_H + 120)))
}
function setNodeRef(id: string, el: HTMLElement | null) {
if (el) nodeRefs[id] = el
}
function getStatusClass(agentId: string) {
const data = agentData[agentId]
const agent = localAgents[agentId]
if (!agent?.enabled) return 'disabled'
if (!data) return 'idle'
return data.status === 'active' ? 'active' : 'idle'
}
function getAgentName(id: string) { return localAgents[id]?.name || id.toUpperCase() }
function getAgentRole(id: string) { return localAgents[id]?.role || '' }
function getAgentDesc(id: string) { return localAgents[id]?.description || '' }
function getMainLaneX(agentId: Exclude<MainAgentId, 'master'>) {
return MAIN_XS[agentId]
}
function getNodeRootAgentType(nodeId: string) {
if (nodeId === 'master') return 'general'
return childMetaMap[nodeId]?.parentId || nodeId
}
function matchesNodeSkill(nodeId: string, skill: Skill) {
return skill.is_active && skill.agent_type === getNodeRootAgentType(nodeId)
}
function ensureSkillSelections(nodeId: string, fallbackSkillIds?: string[]) {
if (agentSkillSelections[nodeId]) return
agentSkillSelections[nodeId] = fallbackSkillIds ? [...fallbackSkillIds] : selectedNodeSkills.value.map(skill => skill.id)
}
async function loadSkillsForNode(nodeId: string) {
const requestId = activeSkillRequestId.value + 1
activeSkillRequestId.value = requestId
skillsLoading.value = true
skillsError.value = ''
try {
const response = await skillApi.list({ agent_type: getNodeRootAgentType(nodeId) })
if (activeSkillRequestId.value !== requestId || selectedAgentId.value !== nodeId) return
availableSkillsByNode[nodeId] = response.data
if (!agentSkillSelections[nodeId]) {
agentSkillSelections[nodeId] = response.data.filter(skill => matchesNodeSkill(nodeId, skill)).map(skill => skill.id)
}
} catch {
if (activeSkillRequestId.value !== requestId || selectedAgentId.value !== nodeId) return
availableSkillsByNode[nodeId] = []
skillsError.value = '加载技能失败,请稍后重试。'
} finally {
if (activeSkillRequestId.value === requestId && selectedAgentId.value === nodeId) {
skillsLoading.value = false
}
}
}
function toggleSkillSelection(skillId: string, checked: boolean) {
if (!editAgent.value) return
editAgent.value = {
...editAgent.value,
selectedSkillIds: checked
? [...editAgent.value.selectedSkillIds, skillId]
: editAgent.value.selectedSkillIds.filter(id => id !== skillId),
}
}
function clampZoom(value: number) {
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number(value.toFixed(2))))
}
function applyZoom(nextZoom: number, anchorX?: number, anchorY?: number) {
const clamped = clampZoom(nextZoom)
const canvas = canvasRef.value
const previousZoom = zoom.value
if (!canvas || clamped === previousZoom) {
zoom.value = clamped
updateBasePan()
return
}
const rect = canvas.getBoundingClientRect()
const localX = anchorX ?? rect.width / 2
const localY = anchorY ?? rect.height / 2
const contentX = (localX - (basePan.x + pan.x)) / previousZoom
const contentY = (localY - (basePan.y + pan.y)) / previousZoom
zoom.value = clamped
updateBasePan()
pan.x = roundPx(localX - basePan.x - contentX * zoom.value)
pan.y = roundPx(localY - basePan.y - contentY * zoom.value)
}
function zoomIn() {
applyZoom(zoom.value + ZOOM_STEP)
}
function zoomOut() {
applyZoom(zoom.value - ZOOM_STEP)
}
function resetView() {
zoom.value = 1
pan.x = 0
pan.y = 0
updateBasePan()
}
function handleWheel(event: WheelEvent) {
const delta = event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP
const canvas = canvasRef.value
if (!canvas) {
applyZoom(zoom.value + delta)
return
}
const rect = canvas.getBoundingClientRect()
applyZoom(zoom.value + delta, event.clientX - rect.left, event.clientY - rect.top)
}
function startPan(event: MouseEvent) {
const target = event.target as HTMLElement | null
if (!target || target.closest('.node-card') || target.closest('.canvas-controls') || target.closest('.hud-panel')) return
isPanning.value = true
panStart.x = event.clientX
panStart.y = event.clientY
panOrigin.x = pan.x
panOrigin.y = pan.y
window.addEventListener('mousemove', movePan)
window.addEventListener('mouseup', endPan, { once: true })
}
function movePan(event: MouseEvent) {
if (!isPanning.value) return
pan.x = panOrigin.x + event.clientX - panStart.x
pan.y = panOrigin.y + event.clientY - panStart.y
}
function endPan() {
isPanning.value = false
window.removeEventListener('mousemove', movePan)
}
async function selectAgent(id: string) {
const agent = localAgents[id]
if (!agent) return
selectedAgentId.value = id
saveError.value = ''
let persistedSkillIds: string[] | undefined
try {
const config = await agentApi.getConfig(id)
if (localAgents[id]) {
Object.assign(localAgents[id], {
name: config.name,
description: config.description,
systemPrompt: config.system_prompt,
enabled: config.enabled,
})
}
persistedSkillIds = config.selected_skill_ids || []
agentSkillSelections[id] = [...persistedSkillIds]
} catch {
persistedSkillIds = agentSkillSelections[id] || []
}
await loadSkillsForNode(id)
ensureSkillSelections(id, persistedSkillIds)
editAgent.value = {
name: localAgents[id].name,
role: localAgents[id].role,
description: localAgents[id].description,
systemPrompt: localAgents[id].systemPrompt,
enabled: localAgents[id].enabled,
selectedSkillIds: [...(agentSkillSelections[id] || [])],
}
drawerOpen.value = true
}
function resetConfig() {
const currentId = selectedAgentId.value || ''
const original = localAgents[currentId]
if (original && editAgent.value) {
editAgent.value = {
name: original.name,
role: original.role,
description: original.description,
systemPrompt: original.systemPrompt,
enabled: original.enabled,
selectedSkillIds: [...(agentSkillSelections[currentId] || [])],
}
}
}
async function saveConfig() {
if (!editAgent.value || !selectedAgentId.value) return
saving.value = true
saveError.value = ''
try {
const nextLocalState = {
name: editAgent.value.name,
role: editAgent.value.role,
description: editAgent.value.description,
systemPrompt: editAgent.value.systemPrompt,
enabled: editAgent.value.enabled,
}
try {
const response = await agentApi.updateConfig(selectedAgentId.value, {
name: editAgent.value.name,
description: editAgent.value.description,
system_prompt: editAgent.value.systemPrompt,
enabled: editAgent.value.enabled,
selected_skill_ids: [...editAgent.value.selectedSkillIds],
})
if (localAgents[selectedAgentId.value]) {
Object.assign(localAgents[selectedAgentId.value], nextLocalState)
}
agentSkillSelections[selectedAgentId.value] = [...(response.selected_skill_ids || editAgent.value.selectedSkillIds)]
drawerOpen.value = false
} catch {
saveError.value = '保存失败,请稍后重试。'
}
} finally {
saving.value = false
}
}
function addAgent() {
if (!newAgent.name || !newAgent.roleKey) return
const id = newAgent.roleKey.toLowerCase().replace(/\s+/g, '_')
if (localAgents[id]) return
localAgents[id] = { id, name: newAgent.name.toUpperCase(), role: newAgent.role, roleKey: id, description: newAgent.description, systemPrompt: newAgent.systemPrompt, enabled: true }
addModalOpen.value = false
}
function setRuntimeState(agentId: string, state: AgentStats) {
agentData[agentId] = {
callCount: state.call_count,
currentTask: state.current_task,
status: state.status,
}
}
function applyHierarchyStats(stats: AgentHierarchyStats) {
agentData.master = { callCount: 47, currentTask: '鍗忚皟缁勭粐閾捐矾', status: 'active' }
let nextMain: string | null = null
let nextChild: string | null = null
for (const main of stats.main_agents) {
setRuntimeState(main.agent_id, main)
if (main.status === 'active') nextMain = main.agent_id
for (const child of main.sub_commanders) {
setRuntimeState(child.agent_id, child)
if (child.status === 'active') {
nextMain = main.agent_id
nextChild = child.agent_id
}
}
}
activeMainId.value = nextMain
activeChildId.value = nextChild
}
function stopDemoRouteCycle() {
if (demoInterval) {
clearInterval(demoInterval)
demoInterval = null
}
}
function startDemoRouteCycle() {
stopDemoRouteCycle()
const demoRoutes = childAgents.map(child => ({ mainId: child.parentId, childId: child.id }))
let index = 0
const applyRoute = () => {
const route = demoRoutes[index]
activeMainId.value = route.mainId
activeChildId.value = route.childId
index = (index + 1) % demoRoutes.length
}
applyRoute()
demoInterval = setInterval(applyRoute, OFFLINE_ROUTE_INTERVAL)
}
function buildOfflineStats() {
return {
main_agents: [
{
agent_id: 'schedule_planner',
call_count: 12,
current_task: null,
status: 'active',
sub_commanders: [
{ agent_id: 'schedule_analysis', call_count: 4, current_task: '姊崇悊浠婃棩閲嶇偣', status: 'active' },
{ agent_id: 'schedule_planning', call_count: 9, current_task: null, status: 'idle' },
],
},
{
agent_id: 'executor',
call_count: 8,
current_task: '鍒涘缓鏂囨。',
status: 'idle',
sub_commanders: [
{ agent_id: 'executor_tasks', call_count: 8, current_task: null, status: 'idle' },
{ agent_id: 'executor_forum', call_count: 4, current_task: null, status: 'idle' },
],
},
{
agent_id: 'librarian',
call_count: 5,
current_task: null,
status: 'idle',
sub_commanders: [
{ agent_id: 'librarian_retrieval', call_count: 5, current_task: null, status: 'idle' },
{ agent_id: 'librarian_graph', call_count: 2, current_task: null, status: 'idle' },
],
},
{
agent_id: 'analyst',
call_count: 3,
current_task: null,
status: 'idle',
sub_commanders: [
{ agent_id: 'analyst_progress', call_count: 2, current_task: null, status: 'idle' },
{ agent_id: 'analyst_insights', call_count: 3, current_task: null, status: 'idle' },
],
},
],
} satisfies AgentHierarchyStats
}
async function refreshStats() {
loading.value = true
try {
const stats = await agentApi.getHierarchyStats()
applyHierarchyStats(stats)
connectionStatus.value = 'connected'
stopDemoRouteCycle()
} catch {
connectionStatus.value = 'disconnected'
applyHierarchyStats(buildOfflineStats())
startDemoRouteCycle()
} finally {
loading.value = false
}
}
function stopTimer(timer: PlaybackHandle | null) {
if (timer) window.clearTimeout(timer)
}
function runTransition(el: Element, keyframes: Keyframe[], options: KeyframeAnimationOptions, done?: () => void) {
const target = el as HTMLElement
if (typeof target.animate !== 'function') {
done?.()
return null
}
const animation = target.animate(keyframes, { fill: 'forwards', ...options })
const finish = () => done?.()
animation.addEventListener('finish', finish, { once: true })
cleanupFns.push(() => animation.cancel())
return animation
}
function animateIn(el: Element, done: () => void) {
runTransition(el, [{ opacity: 0, transform: 'translateX(80px)' }, { opacity: 1, transform: 'translateX(0)' }], { duration: 350, easing: 'cubic-bezier(0.4, 0, 0.2, 1)' }, done)
}
function animateOut(el: Element, done: () => void) {
runTransition(el, [{ opacity: 1, transform: 'translateX(0)' }, { opacity: 0, transform: 'translateX(80px)' }], { duration: 250, easing: 'cubic-bezier(0.4, 0, 1, 1)' }, done)
}
function fadeIn(el: Element, done: () => void) {
runTransition(el, [{ opacity: 0 }, { opacity: 1 }], { duration: 250 }, done)
}
function fadeOut(el: Element, done: () => void) {
runTransition(el, [{ opacity: 1 }, { opacity: 0 }], { duration: 200 }, done)
}
function playEntranceAnimations() {
if (!motionEnabled) return
if (masterCardRef.value) {
runTransition(masterCardRef.value, [
{ opacity: 0, transform: 'translateY(14px) scale(0.99)', filter: 'brightness(0.86)' },
{ opacity: 1, transform: 'translateY(0) scale(1)', filter: 'brightness(1)' },
], { duration: 680, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' })
}
;[...mainAgents.value.map(agent => agent.id), ...childAgents.map(child => child.id)].forEach((id, idx) => {
const el = nodeRefs[id]
if (!el) return
runTransition(el, [
{ opacity: 0, transform: 'translateY(12px) scale(0.99)', filter: 'brightness(0.82)' },
{ opacity: 1, transform: 'translateY(0) scale(1)', filter: 'brightness(1)' },
], { duration: 500, delay: 210 + idx * 55, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' })
const handleMouseEnter = () => {
if (!localAgents[id]?.enabled) return
stopTimer(hoverResetTimers[id] ?? null)
el.style.transform = 'translateY(-2px)'
}
const handleMouseLeave = () => {
stopTimer(hoverResetTimers[id] ?? null)
hoverResetTimers[id] = window.setTimeout(() => {
el.style.transform = ''
hoverResetTimers[id] = null
}, 180)
}
el.addEventListener('mouseenter', handleMouseEnter)
el.addEventListener('mouseleave', handleMouseLeave)
cleanupFns.push(() => {
stopTimer(hoverResetTimers[id] ?? null)
el.removeEventListener('mouseenter', handleMouseEnter)
el.removeEventListener('mouseleave', handleMouseLeave)
})
})
}
onMounted(async () => {
await refreshStats()
pollInterval = setInterval(refreshStats, 5000)
requestAnimationFrame(() => {
updateBasePan()
updateSvgSize()
playEntranceAnimations()
})
resizeObserver = new ResizeObserver(() => {
updateBasePan()
updateSvgSize()
})
if (canvasRef.value) resizeObserver.observe(canvasRef.value)
})
onUnmounted(() => {
if (pollInterval) clearInterval(pollInterval)
stopDemoRouteCycle()
resizeObserver?.disconnect()
window.removeEventListener('mousemove', movePan)
window.removeEventListener('mouseup', endPan)
cleanupFns.forEach(cleanup => cleanup())
})
return {
bgParticles,
canvasRef,
svgRef,
masterCardRef,
isPanning,
connectionStatus,
connectionLabel,
loading,
drawerOpen,
addModalOpen,
editAgent,
newAgent,
skillsLoading,
skillsError,
saveError,
saving,
mainAgents,
childAgents,
relationLabels,
activeMainId,
activeChildId,
activeMainAgents,
activeChildAgents,
activeMainRouteLabel,
activeChildRouteLabel,
selectedAgentId,
selectedNodePackages,
selectedNodeSkills,
agentData,
localAgents,
viewportStyle,
stageStyle,
masterNodeStyle,
zoomPercent,
refreshStats,
startPan,
handleWheel,
getBusLinePath,
getSubLinePath,
selectAgent,
setNodeRef,
getStatusClass,
getAgentName,
getAgentRole,
getAgentDesc,
getMainNodeStyle,
getChildNodeStyle,
zoomOut,
zoomIn,
resetView,
toggleSkillSelection,
resetConfig,
saveConfig,
addAgent,
animateIn,
animateOut,
fadeIn,
fadeOut,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { watch } from 'vue'
import { RouterView, useRouter } from 'vue-router'
import SidebarNav from '@/components/SidebarNav.vue'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
@@ -19,9 +18,13 @@ watch(
<template>
<div class="layout scanlines">
<SidebarNav />
<main class="main-content grid-bg">
<RouterView />
<div class="grid-drift-layer" aria-hidden="true"></div>
<div class="grid-scan-layer" aria-hidden="true"></div>
<div class="grid-vertical-scan-layer" aria-hidden="true"></div>
<div class="content-layer">
<RouterView />
</div>
</main>
</div>
</template>
@@ -40,4 +43,63 @@ watch(
overflow: hidden;
position: relative;
}
/* animated background layers */
.grid-drift-layer,
.grid-scan-layer,
.grid-vertical-scan-layer {
position: absolute;
inset: 0;
pointer-events: none;
}
.grid-drift-layer {
z-index: 0;
opacity: 0.7;
background-image:
linear-gradient(rgba(0, 245, 212, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 245, 212, 0.04) 1px, transparent 1px);
background-size: 40px 40px;
animation: grid-drift 18s linear infinite;
}
.grid-scan-layer {
z-index: 2;
background:
linear-gradient(120deg, transparent 0%, rgba(0, 245, 212, 0.01) 46%, rgba(0, 245, 212, 0.38) 50%, rgba(0, 245, 212, 0.01) 54%, transparent 100%);
mix-blend-mode: screen;
filter: drop-shadow(0 0 18px rgba(0, 245, 212, 0.35));
will-change: transform, opacity;
opacity: 0;
transform: translate3d(-28%, 0, 0);
animation: grid-scan-sweep 5.6s ease-in-out infinite;
}
.grid-vertical-scan-layer {
z-index: 2;
background:
linear-gradient(180deg, transparent 0%, rgba(0, 245, 212, 0.008) 47%, rgba(0, 245, 212, 0.34) 50%, rgba(0, 245, 212, 0.008) 53%, transparent 100%);
mix-blend-mode: screen;
filter: drop-shadow(0 0 18px rgba(0, 245, 212, 0.3));
will-change: transform, opacity;
opacity: 0;
transform: translate3d(0, -24%, 0);
animation: grid-vertical-scan 7.4s ease-in-out infinite 0.8s;
}
.content-layer {
position: relative;
z-index: 1;
height: 100%;
}
@media (prefers-reduced-motion: reduce) {
.grid-drift-layer,
.grid-scan-layer,
.grid-vertical-scan-layer {
animation: none;
transform: none;
opacity: 0;
}
}
</style>

View File

@@ -1,14 +0,0 @@
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
describe('brain graph embedding', () => {
it('renders the reusable graph projection component directly instead of an iframe shell', () => {
const brainPage = readFileSync(path.resolve(__dirname, './index.vue'), 'utf-8')
expect(brainPage).toContain('GraphProjection')
expect(brainPage).not.toContain('<iframe')
expect(brainPage).not.toContain('src="/graph"')
})
})

View File

@@ -1,18 +0,0 @@
import { describe, expect, it } from 'vitest'
import { navItems } from '@/app/navigation/nav'
import { appChildren } from '@/app/router/routes'
describe('brain routing', () => {
it('points the knowledge brain nav item to /brain', () => {
const item = navItems.find((entry) => entry.name === '知识大脑')
expect(item?.path).toBe('/brain')
})
it('registers a brain page route', () => {
const route = appChildren.find((entry) => entry.name === 'brain')
expect(route?.path).toBe('brain')
})
})

View File

@@ -1,7 +0,0 @@
<script setup lang="ts">
import GraphProjection from '@/components/brain/GraphProjection.vue'
</script>
<template>
<GraphProjection fullscreen :show-open-full-view="false" />
</template>

View File

@@ -0,0 +1,108 @@
import { mount, flushPromises } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createMemoryHistory, createRouter } from 'vue-router'
import ChatPage from './index.vue'
import { navItems } from '@/app/navigation/nav'
vi.mock('@/pages/chat/composables/useChatView', async () => {
const { ref } = await import('vue')
return {
useChatView: () => ({
store: {
conversations: [],
messages: [],
currentConversationId: null,
},
inputMessage: ref(''),
isSending: ref(false),
chatContainer: ref(null),
inputRef: ref(null),
isTyping: ref(false),
fileInputRef: ref(null),
showEmojiPicker: ref(false),
chatModels: ref([]),
selectedModelName: ref(''),
selectedModel: ref(null),
isLoadingModels: ref(false),
conversationsError: ref(''),
orchestrationStatus: ref('idle'),
orchestrationInsight: ref({ statusTitle: '', jarvisNote: '', details: [] }),
activeAgent: ref(''),
visitedAgents: ref([]),
orchestrationEventFeed: ref([]),
systemMeta: ref({
systemName: '',
systemVersion: '',
uptimeSeconds: 0,
gpuUtilPercent: null,
gpuName: '',
gpuMemoryUsedMb: null,
gpuMemoryTotalMb: null,
diskUsedGb: 0,
diskTotalGb: 0,
}),
systemTelemetry: ref({
cpu: { online: false, current: null, series: [] },
memory: { online: false, current: null, series: [] },
disk: { online: false, current: null, series: [] },
gpu: { online: false, current: null, series: [] },
network: {
upload: { online: false, current: null, series: [] },
download: { online: false, current: null, series: [] },
},
}),
sessionTelemetry: ref({
eventsCount: 0,
toolCount: 0,
agentCount: 0,
activitySeries: [],
}),
sendMessage: vi.fn(),
selectConversation: vi.fn(),
newConversation: vi.fn(),
deleteConversation: vi.fn(),
formatTime: vi.fn(() => ''),
formatConvDate: vi.fn(() => ''),
autoResize: vi.fn(),
handleFileSelect: vi.fn(),
insertEmoji: vi.fn(),
openFilePicker: vi.fn(),
}),
}
})
describe('Chat topbar shortcuts', () => {
it('replaces READY/heartbeat with shortcut icon row', async () => {
const router = createRouter({
history: createMemoryHistory(),
routes: navItems.map((item) => ({
path: item.path,
name: item.path,
component: { template: '<div />' },
})),
})
await router.push('/chat')
await router.isReady()
const wrapper = mount(ChatPage, {
global: {
plugins: [router],
stubs: {
TelemetrySparkline: true,
OrchestrationPanel: true,
EmojiPicker: true,
FileMessage: true,
},
},
})
await flushPromises()
expect(wrapper.find('.status-text').exists()).toBe(false)
expect(wrapper.text()).not.toContain('READY')
expect(wrapper.find('[data-testid="nav-shortcut-row"]').exists()).toBe(true)
})
})

View File

@@ -130,11 +130,11 @@ describe('useChatView orchestration state', () => {
handlers.onMetadata?.({ conversation_id: 'conv-1', message_id: 'msg-1' })
handlers.onProgress?.({
stage: 'planning',
label: 'Jarvis 正在拆解步骤',
agent: 'planner',
label: 'Jarvis 正在编排日程',
agent: 'schedule_planner',
tool_name: null,
step: '正在分配任务',
steps: ['理解问题', '分配 planner'],
step: '正在生成今日安排',
steps: ['理解当前承诺', '分配 schedule_planner'],
})
handlers.onChunk?.({ content: '最终回复' })
})
@@ -150,9 +150,9 @@ describe('useChatView orchestration state', () => {
expect(view.isTyping.value).toBe(true)
expect(view.orchestrationPanelVisible.value).toBe(true)
expect(view.orchestrationStatus.value).toBe('active')
expect(view.activeAgent.value).toBe('planner')
expect(view.visitedAgents.value).toContain('planner')
expect(view.orchestrationEventFeed.value.map((item) => item.label)).toContain('正在分配任务')
expect(view.activeAgent.value).toBe('schedule_planner')
expect(view.visitedAgents.value).toContain('schedule_planner')
expect(view.orchestrationEventFeed.value.flatMap((group) => group.items.map((item) => item.label))).toContain('正在生成今日安排')
await promise
@@ -161,8 +161,37 @@ describe('useChatView orchestration state', () => {
expect(view.store.messages[1].content).toBe('最终回复')
expect(view.orchestrationPanelVisible.value).toBe(true)
expect(view.orchestrationStatus.value).toBe('complete')
expect(view.orchestrationEventFeed.value.at(-1)?.label).toBe('响应已生成')
expect(view.activeAgent.value).toBe('planner')
expect(view.orchestrationEventFeed.value.at(-1)?.items.at(-1)?.label).toBe('响应已生成')
expect(view.activeAgent.value).toBe('schedule_planner')
expect(view.store.messages).toHaveLength(2)
})
it('surfaces schedule fulfillment progress when chat creates a reminder', async () => {
mocks.chatStream.mockImplementation(async (_message, _conversationId, _fileIds, _modelName, handlers) => {
handlers.onMetadata?.({ conversation_id: 'conv-2', message_id: 'msg-2' })
handlers.onProgress?.({
stage: 'tool',
label: 'Jarvis 正在调用工具',
agent: 'executor',
tool_name: 'create_reminder',
step: '提醒创建成功: [abcd1234] 站会 @ 2026-03-28T09:00:00',
steps: [],
})
handlers.onChunk?.({ content: '已经帮你建好提醒。' })
})
const view = useChatView()
view.inputMessage.value = '明天9点提醒我开站会'
const promise = view.sendMessage()
await Promise.resolve()
expect(view.orchestrationInsight.value.statusTitle).toBe('FULFILLMENT')
expect(view.orchestrationInsight.value.jarvisNote).toContain('真的落到系统里')
expect(view.orchestrationEventFeed.value.flatMap((group) => group.items.map((item) => item.label))).toContain('提醒创建成功: [abcd1234] 站会 @ 2026-03-28T09:00:00')
await promise
expect(view.store.messages.at(-1)?.content).toBe('已经帮你建好提醒。')
})
})

View File

@@ -32,10 +32,18 @@ interface ThinkingState {
interface OrchestrationEventItem {
id: string
time: string
label: string
kind: 'info' | 'tool' | 'success' | 'error'
}
interface OrchestrationEventGroup {
id: string
startedAt: string
status: 'active' | 'success' | 'error'
items: OrchestrationEventItem[]
}
interface OrchestrationInsight {
statusTitle: string
systemSummary: string
@@ -50,8 +58,27 @@ interface TelemetryMetricState {
interface SystemTelemetryState {
cpu: TelemetryMetricState
gpu: TelemetryMetricState
memory: TelemetryMetricState
disk: TelemetryMetricState
network: {
upload: TelemetryMetricState
download: TelemetryMetricState
}
}
interface SystemMetaState {
systemName: string
systemVersion: string
hostname: string
timestamp: string
uptimeSeconds: number
diskUsedGb: number
diskTotalGb: number
gpuName: string | null
gpuMemoryTotalMb: number | null
gpuMemoryUsedMb: number | null
gpuUtilPercent: number | null
}
interface SessionTelemetryState {
@@ -63,6 +90,10 @@ interface SessionTelemetryState {
type OrchestrationStatus = 'idle' | 'active' | 'complete' | 'error'
const ORCHESTRATION_EVENT_STORAGE_KEY = 'jarvis.chat.orchestration.events'
const ORCHESTRATION_EVENT_GROUP_LIMIT = 40
const ORCHESTRATION_EVENT_ITEM_LIMIT = 24
export function useChatView() {
const store = useConversationStore()
const auth = useAuthStore()
@@ -89,11 +120,29 @@ export function useChatView() {
})
const activeAgent = ref<string | null>(null)
const visitedAgents = ref<string[]>([])
const orchestrationEventFeed = ref<OrchestrationEventItem[]>([])
const orchestrationEventFeed = ref<OrchestrationEventGroup[]>([])
const systemTelemetry = ref<SystemTelemetryState>({
cpu: { current: null, series: [], online: false },
gpu: { current: null, series: [], online: false },
memory: { current: null, series: [], online: false },
disk: { current: null, series: [], online: false },
network: {
upload: { current: null, series: [], online: false },
download: { current: null, series: [], online: false },
},
})
const systemMeta = ref<SystemMetaState>({
systemName: '--',
systemVersion: '--',
hostname: '--',
timestamp: '',
uptimeSeconds: 0,
diskUsedGb: 0,
diskTotalGb: 0,
gpuName: null,
gpuMemoryTotalMb: null,
gpuMemoryUsedMb: null,
gpuUtilPercent: null,
})
const sessionTelemetry = ref<SessionTelemetryState>({
activitySeries: [],
@@ -105,6 +154,68 @@ export function useChatView() {
let systemTelemetryTimer: ReturnType<typeof setInterval> | null = null
let sessionTelemetryTimer: ReturnType<typeof setInterval> | null = null
function loadPersistedOrchestrationEvents() {
if (typeof window === 'undefined') return
try {
const raw = window.localStorage.getItem(ORCHESTRATION_EVENT_STORAGE_KEY)
if (!raw) return
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return
orchestrationEventFeed.value = parsed
.filter((group): group is OrchestrationEventGroup => (
group
&& typeof group.id === 'string'
&& typeof group.startedAt === 'string'
&& ['active', 'success', 'error'].includes(group.status)
&& Array.isArray(group.items)
))
.map((group) => ({
...group,
items: group.items.filter((item): item is OrchestrationEventItem => (
item
&& typeof item.id === 'string'
&& typeof item.time === 'string'
&& typeof item.label === 'string'
&& ['info', 'tool', 'success', 'error'].includes(item.kind)
)).slice(-ORCHESTRATION_EVENT_ITEM_LIMIT),
}))
.slice(-ORCHESTRATION_EVENT_GROUP_LIMIT)
} catch {
window.localStorage.removeItem(ORCHESTRATION_EVENT_STORAGE_KEY)
}
}
function persistOrchestrationEvents() {
if (typeof window === 'undefined') return
window.localStorage.setItem(
ORCHESTRATION_EVENT_STORAGE_KEY,
JSON.stringify(orchestrationEventFeed.value.slice(-ORCHESTRATION_EVENT_GROUP_LIMIT)),
)
}
function currentEventTime() {
return new Date().toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
function startOrchestrationEventGroup() {
const nextGroup: OrchestrationEventGroup = {
id: `group-${Date.now()}`,
startedAt: currentEventTime(),
status: 'active',
items: [],
}
orchestrationEventFeed.value = [
...orchestrationEventFeed.value,
nextGroup,
].slice(-ORCHESTRATION_EVENT_GROUP_LIMIT)
persistOrchestrationEvents()
}
function resetOrchestrationState() {
orchestrationPanelVisible.value = true
orchestrationStatus.value = 'idle'
@@ -115,7 +226,6 @@ export function useChatView() {
}
activeAgent.value = null
visitedAgents.value = []
orchestrationEventFeed.value = []
sessionTelemetry.value = {
activitySeries: [],
eventsCount: 0,
@@ -139,7 +249,7 @@ export function useChatView() {
}
}
function updateSystemTelemetry(metric: keyof SystemTelemetryState, value: number | null, online: boolean) {
function updateSystemTelemetry(metric: 'cpu' | 'gpu' | 'memory' | 'disk', value: number | null, online: boolean) {
const current = systemTelemetry.value[metric]
systemTelemetry.value = {
...systemTelemetry.value,
@@ -151,17 +261,51 @@ export function useChatView() {
}
}
function updateNetworkTelemetry(direction: 'upload' | 'download', value: number | null, online: boolean) {
const current = systemTelemetry.value.network[direction]
systemTelemetry.value = {
...systemTelemetry.value,
network: {
...systemTelemetry.value.network,
[direction]: {
current: value,
online,
series: value === null ? current.series : appendTelemetryPoint(current.series, value),
},
},
}
}
async function loadSystemStatus() {
try {
const response = await systemApi.getStatus()
systemMeta.value = {
systemName: response.data.system_name,
systemVersion: response.data.system_version,
hostname: response.data.hostname,
timestamp: response.data.timestamp,
uptimeSeconds: response.data.uptime_seconds,
diskUsedGb: response.data.disk_used_gb,
diskTotalGb: response.data.disk_total_gb,
gpuName: response.data.gpu_name,
gpuMemoryTotalMb: response.data.gpu_memory_total_mb,
gpuMemoryUsedMb: response.data.gpu_memory_used_mb,
gpuUtilPercent: response.data.gpu_util_percent,
}
updateSystemTelemetry('cpu', response.data.cpu_percent, true)
updateSystemTelemetry('gpu', response.data.gpu_util_percent, response.data.gpu_util_percent !== null)
updateSystemTelemetry('memory', response.data.memory_percent, true)
updateSystemTelemetry('disk', response.data.disk_percent, true)
updateNetworkTelemetry('upload', response.data.network_upload_bps, true)
updateNetworkTelemetry('download', response.data.network_download_bps, true)
} catch (error) {
console.error('加载系统状态失败:', error)
updateSystemTelemetry('cpu', systemTelemetry.value.cpu.current, false)
updateSystemTelemetry('gpu', systemTelemetry.value.gpu.current, false)
updateSystemTelemetry('memory', systemTelemetry.value.memory.current, false)
updateSystemTelemetry('disk', systemTelemetry.value.disk.current, false)
updateNetworkTelemetry('upload', systemTelemetry.value.network.upload.current, false)
updateNetworkTelemetry('download', systemTelemetry.value.network.download.current, false)
}
}
@@ -187,15 +331,27 @@ export function useChatView() {
function pushOrchestrationEvent(label: string, kind: OrchestrationEventItem['kind']) {
const normalized = label.trim()
if (!normalized) return
if (orchestrationEventFeed.value.at(-1)?.label === normalized) return
orchestrationEventFeed.value = [
...orchestrationEventFeed.value,
{
id: `${Date.now()}-${orchestrationEventFeed.value.length}`,
label: normalized,
kind,
},
].slice(-5)
if (orchestrationEventFeed.value.length === 0) {
startOrchestrationEventGroup()
}
const currentGroup = orchestrationEventFeed.value.at(-1)
if (!currentGroup) return
if (currentGroup.items.at(-1)?.label === normalized) return
const nextItem: OrchestrationEventItem = {
id: `${Date.now()}-${currentGroup.items.length}`,
time: currentEventTime(),
label: normalized,
kind,
}
orchestrationEventFeed.value = orchestrationEventFeed.value.map((group, index) => (
index === orchestrationEventFeed.value.length - 1
? {
...group,
items: [...group.items, nextItem].slice(-ORCHESTRATION_EVENT_ITEM_LIMIT),
}
: group
))
persistOrchestrationEvents()
}
function buildOrchestrationInsight(payload: ChatProgressEvent): OrchestrationInsight {
@@ -212,7 +368,7 @@ export function useChatView() {
if (payload.stage === 'planning') {
return {
statusTitle: 'ROUTING',
systemSummary: payload.agent === 'planner' ? '已路由至 planner正在拆解任务' : '正在规划执行链路',
systemSummary: payload.agent === 'schedule_planner' ? '已路由至 schedule_planner正在编排日程' : '正在规划执行链路',
jarvisNote: payload.steps?.length
? '问题有几层关系,按顺序拆开会体面很多。'
: '这一步需要一点秩序感。',
@@ -220,12 +376,17 @@ export function useChatView() {
}
if (payload.stage === 'tool') {
const isScheduleAction = payload.tool_name
? ['create_reminder', 'create_goal', 'create_todo', 'create_schedule_task', 'create_task'].includes(payload.tool_name)
: false
return {
statusTitle: 'EXECUTION',
statusTitle: isScheduleAction ? 'FULFILLMENT' : 'EXECUTION',
systemSummary: payload.tool_name ? `正在调用工具 · ${payload.tool_name}` : '正在执行操作',
jarvisNote: payload.tool_name
? '工具链已接通。希望它今天愿意配合。'
: '执行阶段开始了,接下来看看链路表现。',
jarvisNote: isScheduleAction
? '这次不是只给建议,记录会真的落到系统里。'
: payload.tool_name
? '工具链已接通。希望它今天愿意配合。'
: '执行阶段开始了,接下来看看链路表现。',
}
}
@@ -274,6 +435,12 @@ export function useChatView() {
jarvisNote: '很好,问题已经收束。',
}
pushOrchestrationEvent(finalLabel, status === 'error' ? 'error' : 'success')
orchestrationEventFeed.value = orchestrationEventFeed.value.map((group, index) => (
index === orchestrationEventFeed.value.length - 1
? { ...group, status: status === 'error' ? 'error' : 'success' }
: group
))
persistOrchestrationEvents()
}
async function sendMessage() {
@@ -287,6 +454,7 @@ export function useChatView() {
const tempMessageId = `temp-${Date.now()}`
const previousConversationId = store.currentConversationId
inputMessage.value = ''
startOrchestrationEventGroup()
store.addMessage({
id: tempMessageId,
@@ -392,9 +560,7 @@ export function useChatView() {
try {
const response = await settingsApi.get()
const chatModelsList = (response.data.llm_config?.chat || []).filter((model) => model.enabled)
const vlmModels = (response.data.llm_config?.vlm || []).filter((model) => model.enabled)
// 合并 chat 和 vlm 模型
chatModels.value = [...chatModelsList, ...vlmModels]
chatModels.value = chatModelsList
if (!selectedModelName.value || !chatModels.value.some((model) => model.name === selectedModelName.value)) {
selectedModelName.value = chatModels.value[0]?.name || ''
}
@@ -518,6 +684,7 @@ export function useChatView() {
}
void loadSystemStatus()
loadPersistedOrchestrationEvents()
startSystemTelemetryPolling()
startSessionTelemetryDecay()
@@ -556,6 +723,7 @@ export function useChatView() {
activeAgent,
visitedAgents,
orchestrationEventFeed,
systemMeta,
systemTelemetry,
sessionTelemetry,
sendMessage,

File diff suppressed because it is too large Load Diff

View File

@@ -1,478 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { taskApi, type Task, type TaskStatus, type TaskPriority } from '@/api/task'
import { Plus, CheckCircle, Circle, Clock, Trash2, Zap } from 'lucide-vue-next'
const tasks = ref<Task[]>([])
const showCreateForm = ref(false)
const newTaskTitle = ref('')
const newTaskPriority = ref<TaskPriority>('medium')
const todoTasks = computed(() => tasks.value.filter((t) => t.status === 'todo'))
const inProgressTasks = computed(() => tasks.value.filter((t) => t.status === 'in_progress'))
const doneTasks = computed(() => tasks.value.filter((t) => t.status === 'done'))
const priorityConfig: Record<TaskPriority, { color: string; label: string; glow: string }> = {
low: { color: '#4b5563', label: 'LOW', glow: 'rgba(75,85,99,0.3)' },
medium: { color: '#60a5fa', label: 'MED', glow: 'rgba(96,165,250,0.3)' },
high: { color: '#fbbf24', label: 'HIGH', glow: 'rgba(251,191,36,0.3)' },
urgent: { color: '#f87171', label: 'CRIT', glow: 'rgba(248,113,113,0.3)' },
}
async function loadTasks() {
try {
const response = await taskApi.list()
tasks.value = response.data
} catch (e) { console.error('加载任务失败:', e) }
}
async function createTask() {
if (!newTaskTitle.value.trim()) return
try {
const response = await taskApi.create({ title: newTaskTitle.value.trim(), priority: newTaskPriority.value })
tasks.value.unshift(response.data)
newTaskTitle.value = ''
showCreateForm.value = false
} catch (e) { console.error('创建任务失败:', e) }
}
async function updateStatus(task: Task, status: TaskStatus) {
try {
const response = await taskApi.update(task.id, { status })
const index = tasks.value.findIndex((t) => t.id === task.id)
if (index !== -1) tasks.value[index] = response.data
} catch (e) { console.error('更新状态失败:', e) }
}
async function deleteTask(id: string) {
try {
await taskApi.delete(id)
tasks.value = tasks.value.filter((t) => t.id !== id)
} catch (e) { console.error('删除失败:', e) }
}
onMounted(() => { loadTasks() })
</script>
<template>
<div class="kanban-view">
<!-- Header -->
<div class="page-header">
<div class="header-left">
<div class="header-icon"><Zap :size="20" /></div>
<div class="header-text">
<h1>TASK BOARD</h1>
<span class="header-sub">{{ tasks.length }} tasks · {{ doneTasks.length }} completed</span>
</div>
</div>
<button class="add-btn" @click="showCreateForm = true">
<Plus :size="14" />
NEW TASK
</button>
</div>
<!-- Create form -->
<div v-if="showCreateForm" class="create-panel">
<div class="create-inner">
<input
v-model="newTaskTitle"
placeholder="Describe the task..."
@keyup.enter="createTask"
autofocus
/>
<select v-model="newTaskPriority" class="priority-select">
<option value="low">LOW</option>
<option value="medium">MEDIUM</option>
<option value="high">HIGH</option>
<option value="urgent">CRITICAL</option>
</select>
<button class="confirm-btn" @click="createTask">CREATE</button>
<button class="cancel-btn" @click="showCreateForm = false">CANCEL</button>
</div>
</div>
<!-- Board -->
<div class="kanban-board">
<!-- TODO -->
<div class="kanban-col">
<div class="col-header">
<div class="col-title">
<Circle :size="14" />
<span>PENDING</span>
<div class="col-count">{{ todoTasks.length }}</div>
</div>
</div>
<div class="col-line" style="--col-color: #60a5fa"></div>
<div class="col-cards">
<div
v-for="task in todoTasks"
:key="task.id"
class="task-card"
@click="updateStatus(task, 'in_progress')"
>
<div class="task-priority-bar" :style="{ background: priorityConfig[task.priority].color, boxShadow: '0 0 6px ' + priorityConfig[task.priority].glow }"></div>
<div class="task-body">
<div class="task-meta">
<span class="task-priority-tag" :style="{ color: priorityConfig[task.priority].color }">
{{ priorityConfig[task.priority].label }}
</span>
</div>
<div class="task-title">{{ task.title }}</div>
</div>
<button class="task-delete" @click.stop="deleteTask(task.id)">
<Trash2 :size="12" />
</button>
</div>
<div v-if="todoTasks.length === 0" class="col-empty">No pending tasks</div>
</div>
</div>
<!-- IN PROGRESS -->
<div class="kanban-col active-col">
<div class="col-header">
<div class="col-title">
<Clock :size="14" />
<span>IN PROGRESS</span>
<div class="col-count active">{{ inProgressTasks.length }}</div>
</div>
</div>
<div class="col-line" style="--col-color: #fbbf24"></div>
<div class="col-cards">
<div
v-for="task in inProgressTasks"
:key="task.id"
class="task-card"
@click="updateStatus(task, 'done')"
>
<div class="task-priority-bar" :style="{ background: priorityConfig[task.priority].color, boxShadow: '0 0 6px ' + priorityConfig[task.priority].glow }"></div>
<div class="task-body">
<div class="task-meta">
<span class="task-priority-tag" :style="{ color: priorityConfig[task.priority].color }">
{{ priorityConfig[task.priority].label }}
</span>
<span class="active-dot"></span>
</div>
<div class="task-title">{{ task.title }}</div>
</div>
<button class="task-delete" @click.stop="deleteTask(task.id)">
<Trash2 :size="12" />
</button>
</div>
<div v-if="inProgressTasks.length === 0" class="col-empty">No active tasks</div>
</div>
</div>
<!-- DONE -->
<div class="kanban-col">
<div class="col-header">
<div class="col-title">
<CheckCircle :size="14" />
<span>COMPLETED</span>
<div class="col-count">{{ doneTasks.length }}</div>
</div>
</div>
<div class="col-line" style="--col-color: #34d399"></div>
<div class="col-cards">
<div
v-for="task in doneTasks"
:key="task.id"
class="task-card done"
@click="updateStatus(task, 'todo')"
>
<div class="task-priority-bar" :style="{ background: priorityConfig[task.priority].color, boxShadow: '0 0 6px ' + priorityConfig[task.priority].glow, opacity: 0.4 }"></div>
<div class="task-body">
<div class="task-meta">
<span class="task-priority-tag" style="color: var(--text-dim)">
{{ priorityConfig[task.priority].label }}
</span>
</div>
<div class="task-title done-title">{{ task.title }}</div>
</div>
<button class="task-delete" @click.stop="deleteTask(task.id)">
<Trash2 :size="12" />
</button>
</div>
<div v-if="doneTasks.length === 0" class="col-empty">No completed tasks</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.kanban-view {
height: 100%;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left { display: flex; align-items: center; gap: 14px; }
.header-icon { color: var(--accent-amber); filter: drop-shadow(0 0 8px var(--accent-amber)); }
h1 {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
letter-spacing: 0.15em;
color: var(--text-primary);
margin: 0;
}
.header-sub { font-family: var(--font-mono); font-size: 10px; color: var(--text-dim); letter-spacing: 0.1em; }
.add-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--accent-amber-dim);
border: 1px solid rgba(249, 168, 37, 0.25);
border-radius: var(--radius-md);
color: var(--accent-amber);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.12em;
transition: all var(--transition-fast);
}
.add-btn:hover {
background: rgba(249, 168, 37, 0.2);
box-shadow: var(--glow-amber);
}
/* Create panel */
.create-panel {
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-lg);
padding: 16px;
animation: fade-in-up 0.2s ease;
}
.create-inner {
display: flex;
align-items: center;
gap: 10px;
}
.create-inner input {
flex: 1;
padding: 10px 14px;
background: var(--bg-panel);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
font-size: 13px;
}
.create-inner input:focus {
border-color: var(--accent-amber);
box-shadow: var(--glow-amber);
}
.priority-select {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.1em;
padding: 8px 12px;
background: var(--bg-panel);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
color: var(--text-secondary);
cursor: pointer;
}
.confirm-btn {
padding: 10px 20px;
background: var(--accent-amber-dim);
border: 1px solid rgba(249, 168, 37, 0.3);
border-radius: var(--radius-md);
color: var(--accent-amber);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.12em;
transition: all var(--transition-fast);
}
.confirm-btn:hover { background: rgba(249, 168, 37, 0.2); box-shadow: var(--glow-amber); }
.cancel-btn {
padding: 10px 16px;
background: transparent;
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
color: var(--text-dim);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.12em;
transition: all var(--transition-fast);
}
.cancel-btn:hover { border-color: var(--accent-red); color: var(--accent-red); }
/* Board */
.kanban-board {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
flex: 1;
min-height: 0;
}
.kanban-col {
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 400px;
}
.kanban-col.active-col {
border-color: rgba(251, 191, 36, 0.2);
background: rgba(251, 191, 36, 0.02);
}
.col-header { }
.col-title {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.15em;
color: var(--text-dim);
margin-bottom: 8px;
}
.col-count {
margin-left: auto;
background: var(--bg-panel);
border: 1px solid var(--border-dim);
border-radius: 10px;
padding: 1px 8px;
font-size: 10px;
color: var(--text-secondary);
}
.col-count.active {
background: var(--accent-amber-dim);
border-color: rgba(249, 168, 37, 0.3);
color: var(--accent-amber);
}
.col-line {
height: 1px;
background: linear-gradient(90deg, var(--col-color, var(--accent-cyan)), transparent);
margin-bottom: 4px;
opacity: 0.5;
}
.col-cards {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
overflow-y: auto;
}
.col-empty {
text-align: center;
padding: 32px 16px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.08em;
}
.task-card {
background: var(--bg-panel);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: 10px 12px;
cursor: pointer;
display: flex;
align-items: stretch;
gap: 10px;
transition: all var(--transition-fast);
position: relative;
overflow: hidden;
}
.task-card:hover {
border-color: var(--border-mid);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.task-card.done { opacity: 0.55; }
.task-priority-bar {
width: 3px;
border-radius: 2px;
flex-shrink: 0;
}
.task-body { flex: 1; min-width: 0; }
.task-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.task-priority-tag {
font-family: var(--font-display);
font-size: 8px;
letter-spacing: 0.1em;
}
.active-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent-amber);
box-shadow: 0 0 6px var(--accent-amber);
animation: pulse-glow 1.5s ease-in-out infinite;
}
.task-title {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
}
.done-title {
text-decoration: line-through;
color: var(--text-dim);
}
.task-delete {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 2px;
border-radius: 3px;
opacity: 0;
transition: all var(--transition-fast);
flex-shrink: 0;
align-self: flex-start;
}
.task-card:hover .task-delete { opacity: 1; }
.task-delete:hover { color: var(--accent-red); background: rgba(255,71,87,0.1); }
</style>

View File

@@ -0,0 +1,679 @@
.knowledge-view {
height: 100%;
overflow: hidden;
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
position: relative;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
z-index: 10;
}
.header-left {
display: flex;
align-items: center;
gap: 14px;
}
.header-icon {
color: var(--accent-cyan);
filter: drop-shadow(0 0 8px var(--accent-cyan));
}
.header-left h1 {
margin: 0;
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
letter-spacing: 0.15em;
color: var(--text-primary);
text-shadow: var(--glow-cyan);
}
.header-sub {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
letter-spacing: 0.1em;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.staged-shell {
flex: 1;
display: flex;
flex-direction: column;
gap: 18px;
overflow: hidden;
position: relative;
background: rgba(7, 12, 22, 0.4);
border: 1px solid rgba(0, 245, 212, 0.1);
border-radius: var(--radius-xl);
backdrop-filter: blur(10px);
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: linear-gradient(180deg, rgba(0, 245, 212, 0.08), transparent);
border-bottom: 1px solid var(--border-dim);
}
.toolbar-left {
display: flex;
align-items: center;
gap: 16px;
}
.nav-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 245, 212, 0.1);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
color: var(--accent-cyan);
cursor: pointer;
transition: all var(--transition-fast);
}
.nav-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.nav-btn:not(:disabled):hover {
background: rgba(0, 245, 212, 0.2);
box-shadow: 0 0 10px rgba(0, 245, 212, 0.3);
}
.breadcrumbs {
display: flex;
align-items: center;
gap: 6px;
}
.breadcrumb-item {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
background: transparent;
border: none;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: all var(--transition-fast);
}
.breadcrumb-item:hover {
color: var(--accent-cyan);
background: rgba(0, 245, 212, 0.05);
}
.breadcrumb-item.active {
color: var(--accent-cyan);
font-weight: 600;
}
.location-tag {
font-family: var(--font-display);
font-size: 10px;
color: var(--accent-cyan);
letter-spacing: 0.15em;
padding: 4px 12px;
background: rgba(0, 245, 212, 0.1);
border: 1px solid rgba(0, 245, 212, 0.2);
border-radius: 999px;
}
.stage-container {
flex: 1;
display: flex;
gap: 0;
overflow: hidden;
position: relative;
}
/* LEFT PANEL - FOLDER LIST */
.folder-column {
width: 320px;
height: 100%;
border-right: 1px solid rgba(0, 245, 212, 0.15);
display: flex;
flex-direction: column;
background: rgba(5, 10, 18, 0.4);
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
overflow-y: auto;
z-index: 5;
}
.folder-column::-webkit-scrollbar {
width: 4px;
}
.folder-column::-webkit-scrollbar-thumb {
background: rgba(0, 245, 212, 0.2);
border-radius: 2px;
}
.panel-header {
padding: 20px;
border-bottom: 1px solid rgba(0, 245, 212, 0.05);
}
.panel-kicker {
font-family: var(--font-display);
font-size: 9px;
color: var(--accent-cyan);
letter-spacing: 0.2em;
margin-bottom: 4px;
}
.panel-header h2 {
margin: 0;
font-family: var(--font-display);
font-size: 16px;
color: var(--text-primary);
letter-spacing: 0.05em;
}
.folder-list {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.folder-item {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: rgba(10, 20, 30, 0.4);
border: 1px solid rgba(0, 245, 212, 0.1);
border-radius: 12px;
color: var(--text-primary);
text-align: left;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.folder-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--accent-cyan);
opacity: 0;
transition: opacity 0.3s;
}
.folder-item:hover {
background: rgba(0, 245, 212, 0.08);
border-color: rgba(0, 245, 212, 0.3);
transform: translateX(4px);
}
.folder-item.active {
background: linear-gradient(90deg, rgba(0, 245, 212, 0.12), transparent);
border-color: rgba(0, 245, 212, 0.4);
box-shadow: 0 0 20px rgba(0, 245, 212, 0.1);
}
.folder-item.active::before {
opacity: 1;
}
.item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: rgba(0, 245, 212, 0.1);
border: 1px solid rgba(0, 245, 212, 0.2);
border-radius: 10px;
color: var(--accent-cyan);
flex-shrink: 0;
transition: all 0.3s;
}
.folder-item.active .item-icon {
background: var(--accent-cyan);
color: var(--bg-dark);
box-shadow: 0 0 15px var(--accent-cyan);
}
.item-content {
flex: 1;
min-width: 0;
}
.item-name {
display: block;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-meta {
display: block;
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
margin-top: 2px;
}
.item-arrow {
color: var(--accent-cyan);
opacity: 0.5;
transition: transform 0.3s;
}
.folder-item.active .item-arrow {
transform: rotate(90deg);
opacity: 1;
}
/* RIGHT PANEL - CONTENT SLIDE OUT */
.content-view {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
background: rgba(4, 8, 16, 0.6);
position: relative;
overflow-y: auto;
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.content-enter-active,
.content-leave-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.content-enter-from {
transform: translateX(100%);
opacity: 0;
}
.content-leave-to {
transform: translateX(-20px);
opacity: 0;
}
.content-header {
padding: 24px;
background: linear-gradient(180deg, rgba(0, 245, 212, 0.05), transparent);
border-bottom: 1px solid rgba(0, 245, 212, 0.05);
}
.content-title-group {
display: flex;
align-items: center;
justify-content: space-between;
}
.content-title-group h2 {
margin: 0;
font-family: var(--font-display);
font-size: 20px;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 12px;
}
.content-grid {
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
.section-label {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.section-label span:first-child {
font-family: var(--font-display);
font-size: 11px;
color: var(--accent-cyan);
letter-spacing: 0.15em;
text-transform: uppercase;
}
.section-line {
flex: 1;
height: 1px;
background: linear-gradient(90deg, rgba(0, 245, 212, 0.4), transparent);
}
.file-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.file-card {
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
background: rgba(10, 20, 30, 0.6);
border: 1px solid rgba(0, 245, 212, 0.1);
border-radius: 16px;
cursor: pointer;
transition: all 0.3s;
position: relative;
}
.file-card:hover {
background: rgba(0, 245, 212, 0.1);
border-color: rgba(0, 245, 212, 0.4);
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
.file-icon {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: rgba(10, 15, 25, 0.8);
border: 1px solid rgba(255, 255, 255, 0.05);
flex-shrink: 0;
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.file-details {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
}
.status-pill {
padding: 2px 8px;
border-radius: 999px;
font-size: 9px;
text-transform: uppercase;
border: 1px solid currentColor;
opacity: 0.8;
}
/* HUD OVERLAY */
.hud-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background: rgba(0, 5, 10, 0.85);
backdrop-filter: blur(20px);
}
.hud-container {
width: 100%;
max-width: 1200px;
max-height: 90vh;
background: linear-gradient(135deg, rgba(10, 25, 40, 0.95), rgba(5, 10, 20, 0.98));
border: 1px solid rgba(0, 245, 212, 0.3);
border-radius: 32px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 0 100px rgba(0, 245, 212, 0.15), 0 40px 100px rgba(0, 0, 0, 0.8);
position: relative;
}
.hud-container::after {
content: '';
position: absolute;
inset: 0;
border-radius: 32px;
background: radial-gradient(circle at top right, rgba(0, 245, 212, 0.05), transparent 40%);
pointer-events: none;
}
.hud-header {
padding: 24px 32px;
display: flex;
align-items: flex-start;
justify-content: space-between;
border-bottom: 1px solid rgba(0, 245, 212, 0.1);
}
.hud-title-group h3 {
margin: 0;
font-family: var(--font-display);
font-size: 24px;
color: var(--text-primary);
text-shadow: var(--glow-cyan);
}
.hud-meta-row {
display: flex;
align-items: center;
gap: 16px;
margin-top: 8px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
}
.hud-body {
flex: 1;
display: grid;
grid-template-columns: 1fr 400px;
gap: 1px;
background: rgba(0, 245, 212, 0.1);
overflow: hidden;
}
.hud-preview-panel,
.hud-chunks-panel {
background: rgba(5, 10, 20, 0.8);
padding: 32px;
overflow-y: auto;
}
.panel-label {
display: block;
font-family: var(--font-display);
font-size: 12px;
color: var(--accent-cyan);
letter-spacing: 0.2em;
text-transform: uppercase;
margin-bottom: 20px;
}
.preview-content {
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.8;
color: var(--text-secondary);
white-space: pre-wrap;
}
.chunk-card {
background: rgba(15, 25, 35, 0.6);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 20px;
margin-bottom: 16px;
transition: all 0.3s;
}
.chunk-card:hover {
border-color: rgba(0, 245, 212, 0.3);
background: rgba(0, 245, 212, 0.05);
}
.chunk-index {
font-family: var(--font-display);
font-size: 10px;
color: var(--accent-cyan);
margin-bottom: 8px;
display: block;
}
.chunk-content {
font-size: 12px;
line-height: 1.6;
color: var(--text-dim);
}
.close-hud {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: var(--text-dim);
cursor: pointer;
transition: all 0.3s;
}
.close-hud:hover {
background: rgba(255, 71, 87, 0.2);
border-color: rgba(255, 71, 87, 0.4);
color: var(--accent-red);
}
/* UTILS */
.empty-state {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
color: var(--text-dim);
text-align: center;
padding: 40px;
}
.empty-icon {
opacity: 0.2;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: 12px;
font-family: var(--font-display);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.05em;
cursor: pointer;
transition: all 0.3s;
}
.btn.primary {
background: var(--accent-cyan);
border: none;
color: var(--bg-dark);
}
.btn.primary:hover {
box-shadow: 0 0 20px rgba(0, 245, 212, 0.4);
transform: translateY(-1px);
}
.btn.ghost {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-primary);
}
.btn.ghost:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 1024px) {
.hud-body {
grid-template-columns: 1fr;
}
.hud-chunks-panel {
display: none;
}
}
@media (max-width: 768px) {
.folder-column {
width: 80px;
}
.item-content, .item-arrow, .panel-header h2, .panel-kicker {
display: none;
}
.folder-list {
align-items: center;
}
.folder-item {
justify-content: center;
padding: 12px;
}
}

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import { FolderOpen, Folder, FileText, ChevronRight, FileSearch, Database } from 'lucide-vue-next'
import type { FolderTree } from '@/api/folder'
import type { Document } from '@/api/document'
interface Props {
isRoot: boolean
currentFolder: FolderTree | null
currentFolderId: string | null
visibleFolders: FolderTree[]
documents: Document[]
highlightedDocumentId: string | null
uploadError: string
uploadSuccess: string
getFileTypeColor: (type: string) => string
formatFileSize: (size: number) => string
getStatusLabel: (status?: string, isIndexed?: boolean) => string
}
const props = defineProps<Props>()
const emit = defineEmits(['enterFolder', 'openDocument', 'triggerUpload', 'openRename', 'openDelete'])
</script>
<template>
<Transition name="content">
<main v-if="!props.isRoot" class="content-view" :key="props.currentFolderId">
<header class="content-header">
<div class="content-title-group">
<h2>
<FolderOpen :size="24" />
<span>{{ props.currentFolder?.name }}</span>
</h2>
<div class="mini-actions">
<button class="btn ghost small" @click="emit('openRename', props.currentFolder)">RENAME</button>
<button class="btn ghost small danger" @click="emit('openDelete', props.currentFolder)">DELETE</button>
</div>
</div>
</header>
<div class="content-grid">
<!-- Feedback Messages -->
<div v-if="props.uploadError" class="upload-error">{{ props.uploadError }}</div>
<div v-if="props.uploadSuccess" class="upload-success">{{ props.uploadSuccess }}</div>
<!-- Subfolders Section -->
<section v-if="props.visibleFolders.length" class="content-section">
<div class="section-label">
<span>SUB-DIRECTORIES</span>
<div class="section-line"></div>
</div>
<div class="file-list">
<div
v-for="folder in props.visibleFolders"
:key="folder.id"
class="file-card folder-card"
@click="emit('enterFolder', folder)"
>
<div class="file-icon"><Folder :size="20" /></div>
<div class="file-info">
<span class="file-name">{{ folder.name }}</span>
<span class="file-details">{{ folder.children?.length ?? 0 }} ITEMS</span>
</div>
<ChevronRight :size="16" class="item-arrow" />
</div>
</div>
</section>
<!-- Files Section -->
<section v-if="props.documents.length" class="content-section">
<div class="section-label">
<span>DATA OBJECTS</span>
<div class="section-line"></div>
</div>
<div class="file-list">
<div
v-for="doc in props.documents"
:key="doc.id"
class="file-card"
:class="{ highlighted: props.highlightedDocumentId === doc.id }"
@click="emit('openDocument', doc)"
>
<div class="file-icon" :style="{ color: props.getFileTypeColor(doc.file_type) }">
<FileText :size="20" />
</div>
<div class="file-info">
<span class="file-name">{{ doc.title }}</span>
<div class="file-details">
<span>{{ doc.file_type.toUpperCase() }}</span>
<span>·</span>
<span>{{ props.formatFileSize(doc.file_size) }}</span>
</div>
</div>
<span class="status-pill" :class="(doc.ingestion_status ?? (doc.is_indexed ? 'ready' : 'uploaded')).toLowerCase()">
{{ props.getStatusLabel(doc.ingestion_status, doc.is_indexed) }}
</span>
</div>
</div>
</section>
<div v-if="!props.visibleFolders.length && !props.documents.length" class="empty-state">
<FileSearch :size="48" class="empty-icon" />
<span>FOLDER IS EMPTY</span>
<button class="btn primary" @click="emit('triggerUpload')">UPLOAD FIRST FILE</button>
</div>
</div>
</main>
<main v-else class="content-view">
<div class="empty-state">
<div class="pulsing-icon">
<Database :size="64" class="empty-icon" />
</div>
<h3>AWAITING SELECTION</h3>
<p>SELECT A DIRECTORY NODE TO EXPAND SYSTEM LAYER</p>
</div>
</main>
</Transition>
</template>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { X, Loader } from 'lucide-vue-next'
import type { Document, DocumentChunk } from '@/api/document'
interface Props {
show: boolean
activeDocument: Document | null
activeDocumentContent: string
isLoadingDocumentContent: boolean
activeDocumentChunks: DocumentChunk[]
isLoadingDocumentChunks: boolean
getFileTypeColor: (type: string) => string
formatFileSize: (size: number) => string
getStatusLabel: (status?: string, isIndexed?: boolean) => string
}
const props = defineProps<Props>()
const emit = defineEmits(['close'])
</script>
<template>
<Transition name="fade">
<div v-if="props.show && props.activeDocument" class="hud-overlay" @click.self="emit('close')">
<div class="hud-container">
<header class="hud-header">
<div class="hud-title-group">
<span class="panel-kicker">PHASE 03 // DATA PREVIEW</span>
<h3>{{ props.activeDocument.title }}</h3>
<div class="hud-meta-row">
<span class="file-badge" :style="{ color: props.getFileTypeColor(props.activeDocument.file_type), borderColor: props.getFileTypeColor(props.activeDocument.file_type) }">
{{ props.activeDocument.file_type.toUpperCase() }}
</span>
<span>SIZE: {{ props.formatFileSize(props.activeDocument.file_size) }}</span>
<span>CHUNKS: {{ props.activeDocument.chunk_count }}</span>
<span class="status-pill">{{ props.getStatusLabel(props.activeDocument.ingestion_status, props.activeDocument.is_indexed) }}</span>
</div>
</div>
<button class="close-hud" @click="emit('close')"><X :size="20" /></button>
</header>
<div class="hud-body">
<div class="hud-preview-panel">
<span class="panel-label">RAW CONTENT</span>
<div v-if="props.isLoadingDocumentContent" class="preview-loading">
<Loader :size="24" class="spin" />
<span>DECRYPTING DATA...</span>
</div>
<pre v-else class="preview-content">{{ props.activeDocumentContent || 'NO CONTENT DETECTED' }}</pre>
</div>
<div class="hud-chunks-panel">
<span class="panel-label">KNOWLEDGE CHUNKS</span>
<div v-if="props.isLoadingDocumentChunks" class="preview-loading">
<Loader :size="20" class="spin" />
<span>CHUNKING DATA...</span>
</div>
<div v-else class="chunk-list">
<div v-for="chunk in props.activeDocumentChunks" :key="chunk.id" class="chunk-card">
<span class="chunk-index">CHUNK #{{ chunk.chunk_index + 1 }}</span>
<p class="chunk-content">{{ chunk.content.substring(0, 200) }}...</p>
</div>
</div>
</div>
</div>
</div>
</div>
</Transition>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { Folder, FolderOpen, ChevronRight, HardDrive } from 'lucide-vue-next'
import type { FolderTree } from '@/api/folder'
interface Props {
folders: FolderTree[]
currentFolderId: string | null
breadcrumbs: Array<{ id: string | null; name: string }>
}
const props = defineProps<Props>()
const emit = defineEmits(['select'])
</script>
<template>
<aside class="folder-column">
<div class="panel-header">
<span class="panel-kicker">PHASE 01</span>
<h2>DIRECTORIES</h2>
</div>
<div class="folder-list">
<button
v-for="folder in props.folders"
:key="folder.id"
class="folder-item"
:class="{ active: props.currentFolderId === folder.id || props.breadcrumbs.some(b => b.id === folder.id) }"
@click="emit('select', folder)"
>
<div class="item-icon">
<FolderOpen v-if="props.currentFolderId === folder.id" :size="20" />
<Folder v-else :size="20" />
</div>
<div class="item-content">
<span class="item-name">{{ folder.name }}</span>
<span class="item-meta">{{ folder.children?.length ?? 0 }} SUBFOLDERS</span>
</div>
<ChevronRight :size="16" class="item-arrow" />
</button>
<div v-if="!props.folders.length" class="empty-state">
<HardDrive :size="32" class="empty-icon" />
<span>NO DATA NODES</span>
</div>
</div>
</aside>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,536 @@
import { computed, onMounted, ref, watch } from 'vue'
import { animate } from 'motion'
import { goalApi, type Goal } from '@/api/goal'
import { reminderApi, type Reminder } from '@/api/reminder'
import { scheduleCenterApi, type ScheduleCenterDateResponse, type ScheduleCenterDaySummary } from '@/api/scheduleCenter'
import { taskApi, type Task, type TaskPriority, type TaskStatus } from '@/api/task'
import { todoApi, type Todo } from '@/api/todo'
type TimelineItem =
| { id: string; type: 'todo'; tone: 'normal' | 'warn' | 'alert'; time: string; label: string; title: string; meta: string; done: boolean; payload: Todo }
| { id: string; type: 'task'; tone: 'normal' | 'warn' | 'alert'; time: string; label: string; title: string; meta: string; done: boolean; payload: Task }
| { id: string; type: 'reminder'; tone: 'normal' | 'warn' | 'alert'; time: string; label: string; title: string; meta: string; done: boolean; payload: Reminder }
| { id: string; type: 'goal'; tone: 'normal' | 'warn' | 'alert'; time: string; label: string; title: string; meta: string; done: boolean; payload: Goal }
export function useScheduleCenterPage() {
const weekdayLabels = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']
const today = new Date()
const currentMonth = ref(new Date(today.getFullYear(), today.getMonth(), 1))
const selectedDate = ref(formatDate(today))
const panelOpen = ref(false)
const monthSummary = ref<ScheduleCenterDaySummary[]>([])
const detail = ref<ScheduleCenterDateResponse | null>(null)
const loadingMonth = ref(false)
const loadingDetail = ref(false)
const monthRequestId = ref(0)
const detailRequestId = ref(0)
const newTodoTitle = ref('')
const newTaskTitle = ref('')
const newTaskPriority = ref<TaskPriority>('medium')
const newReminderTitle = ref('')
const newReminderTime = ref('09:00')
const newGoalTitle = ref('')
const panelMode = ref<'detail' | 'create'>('detail')
const panelAnimation = ref<ReturnType<typeof animate> | null>(null)
const panelFrameRef = ref<HTMLElement | null>(null)
const priorityConfig: Record<TaskPriority, { color: string; label: string; glow: string }> = {
low: { color: '#4b5563', label: 'LOW', glow: 'rgba(75,85,99,0.28)' },
medium: { color: '#00f5d4', label: 'MED', glow: 'rgba(0,245,212,0.28)' },
high: { color: '#f9a825', label: 'HIGH', glow: 'rgba(249,168,37,0.28)' },
urgent: { color: '#ff4757', label: 'CRIT', glow: 'rgba(255,71,87,0.32)' },
}
function formatDate(date: Date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function getMonthKey(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
}
function formatTimelineDate(dateKey: string) {
return dateKey.replace(/-/g, '.')
}
function getDayRisk(summary: ScheduleCenterDaySummary | null | undefined) {
if (!summary) return 'LOW'
const pressure = summary.todo_total + summary.task_due_total * 1.5 + summary.high_priority_total * 2 + summary.reminder_total
if (pressure >= 9) return 'HIGH'
if (pressure >= 4) return 'MED'
return 'LOW'
}
function getDayState(summary: ScheduleCenterDaySummary | null | undefined) {
if (!summary) return 'OPEN'
if (summary.high_priority_total >= 2) return 'LOCKED'
if (summary.task_due_total >= 4) return 'DENSE'
if (summary.reminder_total >= 2) return 'TRACKED'
if (summary.todo_total + summary.task_due_total + summary.goal_total > 0) return 'QUEUED'
return 'CLEAR'
}
function getDayBars(summary: ScheduleCenterDaySummary | null | undefined) {
if (!summary) return []
return [
{ key: 'QUEUE', count: summary.todo_total, value: `${Math.min(100, summary.todo_total * 14)}%` },
{ key: 'TASK', count: summary.task_due_total, value: `${Math.min(100, summary.task_due_total * 16)}%` },
{ key: 'FOCUS', count: summary.high_priority_total, value: `${Math.min(100, summary.high_priority_total * 28)}%` },
].filter((item) => item.count > 0)
}
function getVisibleMetrics(summary: ScheduleCenterDaySummary | null | undefined) {
if (!summary) return []
return [
summary.todo_total > 0 ? `待办 ${summary.todo_total}` : null,
summary.task_due_total > 0 ? `任务 ${summary.task_due_total}` : null,
summary.reminder_total > 0 ? `提醒 ${summary.reminder_total}` : null,
summary.goal_total > 0 ? `目标 ${summary.goal_total}` : null,
].filter((item): item is string => Boolean(item))
}
const summaryMap = computed(() => new Map(monthSummary.value.map((item) => [item.date, item])))
const monthOverview = computed(() =>
monthSummary.value.reduce(
(acc, item) => {
acc.todo_total += item.todo_total
acc.task_due_total += item.task_due_total
acc.reminder_total += item.reminder_total
acc.goal_total += item.goal_total
acc.high_priority_total += item.high_priority_total
return acc
},
{ todo_total: 0, task_due_total: 0, reminder_total: 0, goal_total: 0, high_priority_total: 0 },
),
)
const calendarDays = computed(() => {
const year = currentMonth.value.getFullYear()
const month = currentMonth.value.getMonth()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const firstDayOffset = (new Date(year, month, 1).getDay() + 6) % 7
return Array.from({ length: 42 }, (_, index) => {
const dayNumber = index - firstDayOffset + 1
if (dayNumber < 1 || dayNumber > daysInMonth) {
return {
date: null,
dateKey: `empty-${index}`,
dayNumber: null,
isCurrentMonth: false,
isToday: false,
isSelected: false,
summary: null,
isPlaceholder: true,
}
}
const date = new Date(year, month, dayNumber)
const dateKey = formatDate(date)
const summary = summaryMap.value.get(dateKey) ?? null
return {
date,
dateKey,
dayNumber,
isCurrentMonth: true,
isToday: dateKey === formatDate(today),
isSelected: dateKey === selectedDate.value,
summary,
risk: getDayRisk(summary),
state: getDayState(summary),
bars: getDayBars(summary),
isPlaceholder: false,
}
})
})
const panelDateLabel = computed(() => formatTimelineDate(selectedDate.value))
const panelSummary = computed(() => detail.value?.summary ?? null)
const selectedTodos = computed(() => detail.value?.todos ?? [])
const selectedTasks = computed(() => detail.value?.tasks ?? [])
const selectedReminders = computed(() => detail.value?.reminders ?? [])
const selectedGoals = computed(() => detail.value?.goals ?? [])
const panelLockLabel = computed(() => (panelSummary.value?.high_priority_total ? 'Priority Lock' : 'Standard Flow'))
const panelRings = computed(() => {
const summary = panelSummary.value
if (!summary) {
return [
{ label: '负载', value: 0, tone: 'normal' },
{ label: '冲突', value: 0, tone: 'warn' },
{ label: '专注', value: 0, tone: 'alert' },
]
}
const load = Math.min(100, summary.todo_total * 10 + summary.task_due_total * 14 + summary.reminder_total * 8 + summary.goal_total * 6)
const conflict = Math.min(100, summary.high_priority_total * 30 + summary.reminder_total * 12 + Math.max(summary.task_due_total - 2, 0) * 8)
const focus = Math.min(100, summary.goal_total * 20 + summary.high_priority_total * 22 + summary.todo_completed * 12)
return [
{ label: '负载', value: load, tone: 'normal' as const },
{ label: '冲突', value: conflict, tone: 'warn' as const },
{ label: '专注', value: focus, tone: 'alert' as const },
]
})
const timelineItems = computed<TimelineItem[]>(() => {
const todos = selectedTodos.value.map<TimelineItem>((todo, index) => ({
id: `todo-${todo.id}`,
type: 'todo',
tone: todo.is_completed ? 'normal' : 'warn',
time: `${String(8 + index).padStart(2, '0')}:00`,
label: 'Todo / Queue',
title: todo.title,
meta: todo.source ? `SOURCE: ${String(todo.source).toUpperCase()}` : 'SOURCE: MANUAL',
done: todo.is_completed,
payload: todo,
}))
const tasks = selectedTasks.value.map<TimelineItem>((task, index) => ({
id: `task-${task.id}`,
type: 'task',
tone: task.priority === 'urgent' || task.priority === 'high' ? 'alert' : 'normal',
time: `${String(10 + index).padStart(2, '0')}:30`,
label: 'Task / Active',
title: task.title,
meta: `PRIORITY: ${priorityConfig[task.priority].label} · STATUS: ${task.status.toUpperCase()}`,
done: task.status === 'done',
payload: task,
}))
const reminders = selectedReminders.value.map<TimelineItem>((reminder) => ({
id: `reminder-${reminder.id}`,
type: 'reminder',
tone: reminder.status === 'done' ? 'normal' : 'warn',
time: reminder.reminder_at.slice(11, 16),
label: 'Reminder / Watch',
title: reminder.title,
meta: `STATE: ${reminder.status.toUpperCase()}`,
done: reminder.status === 'done',
payload: reminder,
}))
const goals = selectedGoals.value.map<TimelineItem>((goal, index) => ({
id: `goal-${goal.id}`,
type: 'goal',
tone: goal.status === 'done' ? 'normal' : 'alert',
time: `${String(18 + index).padStart(2, '0')}:00`,
label: 'Goal / Open',
title: goal.title,
meta: `STATE: ${goal.status.toUpperCase()}`,
done: goal.status === 'done',
payload: goal,
}))
return [...todos, ...tasks, ...reminders, ...goals].sort((a, b) => a.time.localeCompare(b.time))
})
async function loadMonth() {
const requestId = monthRequestId.value + 1
monthRequestId.value = requestId
loadingMonth.value = true
try {
const response = await scheduleCenterApi.month(getMonthKey(currentMonth.value))
if (requestId !== monthRequestId.value) return
monthSummary.value = response.data.days
} catch (error) {
if (requestId !== monthRequestId.value) return
console.error('加载月度调度数据失败', error)
monthSummary.value = []
} finally {
if (requestId === monthRequestId.value) {
loadingMonth.value = false
}
}
}
async function loadDateDetail(dateKey = selectedDate.value) {
const requestId = detailRequestId.value + 1
detailRequestId.value = requestId
loadingDetail.value = true
detail.value = null
try {
const response = await scheduleCenterApi.date(dateKey)
if (requestId !== detailRequestId.value) return
detail.value = response.data
} catch (error) {
if (requestId !== detailRequestId.value) return
console.error('加载日期详情失败', error)
detail.value = null
} finally {
if (requestId === detailRequestId.value) {
loadingDetail.value = false
}
}
}
function openDate(dateKey: string) {
selectedDate.value = dateKey
panelOpen.value = true
panelMode.value = 'detail'
loadDateDetail(dateKey)
}
function shiftMonth(offset: number) {
currentMonth.value = new Date(currentMonth.value.getFullYear(), currentMonth.value.getMonth() + offset, 1)
}
async function addTodo() {
if (!newTodoTitle.value.trim()) return
try {
await todoApi.create({ title: newTodoTitle.value.trim(), todo_date: selectedDate.value })
newTodoTitle.value = ''
await Promise.all([loadDateDetail(), loadMonth()])
} catch (error) {
console.error('新增待办失败', error)
}
}
async function toggleTodo(todo: Todo) {
try {
await todoApi.update(todo.id, { is_completed: !todo.is_completed })
await Promise.all([loadDateDetail(), loadMonth()])
} catch (error) {
console.error('切换待办状态失败', error)
}
}
async function removeTodo(id: string) {
try {
await todoApi.delete(id)
await Promise.all([loadDateDetail(), loadMonth()])
} catch (error) {
console.error('删除待办失败', error)
}
}
async function addTask() {
if (!newTaskTitle.value.trim()) return
try {
await taskApi.create({ title: newTaskTitle.value.trim(), priority: newTaskPriority.value, due_date: `${selectedDate.value}T09:00:00Z` })
newTaskTitle.value = ''
newTaskPriority.value = 'medium'
await Promise.all([loadDateDetail(), loadMonth()])
} catch (error) {
console.error('新增任务失败', error)
}
}
async function cycleTaskStatus(task: Task) {
const nextStatus: TaskStatus = task.status === 'todo' ? 'in_progress' : task.status === 'in_progress' ? 'done' : 'todo'
try {
await taskApi.update(task.id, { status: nextStatus })
await Promise.all([loadDateDetail(), loadMonth()])
} catch (error) {
console.error('切换任务状态失败', error)
}
}
async function removeTask(id: string) {
try {
await taskApi.delete(id)
await Promise.all([loadDateDetail(), loadMonth()])
} catch (error) {
console.error('删除任务失败', error)
}
}
async function addReminder() {
if (!newReminderTitle.value.trim()) return
try {
await reminderApi.create({
title: newReminderTitle.value.trim(),
reminder_at: `${selectedDate.value}T${newReminderTime.value}:00Z`,
})
newReminderTitle.value = ''
newReminderTime.value = '09:00'
await Promise.all([loadDateDetail(), loadMonth()])
} catch (error) {
console.error('新增提醒失败', error)
}
}
async function toggleReminder(reminder: Reminder) {
try {
await reminderApi.update(reminder.id, { status: reminder.status === 'done' ? 'pending' : 'done' })
await Promise.all([loadDateDetail(), loadMonth()])
} catch (error) {
console.error('切换提醒状态失败', error)
}
}
async function removeReminder(id: string) {
try {
await reminderApi.delete(id)
await Promise.all([loadDateDetail(), loadMonth()])
} catch (error) {
console.error('删除提醒失败', error)
}
}
async function addGoal() {
if (!newGoalTitle.value.trim()) return
try {
await goalApi.create({ title: newGoalTitle.value.trim(), goal_date: selectedDate.value })
newGoalTitle.value = ''
await Promise.all([loadDateDetail(), loadMonth()])
} catch (error) {
console.error('新增目标失败', error)
}
}
async function toggleGoal(goal: Goal) {
try {
await goalApi.update(goal.id, { status: goal.status === 'done' ? 'active' : 'done' })
await Promise.all([loadDateDetail(), loadMonth()])
} catch (error) {
console.error('切换目标状态失败', error)
}
}
async function removeGoal(id: string) {
try {
await goalApi.delete(id)
await Promise.all([loadDateDetail(), loadMonth()])
} catch (error) {
console.error('删除目标失败', error)
}
}
async function toggleTimelineItem(item: TimelineItem) {
if (item.type === 'todo') return toggleTodo(item.payload)
if (item.type === 'task') return cycleTaskStatus(item.payload)
if (item.type === 'reminder') return toggleReminder(item.payload)
return toggleGoal(item.payload)
}
async function removeTimelineItem(item: TimelineItem) {
if (item.type === 'todo') return removeTodo(item.payload.id)
if (item.type === 'task') return removeTask(item.payload.id)
if (item.type === 'reminder') return removeReminder(item.payload.id)
return removeGoal(item.payload.id)
}
watch(currentMonth, loadMonth)
onMounted(async () => {
await loadMonth()
await loadDateDetail()
panelOpen.value = true
panelMode.value = 'detail'
})
function animatePanelContent() {
const panel = panelFrameRef.value
if (!panel) return
const targets = panel.querySelectorAll<HTMLElement>('.panel-header, .panel-switcher, .ring-strip, .panel-loading, .timeline, .create-stack')
targets.forEach((element, index) => {
animate(
element,
{
opacity: [0, 1],
transform: ['translateY(14px)', 'translateY(0px)'] as const,
} as never,
{
duration: 0.24,
delay: 0.03 + index * 0.04,
easing: [0.22, 1, 0.36, 1],
} as never,
)
})
}
function animatePanelEnter(el: Element, done: () => void) {
panelAnimation.value?.cancel()
const animation = animate(
el,
{
opacity: [0, 1],
transform: ['translateX(36px) scale(0.985)', 'translateX(0px) scale(1)'] as const,
} as never,
{
duration: 0.3,
easing: [0.22, 1, 0.36, 1],
} as never,
)
panelAnimation.value = animation
animation.finished.catch(() => undefined).finally(() => {
if (panelAnimation.value === animation) {
panelAnimation.value = null
requestAnimationFrame(() => animatePanelContent())
}
done()
})
}
function animatePanelLeave(el: Element, done: () => void) {
panelAnimation.value?.cancel()
const animation = animate(
el,
{
opacity: [1, 0],
transform: ['translateX(0px) scale(1)', 'translateX(28px) scale(0.99)'] as const,
} as never,
{
duration: 0.2,
easing: 'ease-in',
} as never,
)
panelAnimation.value = animation
animation.finished.catch(() => undefined).finally(() => {
if (panelAnimation.value === animation) {
panelAnimation.value = null
}
done()
})
}
return {
weekdayLabels,
currentMonth,
panelOpen,
loadingMonth,
loadingDetail,
newTodoTitle,
newTaskTitle,
newTaskPriority,
newReminderTitle,
newReminderTime,
newGoalTitle,
panelMode,
panelFrameRef,
priorityConfig,
calendarDays,
panelDateLabel,
panelSummary,
panelLockLabel,
panelRings,
timelineItems,
monthOverview,
getMonthKey,
getVisibleMetrics,
openDate,
shiftMonth,
addTodo,
addTask,
addReminder,
addGoal,
toggleTimelineItem,
removeTimelineItem,
animatePanelEnter,
animatePanelLeave,
}
}

View File

@@ -0,0 +1,163 @@
import { mount, flushPromises } from '@vue/test-utils'
import { describe, expect, it, vi, beforeEach, type Mocked } from 'vitest'
vi.mock('@/api/scheduleCenter', () => ({
scheduleCenterApi: {
month: vi.fn(),
date: vi.fn(),
},
}))
vi.mock('@/api/todo', () => ({
todoApi: {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}))
vi.mock('@/api/task', () => ({
taskApi: {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}))
vi.mock('@/api/reminder', () => ({
reminderApi: {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}))
vi.mock('@/api/goal', () => ({
goalApi: {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}))
import ScheduleCenterPage from './index.vue'
import { scheduleCenterApi } from '@/api/scheduleCenter'
import { todoApi } from '@/api/todo'
import { taskApi } from '@/api/task'
import { reminderApi } from '@/api/reminder'
import { goalApi } from '@/api/goal'
const mockedScheduleCenterApi = scheduleCenterApi as Mocked<typeof scheduleCenterApi>
const mockedTodoApi = todoApi as Mocked<typeof todoApi>
const mockedTaskApi = taskApi as Mocked<typeof taskApi>
const mockedReminderApi = reminderApi as Mocked<typeof reminderApi>
const mockedGoalApi = goalApi as Mocked<typeof goalApi>
const monthPayload = {
data: {
month: '2026-03',
days: [
{
date: '2026-03-26',
todo_total: 1,
todo_completed: 0,
task_due_total: 2,
high_priority_total: 1,
reminder_total: 1,
goal_total: 1,
},
],
},
}
const datePayload = {
data: {
date: '2026-03-26',
todos: [{ id: 'todo-1', title: 'Write plan', is_completed: false, source: 'manual', source_detail: null, todo_date: '2026-03-26', completed_at: null, created_at: '', updated_at: '' }],
tasks: [{ id: 'task-1', title: 'Ship center', status: 'todo', priority: 'high', created_at: '', updated_at: '' }],
reminders: [{ id: 'rem-1', title: 'Standup', note: null, reminder_at: '2026-03-26T09:00:00Z', status: 'pending', is_dismissed: false, created_at: '', updated_at: '' }],
goals: [{ id: 'goal-1', title: 'Launch', note: null, goal_date: '2026-03-26', status: 'active', created_at: '', updated_at: '' }],
summary: {
date: '2026-03-26',
todo_total: 1,
todo_completed: 0,
task_due_total: 2,
high_priority_total: 1,
reminder_total: 1,
goal_total: 1,
},
generated_at: '2026-03-26T00:00:00Z',
},
}
describe('ScheduleCenterPage', () => {
beforeEach(() => {
mockedScheduleCenterApi.month.mockResolvedValue(monthPayload as never)
mockedScheduleCenterApi.date.mockResolvedValue(datePayload as never)
mockedTodoApi.create.mockResolvedValue({ data: {} } as never)
mockedTodoApi.update.mockResolvedValue({ data: {} } as never)
mockedTodoApi.delete.mockResolvedValue({ data: {} } as never)
mockedTaskApi.create.mockResolvedValue({ data: {} } as never)
mockedTaskApi.update.mockResolvedValue({ data: {} } as never)
mockedTaskApi.delete.mockResolvedValue({ data: {} } as never)
mockedReminderApi.create.mockResolvedValue({ data: {} } as never)
mockedReminderApi.update.mockResolvedValue({ data: {} } as never)
mockedReminderApi.delete.mockResolvedValue({ data: {} } as never)
mockedGoalApi.create.mockResolvedValue({ data: {} } as never)
mockedGoalApi.update.mockResolvedValue({ data: {} } as never)
mockedGoalApi.delete.mockResolvedValue({ data: {} } as never)
})
it('loads month and selected day data on mount', async () => {
const wrapper = mount(ScheduleCenterPage)
await flushPromises()
expect(mockedScheduleCenterApi.month).toHaveBeenCalledTimes(1)
expect(mockedScheduleCenterApi.date).toHaveBeenCalledTimes(1)
expect(wrapper.find('.date-panel').exists()).toBe(true)
expect(wrapper.text()).toContain('SCHEDULE CENTER / TACTICAL CALENDAR MATRIX')
expect(wrapper.text()).toContain('查看当前设置')
expect(wrapper.text()).toContain('新增安排')
expect(wrapper.text()).toContain('Write plan')
expect(wrapper.text()).toContain('Ship center')
expect(wrapper.text()).not.toContain('新增 TODO')
})
it('keeps the side panel open and refreshes when selecting a calendar cell', async () => {
const wrapper = mount(ScheduleCenterPage)
await flushPromises()
expect(wrapper.find('.date-panel').exists()).toBe(true)
await wrapper.find('.calendar-cell').trigger('click')
await flushPromises()
expect(wrapper.find('.date-panel').exists()).toBe(true)
expect(mockedScheduleCenterApi.date.mock.calls.length).toBeGreaterThanOrEqual(2)
})
it('switches panel to create mode and creates a todo', async () => {
const wrapper = mount(ScheduleCenterPage)
await flushPromises()
const panelTabs = wrapper.findAll('.panel-tab')
await panelTabs[1].trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('新增 TODO')
expect(wrapper.text()).toContain('新增任务')
expect(wrapper.text()).toContain('新增提醒')
expect(wrapper.text()).toContain('新增目标')
expect(wrapper.text()).not.toContain('Write plan')
const inputs = wrapper.findAll('input')
await inputs[0].setValue('New todo item')
const addButtons = wrapper.findAll('.create-row button')
await addButtons[0].trigger('click')
await flushPromises()
expect(mockedTodoApi.create).toHaveBeenCalledWith({ title: 'New todo item', todo_date: expect.any(String) })
expect(mockedScheduleCenterApi.date.mock.calls.length).toBeGreaterThanOrEqual(3)
expect(mockedScheduleCenterApi.month.mock.calls.length).toBeGreaterThanOrEqual(3)
})
})

View File

@@ -0,0 +1,228 @@
<script setup lang="ts">
import { ChevronLeft, ChevronRight, Clock3, Plus } from 'lucide-vue-next'
import { useScheduleCenterPage } from './composables/useScheduleCenterPage'
const {
weekdayLabels,
currentMonth,
panelOpen,
loadingMonth,
loadingDetail,
newTodoTitle,
newTaskTitle,
newTaskPriority,
newReminderTitle,
newReminderTime,
newGoalTitle,
panelMode,
panelFrameRef,
calendarDays,
panelRings,
timelineItems,
monthOverview,
getMonthKey,
openDate,
shiftMonth,
addTodo,
addTask,
addReminder,
addGoal,
toggleTimelineItem,
removeTimelineItem,
animatePanelEnter,
animatePanelLeave,
} = useScheduleCenterPage()
</script>
<template>
<div class="schedule-center scanlines" :class="{ 'panel-active': panelOpen }">
<div class="bg-grid"></div>
<div class="bg-scan"></div>
<div class="bg-orbit"></div>
<div class="schedule-shell">
<section class="frame board">
<div class="topbar">
<div class="hero-label">SCHEDULE CENTER / TACTICAL CALENDAR MATRIX</div>
</div>
<div class="metrics">
<div class="metric">
<div class="small-label">Pending Queue</div>
<strong>{{ monthOverview.todo_total }}</strong>
</div>
<div class="metric">
<div class="small-label">Task Pressure</div>
<strong>{{ monthOverview.task_due_total }}</strong>
</div>
<div class="metric warn">
<div class="small-label">Reminder Burst</div>
<strong>{{ monthOverview.reminder_total }}</strong>
</div>
<div class="metric alert">
<div class="small-label">Critical Focus</div>
<strong>{{ monthOverview.high_priority_total }}</strong>
</div>
</div>
<div class="grid-head">
<div>
<div class="eyebrow">Calendar Matrix</div>
<div class="month">{{ getMonthKey(currentMonth) }}</div>
</div>
<div class="toolbar">
<button class="ghost-btn" type="button" @click="shiftMonth(-1)">
<ChevronLeft :size="16" />
</button>
<button class="ghost-btn" type="button" @click="shiftMonth(1)">
<ChevronRight :size="16" />
</button>
</div>
</div>
<div class="weekday">
<div v-for="label in weekdayLabels" :key="label">{{ label }}</div>
</div>
<div class="days" :class="{ loading: loadingMonth }">
<template v-for="day in calendarDays" :key="day.dateKey">
<div v-if="day.isPlaceholder" class="day muted" aria-hidden="true"></div>
<button
v-else
class="day calendar-cell"
:class="{
active: day.isSelected,
today: day.isToday,
}"
@click="openDate(day.dateKey)"
>
<div class="day-top">
<span class="day-num">{{ day.dayNumber }}</span>
<span class="risk">{{ day.risk }}</span>
</div>
<div class="state">{{ day.state }}</div>
<div class="bars">
<div v-for="bar in day.bars" :key="bar.key" class="bar">
<span>{{ bar.key }}</span>
<div class="bar-track">
<div class="bar-fill" :style="{ '--value': bar.value }"></div>
</div>
<span>{{ bar.count }}</span>
</div>
</div>
</button>
</template>
</div>
</section>
<Transition :css="false" @enter="animatePanelEnter" @leave="animatePanelLeave">
<aside v-if="panelOpen" ref="panelFrameRef" class="frame side date-panel">
<div class="panel-header">
<div class="panel-title-line">DAY DIAGNOSTICS / ACTIVE FEED</div>
</div>
<div class="panel-switcher">
<button class="panel-tab" :class="{ active: panelMode === 'detail' }" @click="panelMode = 'detail'">查看当前设置</button>
<button class="panel-tab" :class="{ active: panelMode === 'create' }" @click="panelMode = 'create'">新增安排</button>
</div>
<div class="ring-strip">
<div v-for="ring in panelRings" :key="ring.label" class="ring-card">
<div class="ring" :class="ring.tone" :style="{ '--pct': ring.value }">{{ ring.value }}%</div>
<div class="micro">{{ ring.label }}</div>
</div>
</div>
<div v-if="loadingDetail" class="panel-loading">SYNCING DAY DATA...</div>
<div v-else class="panel-content">
<template v-if="panelMode === 'detail'">
<div class="timeline">
<article v-for="item in timelineItems" :key="item.id" class="event" :class="item.tone">
<div class="event-top">
<span class="event-time">{{ item.time }}</span>
<span class="event-type">{{ item.label }}</span>
</div>
<div class="event-title">{{ item.title }}</div>
<div class="event-meta">{{ item.meta }}</div>
<div class="event-actions">
<button class="mini-btn" type="button" @click="toggleTimelineItem(item)">{{ item.done ? '恢复' : '完成' }}</button>
<button class="mini-btn danger" type="button" @click="removeTimelineItem(item)">删除</button>
</div>
</article>
<div v-if="!timelineItems.length" class="empty-state">当前日期还没有安排</div>
</div>
</template>
<template v-else>
<div class="create-stack">
<section class="create-card">
<div class="create-head">
<span>新增 TODO</span>
<span>QUEUE</span>
</div>
<div class="create-row">
<input v-model="newTodoTitle" placeholder="新增当天待办..." @keyup.enter="addTodo" />
<button type="button" @click="addTodo">
<Plus :size="14" />
</button>
</div>
</section>
<section class="create-card">
<div class="create-head">
<span>新增任务</span>
<span>TASK</span>
</div>
<div class="create-row split">
<input v-model="newTaskTitle" placeholder="新增计划任务..." @keyup.enter="addTask" />
<select v-model="newTaskPriority">
<option value="low">LOW</option>
<option value="medium">MEDIUM</option>
<option value="high">HIGH</option>
<option value="urgent">CRITICAL</option>
</select>
<button type="button" @click="addTask">
<Plus :size="14" />
</button>
</div>
</section>
<section class="create-card">
<div class="create-head">
<span>新增提醒</span>
<span>REMINDER</span>
</div>
<div class="create-row split">
<input v-model="newReminderTitle" placeholder="新增提醒..." @keyup.enter="addReminder" />
<label class="time-field"><span class="time-field-icon"><Clock3 :size="14" /></span><input v-model="newReminderTime" type="time" aria-label="提醒时间" /></label>
<button type="button" @click="addReminder">
<Plus :size="14" />
</button>
</div>
</section>
<section class="create-card">
<div class="create-head">
<span>新增目标</span>
<span>GOAL</span>
</div>
<div class="create-row">
<input v-model="newGoalTitle" placeholder="新增当天目标..." @keyup.enter="addGoal" />
<button type="button" @click="addGoal">
<Plus :size="14" />
</button>
</div>
</section>
</div>
</template>
</div>
</aside>
</Transition>
</div>
</div>
</template>
<style scoped src="./scheduleCenterPage.css"></style>

View File

@@ -0,0 +1,751 @@
.schedule-center {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
min-height: 100%;
overflow: hidden;
background:
radial-gradient(circle at 20% 20%, rgba(0, 245, 212, 0.08), transparent 30%),
radial-gradient(circle at 84% 18%, rgba(0, 245, 212, 0.05), transparent 24%),
linear-gradient(180deg, var(--bg-deep) 0%, var(--bg-void) 48%, #02060c 100%);
}
.bg-grid,
.bg-scan,
.bg-orbit {
position: absolute;
inset: 0;
pointer-events: none;
}
.bg-grid {
background-image:
linear-gradient(rgba(0, 245, 212, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 245, 212, 0.04) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: radial-gradient(circle at center, black 48%, transparent 94%);
opacity: 0.5;
}
.bg-scan {
background: linear-gradient(180deg, transparent 0%, rgba(0, 245, 212, 0.08) 49%, transparent 51%, transparent 100%);
transform: translateY(-100%);
animation: scan 12s linear infinite;
opacity: 0.4;
}
.bg-orbit {
width: 44vw;
height: 44vw;
inset: -8vw 0 0 auto;
border-radius: 50%;
border: 1px solid var(--border-dim);
box-shadow: 0 0 40px rgba(0, 245, 212, 0.06);
animation: spin 36s linear infinite;
}
.bg-orbit::before,
.bg-orbit::after {
content: '';
position: absolute;
inset: 7%;
border-radius: 50%;
border: 1px solid var(--border-dim);
}
.bg-orbit::after {
inset: 21%;
}
.schedule-shell {
position: relative;
z-index: 1;
flex: 1;
display: grid;
grid-template-columns: minmax(780px, 1.25fr) 420px;
gap: 22px;
height: 100%;
min-height: 0;
padding: 28px;
box-sizing: border-box;
}
.frame {
position: relative;
overflow: hidden;
border: 1px solid var(--border-mid);
background:
linear-gradient(180deg, rgba(10, 15, 26, 0.94) 0%, rgba(7, 13, 22, 0.82) 100%),
radial-gradient(circle at top right, rgba(0, 245, 212, 0.08), transparent 38%);
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.45), inset 0 0 0 1px rgba(255, 255, 255, 0.02);
backdrop-filter: blur(14px);
}
.frame::before,
.frame::after {
content: '';
position: absolute;
pointer-events: none;
}
.frame::before {
inset: 14px;
border: 1px solid rgba(0, 245, 212, 0.07);
clip-path: polygon(0 14px, 14px 0, calc(100% - 62px) 0, 100% 0, 100% calc(100% - 14px), calc(100% - 14px) 100%, 62px 100%, 0 100%);
}
.frame::after {
top: 18px;
right: 20px;
width: 120px;
height: 1px;
background: linear-gradient(90deg, rgba(0, 245, 212, 0.55), transparent);
}
.board {
display: grid;
grid-template-rows: auto auto auto auto 1fr;
gap: 18px;
padding: 22px;
border-radius: 28px;
min-height: 0;
}
.side {
display: grid;
grid-template-rows: auto auto auto 1fr;
gap: 22px;
padding: 28px 24px 24px;
border-radius: 28px;
min-height: 0;
}
.date-panel {
min-height: 0;
}
.topbar,
.grid-head,
.panel-header,
.event-top,
.event-actions,
.create-head {
display: flex;
justify-content: space-between;
gap: 16px;
}
.topbar,
.grid-head,
.panel-header {
align-items: flex-start;
}
.title-group,
.bars,
.timeline,
.create-stack,
.panel-content {
display: grid;
gap: 16px;
}
.small-label,
.micro,
.month,
.weekday div,
.day-top,
.state,
.bar,
.lock,
.event-time,
.event-type,
.create-head,
.panel-tab,
.mini-btn {
font-family: var(--font-mono);
letter-spacing: 0.16em;
text-transform: uppercase;
}
.small-label,
.micro {
color: var(--text-dim);
font-size: 9px;
}
.hero-label,
.panel-title-line {
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
}
.hero-label {
padding-top: 6px;
}
.panel-title-line {
opacity: 0.92;
}
.month {
font-size: 20px;
font-family: var(--font-display);
color: var(--text-primary);
}
h1,
h2,
p {
margin: 0;
}
h1,
h2 {
font-family: var(--font-display);
letter-spacing: 0.12em;
}
h1 {
font-size: 34px;
}
h2 {
font-size: 28px;
}
.subtitle {
max-width: 520px;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.55;
}
.metrics,
.toolbar,
.weekday,
.days,
.ring-strip {
display: grid;
gap: 12px;
}
.metrics {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.toolbar {
grid-auto-flow: column;
justify-content: end;
align-items: center;
}
.weekday,
.days {
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 7px;
min-height: 0;
grid-auto-rows: minmax(0, 1fr);
}
.days {
align-content: stretch;
overflow: hidden;
}
.ring-strip {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.metric,
.ring-card,
.event,
.action,
.day,
.create-card {
border: 1px solid var(--border-dim);
background: linear-gradient(180deg, rgba(13, 21, 37, 0.94), rgba(10, 15, 26, 0.82));
}
.metric strong {
display: block;
margin-top: 5px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 16px;
font-weight: 600;
}
.metric {
position: relative;
padding: 16px;
clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
}
.metric::after {
content: '';
position: absolute;
left: 14px;
right: 14px;
bottom: 11px;
height: 2px;
background: linear-gradient(90deg, var(--accent-cyan), transparent);
opacity: 0.35;
}
.metric.warn strong {
color: var(--accent-amber);
}
.metric.alert strong {
color: var(--accent-red);
}
.ghost-btn,
.mini-btn,
.create-row button {
border: 1px solid var(--border-mid);
background: var(--accent-cyan-dim);
color: var(--accent-cyan);
cursor: pointer;
transition: all var(--transition-fast);
}
.ghost-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 38px;
width: 38px;
height: 38px;
border-radius: 12px;
}
.ghost-btn:hover,
.mini-btn:hover,
.create-row button:hover,
.day:hover:not(.muted) {
border-color: var(--border-bright);
box-shadow: var(--glow-cyan);
transform: translateY(-1px);
}
.weekday div {
text-align: center;
color: var(--text-dim);
font-size: 11px;
}
.days.loading {
opacity: 0.72;
}
.day {
position: relative;
min-height: 0;
height: 100%;
padding: 9px;
display: grid;
grid-template-rows: auto auto 1fr;
gap: 6px;
clip-path: polygon(0 0, calc(100% - 16px) 0, 100% 16px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
color: var(--text-primary);
text-align: left;
overflow: hidden;
}
.day.muted {
opacity: 0.16;
cursor: default;
}
.day.active {
border-color: rgba(249, 168, 37, 0.72);
box-shadow: 0 0 24px rgba(249, 168, 37, 0.16);
}
.day.today {
border-color: rgba(249, 168, 37, 0.28);
}
.day-num {
font-family: var(--font-display);
font-size: 15px;
}
.risk {
color: var(--accent-amber);
font-size: 8px;
}
.state {
color: var(--accent-cyan);
font-size: 8px;
}
.bar {
display: grid;
grid-template-columns: 34px 1fr 18px;
gap: 4px;
align-items: center;
color: var(--text-dim);
font-size: 7px;
}
.bar-track {
position: relative;
height: 3px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
overflow: hidden;
}
.bar-fill {
position: absolute;
inset: 0 auto 0 0;
width: var(--value);
background: linear-gradient(90deg, var(--accent-cyan), rgba(0, 245, 212, 0.14));
box-shadow: 0 0 14px rgba(0, 245, 212, 0.22);
}
.panel-switcher {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.panel-tab {
border: 1px solid var(--border-dim);
border-radius: 6px;
clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 14px 100%, 0 calc(100% - 14px));
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255,255,255,0.01));
color: var(--text-secondary);
padding: 12px 14px;
font-size: 11px;
transition: all var(--transition-fast);
}
.panel-tab:hover,
.panel-tab.active {
border-color: var(--border-bright);
color: var(--accent-cyan);
background: linear-gradient(180deg, rgba(0, 245, 212, 0.12), rgba(0, 245, 212, 0.05));
box-shadow: 0 0 18px rgba(0, 245, 212, 0.14);
}
.ring-card {
display: grid;
place-items: center;
gap: 10px;
padding: 16px 12px;
background: rgba(0, 245, 212, 0.03);
}
.ring {
width: 72px;
height: 72px;
display: grid;
place-items: center;
border-radius: 50%;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
background:
radial-gradient(circle at center, rgba(3, 10, 18, 0.95) 0 54%, transparent 55%),
conic-gradient(var(--accent-cyan) calc(var(--pct) * 1%), rgba(255, 255, 255, 0.06) 0);
box-shadow: inset 0 0 18px rgba(0, 245, 212, 0.08), 0 0 24px rgba(0, 245, 212, 0.08);
}
.ring.warn {
background:
radial-gradient(circle at center, rgba(3, 10, 18, 0.95) 0 54%, transparent 55%),
conic-gradient(var(--accent-amber) calc(var(--pct) * 1%), rgba(255, 255, 255, 0.06) 0);
}
.ring.alert {
background:
radial-gradient(circle at center, rgba(3, 10, 18, 0.95) 0 54%, transparent 55%),
conic-gradient(var(--accent-red) calc(var(--pct) * 1%), rgba(255, 255, 255, 0.06) 0);
}
.panel-loading {
padding: 24px 0;
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 11px;
}
.panel-content {
min-height: 0;
overflow-y: auto;
padding-right: 2px;
}
.timeline {
position: relative;
padding-left: 18px;
}
.timeline::before {
content: '';
position: absolute;
left: 5px;
top: 6px;
bottom: 6px;
width: 1px;
background: linear-gradient(180deg, rgba(0, 245, 212, 0.36), rgba(0, 245, 212, 0.06));
}
.event {
position: relative;
padding: 14px 14px 14px 16px;
}
.event::before {
content: '';
position: absolute;
left: -17px;
top: 18px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent-cyan);
box-shadow: 0 0 14px rgba(0, 245, 212, 0.5);
}
.event.warn::before {
background: var(--accent-amber);
box-shadow: 0 0 14px rgba(249, 168, 37, 0.42);
}
.event.alert::before {
background: var(--accent-red);
box-shadow: 0 0 14px rgba(255, 71, 87, 0.42);
}
.event-time {
color: var(--accent-cyan);
font-size: 10px;
}
.event-type {
color: var(--text-dim);
font-size: 10px;
}
.event-title {
margin: 8px 0 4px;
color: var(--text-primary);
font-size: 14px;
line-height: 1.45;
}
.event-meta {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
}
.event-actions {
margin-top: 12px;
}
.mini-btn {
border-radius: 10px;
padding: 7px 10px;
font-size: 10px;
}
.mini-btn.danger {
border-color: rgba(255, 71, 87, 0.35);
background: rgba(255, 71, 87, 0.08);
color: var(--accent-red);
}
.create-stack {
align-content: start;
}
.create-card {
display: grid;
gap: 14px;
padding: 16px;
border-radius: 8px;
}
.create-head {
color: var(--text-secondary);
font-size: 10px;
}
.create-row {
display: flex;
gap: 10px;
align-items: stretch;
min-width: 0;
}
.create-row.split select,
.create-row input {
flex: 1;
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--border-mid);
border-radius: 12px;
background: var(--bg-card);
color: var(--text-primary);
font-family: var(--font-mono);
}
.create-row.split > * {
min-width: 0;
}
.create-row.split select {
flex: 0 0 112px;
}
.create-row.split input[type='time'] {
flex: 1;
min-width: 0;
max-width: none;
padding: 0;
border: none;
background: transparent;
color: var(--text-primary);
letter-spacing: 0.04em;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.time-field {
flex: 0 0 148px;
min-width: 148px;
max-width: 148px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
border: 1px solid var(--border-mid);
border-radius: 8px;
background:
linear-gradient(180deg, rgba(10, 18, 28, 0.94), rgba(8, 14, 23, 0.88)),
radial-gradient(circle at top left, rgba(0, 245, 212, 0.07), transparent 55%);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.02);
}
.time-field:focus-within {
border-color: var(--border-bright);
box-shadow: var(--glow-cyan);
}
.time-field-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--accent-cyan);
opacity: 0.9;
flex: none;
}
.time-field input::-webkit-calendar-picker-indicator {
filter: invert(84%) sepia(78%) saturate(613%) hue-rotate(112deg) brightness(101%) contrast(101%);
cursor: pointer;
opacity: 0.9;
}
.create-row button {
flex: 0 0 40px;
width: 40px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.empty-state {
padding: 12px;
border: 1px dashed rgba(255, 255, 255, 0.08);
border-radius: 14px;
background: rgba(255, 255, 255, 0.01);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
}
@keyframes scan {
from {
transform: translateY(-100%);
}
to {
transform: translateY(100%);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 1360px) {
.schedule-center {
overflow: auto;
}
.schedule-shell {
grid-template-columns: 1fr;
height: auto;
}
.metrics,
.ring-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 820px) {
.schedule-shell {
padding: 16px;
}
.board,
.side {
padding: 20px;
}
.metrics,
.ring-strip {
grid-template-columns: 1fr;
}
.days {
gap: 8px;
}
.day {
min-height: 72px;
}
.create-row.split {
flex-wrap: wrap;
}
.create-row.split input:first-child {
flex-basis: 100%;
}
.create-row.split select,
.create-row.split input[type='time'],
.time-field {
flex: 1 1 0;
max-width: none;
}
}

View File

@@ -97,7 +97,6 @@ export function useSettingsView() {
function createEmptyModel(type: string): LLMModelConfig {
return {
name: `${type.toUpperCase()}-${Date.now()}`,
provider: 'openai',
model: type === 'chat'
? 'gpt-4o'
: type === 'vlm'
@@ -105,7 +104,7 @@ export function useSettingsView() {
: type === 'embedding'
? 'text-embedding-3-small'
: 'bge-reranker-v2',
base_url: '',
base_url: 'https://api.openai.com/v1',
api_key: '',
enabled: true,
}
@@ -242,7 +241,6 @@ export function useSettingsView() {
try {
const response = await settingsApi.testLLM({
type: type as LLMType,
provider: model.provider,
model: model.model,
base_url: model.base_url,
api_key: model.api_key,

View File

@@ -0,0 +1,446 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { skillApi, type Skill, type SkillCreate } from '@/api/skill'
function prefersReducedMotion() {
return typeof window !== 'undefined'
&& typeof window.matchMedia === 'function'
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
function animateIn(el: Element, done: () => void) {
const target = el as HTMLElement
if (prefersReducedMotion()) {
target.style.opacity = '1'
target.style.transform = 'translateY(0)'
done()
return
}
target.animate(
[
{ opacity: '0', transform: 'translateY(12px)' },
{ opacity: '1', transform: 'translateY(0)' },
],
{ duration: 220, easing: 'ease-out', fill: 'forwards' },
).finished.finally(done)
}
function animateOut(el: Element, done: () => void) {
const target = el as HTMLElement
if (prefersReducedMotion()) {
target.style.opacity = '0'
target.style.transform = 'translateY(12px)'
done()
return
}
target.animate(
[
{ opacity: '1', transform: 'translateY(0)' },
{ opacity: '0', transform: 'translateY(12px)' },
],
{ duration: 180, easing: 'ease-in', fill: 'forwards' },
).finished.finally(done)
}
const AGENT_TYPES = ['general', 'schedule_planner', 'executor', 'librarian', 'analyst']
const AVAILABLE_TOOLS = ['file_operations', 'web_search', 'code_execution', 'database', 'api_calls', 'shell', 'git', 'calendar', 'tasks']
const VISIBILITY_OPTIONS = ['private', 'team', 'market'] as const
const SOURCE_OPTIONS = ['all', 'builtin', 'custom'] as const
const STATUS_OPTIONS = ['all', 'active', 'inactive'] as const
const BUILTIN_MCP_PACKS = [
{
id: 'mcp-schedule-coordinator',
name: '鏃ョ▼鍗忚皟宸ュ叿鍖?',
agentType: 'schedule_planner',
category: 'planning',
tools: ['calendar', 'tasks'],
description: '鍥寸粫鎺掓湡銆佸啿绐佽瘑鍒笌璁″垝钀藉湴缁勭粐鍙墽琛屽伐鍏疯兘鍔涖€?',
},
{
id: 'mcp-execution-ops',
name: '鎵ц鎺ㄨ繘宸ュ叿鍖?',
agentType: 'executor',
category: 'operations',
tools: ['shell', 'api_calls', 'git'],
description: '涓烘墽琛岃鑹叉彁渚涗换鍔℃帹杩涖€佸懡浠ゆ墽琛屼笌澶栭儴浜や簰鑳藉姏銆?',
},
{
id: 'mcp-knowledge-pipeline',
name: '鐭ヨ瘑娌夋穩宸ュ叿鍖?',
agentType: 'librarian',
category: 'knowledge',
tools: ['web_search', 'database'],
description: '鏀寔妫€绱€佹暣鐞嗕笌鍥捐氨娌夋穩绛夌煡璇嗗伐浣滄祦銆?',
},
{
id: 'mcp-analysis-console',
name: '鍒嗘瀽娲炲療宸ュ叿鍖?',
agentType: 'analyst',
category: 'analysis',
tools: ['database', 'api_calls', 'code_execution'],
description: '鑱氬悎鍒嗘瀽銆佸姣斾笌娲炲療鏁寸悊鎵€闇€鐨勬牳蹇冨伐鍏枫€?',
},
] as const
type BuiltinMcpPack = (typeof BUILTIN_MCP_PACKS)[number]
export function useSkillsPage() {
const skills = ref<Skill[]>([])
const loading = ref(false)
const saving = ref(false)
const modalOpen = ref(false)
const mcpPanelOpen = ref(false)
const editingSkill = ref<Skill | null>(null)
const selectedSkillId = ref<string | null>(null)
const detailDrawerOpen = ref(false)
const drawerTitleId = 'skills-detail-drawer-title'
const drawerDescriptionId = 'skills-detail-drawer-description'
const drawerPanelRef = ref<HTMLElement | null>(null)
const lastTriggerElement = ref<HTMLElement | null>(null)
const searchQuery = ref('')
const selectedAgentFilter = ref<'all' | string>('all')
const selectedSourceFilter = ref<(typeof SOURCE_OPTIONS)[number]>('all')
const selectedStatusFilter = ref<(typeof STATUS_OPTIONS)[number]>('all')
const selectedVisibilityFilter = ref<'all' | Skill['visibility']>('all')
const mcpPacksByAgent = computed(() => {
return BUILTIN_MCP_PACKS.reduce<Record<string, BuiltinMcpPack[]>>((acc, pack) => {
const existing = acc[pack.agentType] || []
acc[pack.agentType] = [...existing, pack]
return acc
}, {})
})
const activeSkillCount = computed(() => skills.value.filter(skill => skill.is_active).length)
const coverageToolCount = computed(() => new Set(skills.value.flatMap(skill => skill.tools)).size)
const activeRatio = computed(() => skills.value.length ? `${Math.round((activeSkillCount.value / skills.value.length) * 100)}%` : '0%')
const selectedSkill = computed(() => skills.value.find(skill => skill.id === selectedSkillId.value) ?? null)
const filteredSkills = computed(() => {
const keyword = searchQuery.value.trim().toLowerCase()
return skills.value.filter((skill) => {
const matchesKeyword = !keyword || [
skill.name,
skill.description || '',
skill.agent_type,
skill.visibility,
...skill.tools,
].some(value => value.toLowerCase().includes(keyword))
const matchesAgent = selectedAgentFilter.value === 'all' || skill.agent_type === selectedAgentFilter.value
const matchesSource = selectedSourceFilter.value === 'all'
|| (selectedSourceFilter.value === 'builtin' ? skill.is_builtin : !skill.is_builtin)
const matchesStatus = selectedStatusFilter.value === 'all'
|| (selectedStatusFilter.value === 'active' ? skill.is_active : !skill.is_active)
const matchesVisibility = selectedVisibilityFilter.value === 'all' || skill.visibility === selectedVisibilityFilter.value
return matchesKeyword && matchesAgent && matchesSource && matchesStatus && matchesVisibility
})
})
const form = ref<SkillCreate>({
name: '',
description: '',
instructions: '',
agent_type: 'general',
tools: [],
visibility: 'private',
is_active: true,
})
function resetForm() {
form.value = {
name: '',
description: '',
instructions: '',
agent_type: 'general',
tools: [],
visibility: 'private',
is_active: true,
}
}
function openCreateModal() {
editingSkill.value = null
resetForm()
modalOpen.value = true
}
function openEditModal(skill: Skill) {
editingSkill.value = skill
form.value = {
name: skill.name,
description: skill.description || '',
instructions: skill.instructions,
agent_type: skill.agent_type,
tools: [...skill.tools],
visibility: skill.visibility,
is_active: skill.is_active,
}
modalOpen.value = true
}
function closeModal() {
modalOpen.value = false
editingSkill.value = null
resetForm()
}
function openMcpPanel() {
mcpPanelOpen.value = true
}
function closeMcpPanel() {
mcpPanelOpen.value = false
}
function selectSkill(skillId: string, trigger?: EventTarget | null) {
selectedSkillId.value = skillId
lastTriggerElement.value = trigger instanceof HTMLElement ? trigger : null
detailDrawerOpen.value = true
}
function closeDetailDrawer() {
detailDrawerOpen.value = false
}
function handleDrawerKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault()
closeDetailDrawer()
return
}
if (event.key !== 'Tab' || !drawerPanelRef.value) return
const focusableElements = Array.from(
drawerPanelRef.value.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'),
).filter(element => !element.hasAttribute('disabled'))
if (focusableElements.length === 0) return
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
const activeElement = document.activeElement
if (event.shiftKey && activeElement === firstElement) {
event.preventDefault()
lastElement.focus()
return
}
if (!event.shiftKey && activeElement === lastElement) {
event.preventDefault()
firstElement.focus()
}
}
function formatRelativeTime(value: string) {
const timestamp = new Date(value).getTime()
if (Number.isNaN(timestamp)) return '--'
const diffMinutes = Math.max(0, Math.floor((Date.now() - timestamp) / 60000))
if (diffMinutes < 1) return '鍒氬垰'
if (diffMinutes < 60) return `${diffMinutes}m ago`
const diffHours = Math.floor(diffMinutes / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
return `${diffDays}d ago`
}
function getBindingCount(skill: Skill) {
return Math.max(1, Math.min(4, skill.tools.length || 1))
}
function getSourceLabel(skill: Skill) {
return skill.is_builtin ? 'BUILT-IN' : 'CUSTOM'
}
function getStatusLabel(skill: Skill) {
return skill.is_active ? 'ACTIVE' : 'INACTIVE'
}
function getToolPreview(skill: Skill) {
return skill.tools.slice(0, 3)
}
function getToolOverflow(skill: Skill) {
return Math.max(0, skill.tools.length - 3)
}
async function fetchSkills() {
loading.value = true
try {
const res = await skillApi.list()
if (res.data.length === 0) {
const bootstrapRes = await skillApi.bootstrapBuiltin()
skills.value = bootstrapRes.data
} else {
skills.value = res.data
}
selectedSkillId.value = skills.value[0]?.id ?? null
detailDrawerOpen.value = false
} catch (e) {
console.error('Failed to fetch skills', e)
} finally {
loading.value = false
}
}
async function createSkill() {
saving.value = true
try {
const res = await skillApi.create(form.value)
skills.value = [...skills.value, res.data]
selectedSkillId.value = res.data.id
detailDrawerOpen.value = true
closeModal()
} catch (e) {
console.error('Failed to create skill', e)
} finally {
saving.value = false
}
}
async function updateSkill() {
if (!editingSkill.value) return
saving.value = true
try {
const res = await skillApi.update(editingSkill.value.id, form.value)
skills.value = skills.value.map(skill => skill.id === editingSkill.value?.id ? res.data : skill)
selectedSkillId.value = res.data.id
detailDrawerOpen.value = true
closeModal()
} catch (e) {
console.error('Failed to update skill', e)
} finally {
saving.value = false
}
}
async function deleteSkill(skill: Skill) {
if (!confirm(`Delete skill "${skill.name}"?`)) return
try {
await skillApi.delete(skill.id)
skills.value = skills.value.filter(s => s.id !== skill.id)
if (selectedSkillId.value === skill.id) {
selectedSkillId.value = skills.value[0]?.id ?? null
if (!selectedSkillId.value) {
detailDrawerOpen.value = false
}
}
} catch (e) {
console.error('Failed to delete skill', e)
}
}
async function toggleActive(skill: Skill) {
try {
const res = await skillApi.update(skill.id, { is_active: !skill.is_active })
skills.value = skills.value.map(item => item.id === skill.id ? res.data : item)
} catch (e) {
console.error('Failed to toggle skill active state', e)
}
}
function copySkill(skill: Skill) {
const skillText = JSON.stringify({
name: skill.name,
description: skill.description,
instructions: skill.instructions,
agent_type: skill.agent_type,
tools: skill.tools,
visibility: skill.visibility,
}, null, 2)
navigator.clipboard.writeText(skillText).catch(e => {
console.error('Failed to copy skill', e)
})
}
function toggleTool(tool: string) {
const idx = form.value.tools?.indexOf(tool) ?? -1
if (idx === -1) {
form.value.tools = [...(form.value.tools || []), tool]
} else {
form.value.tools = form.value.tools?.filter(t => t !== tool) ?? []
}
}
watch(detailDrawerOpen, async (isOpen) => {
if (isOpen) {
await nextTick()
drawerPanelRef.value?.focus()
return
}
const target = lastTriggerElement.value
lastTriggerElement.value = null
target?.focus()
})
watch(skills, (nextSkills) => {
if (selectedSkillId.value && !nextSkills.some(skill => skill.id === selectedSkillId.value)) {
selectedSkillId.value = null
detailDrawerOpen.value = false
}
})
onMounted(fetchSkills)
onBeforeUnmount(() => {
lastTriggerElement.value = null
})
return {
AGENT_TYPES,
AVAILABLE_TOOLS,
VISIBILITY_OPTIONS,
SOURCE_OPTIONS,
STATUS_OPTIONS,
BUILTIN_MCP_PACKS,
skills,
loading,
saving,
modalOpen,
mcpPanelOpen,
editingSkill,
detailDrawerOpen,
drawerTitleId,
drawerDescriptionId,
drawerPanelRef,
searchQuery,
selectedAgentFilter,
selectedSourceFilter,
selectedStatusFilter,
selectedVisibilityFilter,
mcpPacksByAgent,
activeSkillCount,
coverageToolCount,
activeRatio,
selectedSkill,
filteredSkills,
form,
openCreateModal,
openEditModal,
closeModal,
openMcpPanel,
closeMcpPanel,
selectSkill,
closeDetailDrawer,
handleDrawerKeydown,
formatRelativeTime,
getBindingCount,
getSourceLabel,
getStatusLabel,
getToolPreview,
getToolOverflow,
createSkill,
updateSkill,
deleteSkill,
toggleActive,
copySkill,
toggleTool,
animateIn,
animateOut,
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,296 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { mount } from '@vue/test-utils'
const mocks = vi.hoisted(() => ({
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
bootstrapBuiltin: vi.fn(),
}))
vi.mock('@/api/skill', () => ({
skillApi: {
list: mocks.list,
create: mocks.create,
update: mocks.update,
delete: mocks.delete,
bootstrapBuiltin: mocks.bootstrapBuiltin,
},
}))
import SkillsPage from './index.vue'
const skillFixtures = [
{
id: 'skill-schedule-1',
name: 'Priority Router',
description: 'Aligns planner priorities.',
instructions: 'Prioritize schedule risks.',
agent_type: 'schedule_planner',
tools: ['calendar', 'tasks'],
required_context: [],
output_format: null,
visibility: 'private' as const,
is_builtin: true,
team_id: null,
is_active: true,
owner_id: 'user-1',
created_at: '2026-03-26T00:00:00Z',
updated_at: '2026-03-26T00:00:00Z',
},
{
id: 'skill-analyst-1',
name: 'Trend Lens',
description: 'Summarizes movement signals.',
instructions: 'Summarize the latest trend shifts.',
agent_type: 'analyst',
tools: ['database'],
required_context: [],
output_format: null,
visibility: 'team' as const,
is_builtin: false,
team_id: null,
is_active: true,
owner_id: 'user-1',
created_at: '2026-03-26T00:00:00Z',
updated_at: '2026-03-26T00:00:00Z',
},
]
describe('skills page ability center', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.list.mockResolvedValue({ data: skillFixtures })
mocks.create.mockResolvedValue({ data: skillFixtures[0] })
mocks.update.mockResolvedValue({ data: skillFixtures[0] })
mocks.delete.mockResolvedValue({})
mocks.bootstrapBuiltin.mockResolvedValue({ data: skillFixtures })
vi.stubGlobal('navigator', {
clipboard: {
writeText: vi.fn().mockResolvedValue(undefined),
},
})
vi.stubGlobal('confirm', vi.fn(() => true))
vi.stubGlobal('matchMedia', vi.fn().mockImplementation(() => ({
matches: false,
media: '(prefers-reduced-motion: reduce)',
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})))
})
it('renders the ability center title and preserves the create skill action', async () => {
const wrapper = mount(SkillsPage)
await Promise.resolve()
await Promise.resolve()
expect(wrapper.get('[data-testid="skills-page-title"]').text()).toContain('能力中心')
expect(wrapper.text()).not.toContain('技能中心')
expect(wrapper.get('[data-testid="skills-create-button"]').text()).toContain('新建技能')
})
it('removes the legacy refresh action from the header', async () => {
const wrapper = mount(SkillsPage)
await Promise.resolve()
await Promise.resolve()
expect(wrapper.find('[data-testid="skills-refresh-button"]').exists()).toBe(false)
})
it('shows an MCP action in the header', async () => {
const wrapper = mount(SkillsPage)
await Promise.resolve()
await Promise.resolve()
expect(wrapper.get('[data-testid="skills-mcp-button"]').text()).toContain('MCP')
expect(wrapper.find('[data-testid="skills-create-button"]').exists()).toBe(true)
})
it('shows builtin badges in the table without the legacy overview chips', async () => {
const wrapper = mount(SkillsPage)
await Promise.resolve()
await Promise.resolve()
expect(wrapper.find('[data-testid="skills-overview"]').exists()).toBe(false)
const builtinBadges = wrapper.findAll('[data-testid="builtin-skill-badge"]')
expect(builtinBadges).toHaveLength(1)
expect(builtinBadges[0].text()).toContain('内置')
expect(wrapper.text()).toContain('Priority Router')
expect(wrapper.text()).toContain('Trend Lens')
})
it('renders the new table-first console layout with the detail drawer hidden by default', async () => {
const wrapper = mount(SkillsPage)
await Promise.resolve()
await Promise.resolve()
expect(wrapper.find('[data-testid="skills-table"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="skills-detail-panel"]').exists()).toBe(false)
expect(wrapper.get('[data-testid="skills-metrics-strip"]').text()).toContain('TOTAL')
expect(wrapper.text()).not.toContain('ABILITY TABLE')
expect(wrapper.text()).not.toContain('rows')
expect(wrapper.find('.panel-title').exists()).toBe(false)
})
it('filters rows from the search input', async () => {
const wrapper = mount(SkillsPage)
await Promise.resolve()
await Promise.resolve()
await wrapper.get('[data-testid="skills-search-input"]').setValue('Trend')
expect(wrapper.find('[data-testid="skills-table-row-skill-schedule-1"]').exists()).toBe(false)
expect(wrapper.get('[data-testid="skills-table-row-skill-analyst-1"]').text()).toContain('Trend Lens')
})
it('opens the detail drawer when a row is activated', async () => {
const wrapper = mount(SkillsPage)
await Promise.resolve()
await Promise.resolve()
await wrapper.get('[data-testid="skills-table-row-skill-analyst-1"]').trigger('click')
expect(wrapper.get('[data-testid="skills-detail-panel"]').text()).toContain('Trend Lens')
expect(wrapper.get('[data-testid="skills-detail-panel"]').text()).toContain('Summarizes movement signals.')
})
it('closes the detail drawer from the close button', async () => {
const wrapper = mount(SkillsPage)
await Promise.resolve()
await Promise.resolve()
await wrapper.get('[data-testid="skills-table-row-skill-analyst-1"]').trigger('click')
await wrapper.get('[aria-label="关闭详情滑窗"]').trigger('click')
expect(wrapper.find('[data-testid="skills-detail-panel"]').exists()).toBe(false)
})
it('keeps the selected drawer record stable when filters hide its row', async () => {
const wrapper = mount(SkillsPage)
await Promise.resolve()
await Promise.resolve()
await wrapper.get('[data-testid="skills-table-row-skill-analyst-1"]').trigger('click')
await wrapper.get('[data-testid="skills-search-input"]').setValue('Priority')
await nextTick()
expect(wrapper.find('[data-testid="skills-table-row-skill-analyst-1"]').exists()).toBe(false)
expect(wrapper.get('[data-testid="skills-detail-panel"]').text()).toContain('Trend Lens')
})
it('renders the detail drawer as an accessible dialog and closes on escape', async () => {
const wrapper = mount(SkillsPage, { attachTo: document.body })
await Promise.resolve()
await Promise.resolve()
await wrapper.get('[data-testid="skills-table-row-skill-analyst-1"]').trigger('click')
await nextTick()
const panel = wrapper.get('[data-testid="skills-detail-panel"]')
expect(panel.attributes('role')).toBe('dialog')
expect(panel.attributes('aria-modal')).toBe('true')
await panel.trigger('keydown', { key: 'Escape' })
await nextTick()
expect(wrapper.find('[data-testid="skills-detail-panel"]').exists()).toBe(false)
wrapper.unmount()
})
it('opens and closes the drawer without animation when reduced motion is enabled', async () => {
vi.stubGlobal('matchMedia', vi.fn().mockImplementation(() => ({
matches: true,
media: '(prefers-reduced-motion: reduce)',
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})))
const wrapper = mount(SkillsPage, { attachTo: document.body })
await Promise.resolve()
await Promise.resolve()
await wrapper.get('[data-testid="skills-table-row-skill-analyst-1"]').trigger('click')
await nextTick()
expect(wrapper.find('[data-testid="skills-detail-panel"]').exists()).toBe(true)
await wrapper.get('[aria-label="关闭详情滑窗"]').trigger('click')
await nextTick()
expect(wrapper.find('[data-testid="skills-detail-panel"]').exists()).toBe(false)
wrapper.unmount()
})
it('bootstraps builtin skills when the first list is empty', async () => {
mocks.list.mockResolvedValueOnce({ data: [] })
mocks.bootstrapBuiltin.mockResolvedValueOnce({ data: skillFixtures })
const wrapper = mount(SkillsPage)
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
expect(mocks.bootstrapBuiltin).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('Priority Router')
expect(wrapper.find('[data-testid="skills-overview"]').exists()).toBe(false)
})
it('opens the MCP panel inside the skills page when MCP is clicked', async () => {
const wrapper = mount(SkillsPage)
await Promise.resolve()
await Promise.resolve()
expect(wrapper.find('[data-testid="skills-mcp-panel"]').exists()).toBe(false)
await wrapper.get('[data-testid="skills-mcp-button"]').trigger('click')
expect(wrapper.get('[data-testid="skills-mcp-panel"]').text()).toContain('MCP 工具能力包')
expect(wrapper.get('[data-testid="skills-page-title"]').text()).toContain('能力中心')
})
it('submits schedule_planner agent type when creating a skill', async () => {
const wrapper = mount(SkillsPage)
await Promise.resolve()
await Promise.resolve()
await wrapper.get('[data-testid="skills-create-button"]').trigger('click')
await wrapper.get('[data-testid="skill-agent-type-select"]').setValue('schedule_planner')
await wrapper.get('[data-testid="skill-name-input"]').setValue('Planner Skill')
await wrapper.get('[data-testid="skill-instructions-input"]').setValue('Prioritize schedule risks.')
await wrapper.get('[data-testid="skill-save-button"]').trigger('click')
await Promise.resolve()
expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ agent_type: 'schedule_planner' }))
})
it('keeps the create skill modal working after the MCP action is added', async () => {
const wrapper = mount(SkillsPage)
await Promise.resolve()
await Promise.resolve()
await wrapper.get('[data-testid="skills-create-button"]').trigger('click')
expect(wrapper.get('[data-testid="skills-skill-modal"]').text()).toContain('新建技能')
expect(wrapper.find('[data-testid="skills-mcp-panel"]').exists()).toBe(false)
})
it('marks the table region as the internal scroll container', async () => {
const wrapper = mount(SkillsPage)
await Promise.resolve()
await Promise.resolve()
const tableWrap = wrapper.get('[data-testid="skills-table-wrap"]')
expect(tableWrap.classes()).toContain('skills-table-wrap')
expect(wrapper.find('.table-viewport').exists()).toBe(true)
})
})

View File

@@ -1,563 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import * as statsApi from '@/api/stats'
import { Cpu, HardDrive, MemoryStick, Clock, TrendingUp, Tag } from 'lucide-vue-next'
const reloadPage = () => globalThis.location.reload()
import SectionHeader from '@/components/stats/SectionHeader.vue'
import MetricCard from '@/components/stats/MetricCard.vue'
import MiniLineChart from '@/components/stats/MiniLineChart.vue'
import MiniBarChart from '@/components/stats/MiniBarChart.vue'
type DailyPoint = { date: string; count: number }
type HourlyPoint = { hour: number; count: number }
const isLoading = ref(true)
const hasError = ref(false)
// 数据状态
const systemHealth = ref<any>(null)
const conversationStats = ref<any>(null)
const knowledgeStats = ref<any>(null)
const kanbanStats = ref<any>(null)
const communityStats = ref<any>(null)
const personalInsights = ref<any>(null)
function formatUptime(seconds: number) {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const mins = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days}d ${hours}h`
if (hours > 0) return `${hours}h ${mins}m`
return `${mins}m`
}
function formatNumber(num: number): string {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
return num.toString()
}
onMounted(async () => {
try {
// 系统健康不需要认证
const sys = await statsApi.getSystemHealth().catch(() => null)
systemHealth.value = sys?.data || null
// 用户相关数据需要认证
const [conv, know, kanban, community, insights] = await Promise.all([
statsApi.getConversationStats().catch(() => null),
statsApi.getKnowledgeStats().catch(() => null),
statsApi.getKanbanStats().catch(() => null),
statsApi.getCommunityStats().catch(() => null),
statsApi.getPersonalInsights().catch(() => null),
])
conversationStats.value = conv?.data || null
knowledgeStats.value = know?.data || null
kanbanStats.value = kanban?.data || null
communityStats.value = community?.data || null
personalInsights.value = insights?.data || null
} catch (e) {
hasError.value = true
console.error('Failed to load stats:', e)
} finally {
isLoading.value = false
}
})
// 图表数据转换
const convChartData = computed(() =>
conversationStats.value?.daily_conversations?.map((d: DailyPoint) => ({ date: d.date, value: d.count })) || []
)
const knowChartData = computed(() =>
knowledgeStats.value?.daily_new_tags?.map((d: DailyPoint) => ({ date: d.date, value: d.count })) || []
)
const kanbanNewData = computed(() =>
kanbanStats.value?.daily_new_tasks?.map((d: DailyPoint) => d.count) || []
)
const kanbanDoneData = computed(() =>
kanbanStats.value?.daily_completed_tasks?.map((d: DailyPoint) => d.count) || []
)
const communityChartData = computed(() =>
communityStats.value?.daily_posts?.map((d: DailyPoint) => ({ date: d.date, value: d.count })) || []
)
const hourlyActivityData = computed(() =>
personalInsights.value?.hourly_activity?.map((h: HourlyPoint) => h.count) || []
)
const convBarValues = computed(() => convChartData.value.map((d: { date: string; value: number }) => d.value))
const knowBarValues = computed(() => knowChartData.value.map((d: { date: string; value: number }) => d.value))
const communityBarValues = computed(() => communityChartData.value.map((d: { date: string; value: number }) => d.value))
</script>
<template>
<div class="stats-view">
<div class="stats-header">
<h1>// 运行状态</h1>
</div>
<div v-if="isLoading" class="loading-state">
<div class="loading-spinner"></div>
<span>Loading metrics...</span>
</div>
<div v-else-if="hasError" class="error-state">
<span>Failed to load stats</span>
<button @click="reloadPage">Refresh</button>
</div>
<div v-else class="stats-content">
<!-- SYSTEM HEALTH -->
<section class="stats-section">
<SectionHeader title="SYSTEM HEALTH" tag="cyan" />
<div class="metrics-grid">
<MetricCard
:icon="Cpu"
label="CPU Usage"
:value="systemHealth ? systemHealth.cpu_percent + '%' : '--'"
accentColor="var(--accent-cyan)"
/>
<MetricCard
:icon="MemoryStick"
label="Memory"
:value="systemHealth ? systemHealth.memory_percent + '%' : '--'"
accentColor="var(--accent-purple)"
/>
<MetricCard
:icon="HardDrive"
label="Disk"
:value="systemHealth ? systemHealth.disk_percent + '%' : '--'"
accentColor="var(--accent-amber)"
/>
<MetricCard
:icon="Clock"
label="Uptime"
:value="systemHealth ? formatUptime(systemHealth.uptime_seconds) : '--'"
accentColor="var(--accent-green)"
/>
</div>
</section>
<!-- CONVERSATIONS -->
<section class="stats-section">
<SectionHeader title="沟通系统" tag="cyan" />
<div class="stats-metrics-grid-4">
<div class="stat-bar-item">
<div class="stat-bar-label">对话数</div>
<div class="stat-bar-value">{{ formatNumber(conversationStats?.totals?.conversations || 0) }}</div>
<div class="stat-bar-chart">
<MiniBarChart v-if="convChartData.length > 0" :data="convBarValues" color="var(--accent-cyan)" :height="30" />
</div>
</div>
<div class="stat-bar-item">
<div class="stat-bar-label">消息数</div>
<div class="stat-bar-value">{{ formatNumber(conversationStats?.totals?.messages || 0) }}</div>
<div class="stat-bar-chart">
<MiniBarChart v-if="convChartData.length > 0" :data="convBarValues" color="var(--accent-purple)" :height="30" />
</div>
</div>
<div class="stat-bar-item">
<div class="stat-bar-label">输入Token</div>
<div class="stat-bar-value">{{ formatNumber(conversationStats?.totals?.input_tokens || 0) }}</div>
<div class="stat-bar-chart">
<MiniLineChart v-if="convChartData.length > 0" :data="convChartData" color="var(--accent-amber)" :height="30" />
</div>
</div>
<div class="stat-bar-item">
<div class="stat-bar-label">输出Token</div>
<div class="stat-bar-value">{{ formatNumber(conversationStats?.totals?.output_tokens || 0) }}</div>
<div class="stat-bar-chart">
<MiniLineChart v-if="convChartData.length > 0" :data="convChartData" color="var(--accent-green)" :height="30" />
</div>
</div>
</div>
</section>
<!-- KNOWLEDGE -->
<section class="stats-section">
<SectionHeader title="知识库" tag="purple" />
<div class="stats-metrics-row">
<div class="stat-bar-item">
<div class="stat-bar-label">新标签</div>
<div class="stat-bar-value">{{ formatNumber(knowledgeStats?.totals?.new_tags || 0) }}</div>
<div class="stat-bar-chart">
<MiniBarChart v-if="knowChartData.length > 0" :data="knowBarValues" color="var(--accent-purple)" :height="30" />
</div>
</div>
<div class="stat-bar-item">
<div class="stat-bar-label">文档数</div>
<div class="stat-bar-value">{{ formatNumber(knowledgeStats?.totals?.documents || 0) }}</div>
<div class="stat-bar-chart">
<MiniBarChart v-if="knowChartData.length > 0" :data="knowBarValues" color="var(--accent-cyan)" :height="30" />
</div>
</div>
<div class="stat-bar-item">
<div class="stat-bar-label">标签关联</div>
<div class="stat-bar-value">{{ formatNumber(knowledgeStats?.totals?.tag_relations || 0) }}</div>
<div class="stat-bar-chart">
<MiniLineChart v-if="knowChartData.length > 0" :data="knowChartData" color="var(--accent-amber)" :height="30" />
</div>
</div>
</div>
</section>
<!-- KANBAN -->
<section class="stats-section">
<SectionHeader title="任务矩阵" tag="cyan" />
<div class="stats-metrics-row">
<div class="stat-bar-item">
<div class="stat-bar-label">待处理</div>
<div class="stat-bar-value">{{ kanbanStats?.current_pending_tasks || 0 }}</div>
<div class="stat-bar-chart">
<MiniBarChart :data="kanbanNewData" color="var(--accent-amber)" :height="30" />
</div>
</div>
<div class="stat-bar-item">
<div class="stat-bar-label">新建(30)</div>
<div class="stat-bar-value">{{ formatNumber(kanbanStats?.totals?.new_tasks || 0) }}</div>
<div class="stat-bar-chart">
<MiniBarChart :data="kanbanNewData" color="var(--accent-cyan)" :height="30" />
</div>
</div>
<div class="stat-bar-item">
<div class="stat-bar-label">完成(30)</div>
<div class="stat-bar-value">{{ formatNumber(kanbanStats?.totals?.completed_tasks || 0) }}</div>
<div class="stat-bar-chart">
<MiniBarChart :data="kanbanDoneData" color="var(--accent-green)" :height="30" />
</div>
</div>
</div>
</section>
<!-- COMMUNITY -->
<section class="stats-section">
<SectionHeader title="信息交易所" tag="amber" />
<div class="stats-metrics-row">
<div class="stat-bar-item">
<div class="stat-bar-label">帖子数</div>
<div class="stat-bar-value">{{ formatNumber(communityStats?.totals?.posts || 0) }}</div>
<div class="stat-bar-chart">
<MiniBarChart v-if="communityChartData.length > 0" :data="communityBarValues" color="var(--accent-amber)" :height="30" />
</div>
</div>
<div class="stat-bar-item">
<div class="stat-bar-label">回复数</div>
<div class="stat-bar-value">{{ formatNumber(communityStats?.totals?.replies || 0) }}</div>
<div class="stat-bar-chart">
<MiniBarChart v-if="communityChartData.length > 0" :data="communityBarValues" color="var(--accent-purple)" :height="30" />
</div>
</div>
<div class="stat-bar-item">
<div class="stat-bar-label">AI执行</div>
<div class="stat-bar-value">{{ formatNumber(communityStats?.totals?.ai_executions || 0) }}</div>
<div class="stat-bar-chart">
<MiniLineChart v-if="communityChartData.length > 0" :data="communityChartData" color="var(--accent-cyan)" :height="30" />
</div>
</div>
</div>
</section>
<!-- INSIGHTS -->
<section class="stats-section">
<SectionHeader title="个人洞察" tag="cyan" />
<div class="insights-grid" v-if="personalInsights">
<div class="insight-card">
<h4>Hourly Activity</h4>
<MiniBarChart
v-if="hourlyActivityData.length > 0"
:data="hourlyActivityData"
color="var(--accent-cyan)"
:height="80"
:maxBars="24"
/>
<div v-else class="empty-state small">No activity data</div>
</div>
<div class="insight-card">
<h4>Top Tags</h4>
<ul class="tag-list" v-if="personalInsights.top_tags?.length">
<li v-for="tag in personalInsights.top_tags" :key="tag.tag_path">
<Tag :size="12" />
<span class="tag-name">{{ tag.tag_path }}</span>
<span class="tag-count">{{ tag.usage_count }}</span>
</li>
</ul>
<div v-else class="empty-state small">No tags yet</div>
</div>
<div class="insight-card">
<h4>Token Trend</h4>
<div class="token-trend">
<span class="trend-value" :class="personalInsights.token_trend_percent > 0 ? 'up' : 'down'">
<TrendingUp :size="16" />
{{ personalInsights.token_trend_percent }}%
</span>
<span class="trend-label">vs last month</span>
</div>
</div>
</div>
<div v-else class="empty-state">
<span>Login to see personal insights</span>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.stats-view {
height: 100%;
overflow-y: auto;
padding: 24px;
background: var(--bg-void);
}
.stats-header {
margin-bottom: 24px;
}
.stats-header h1 {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
letter-spacing: 0.15em;
color: var(--accent-cyan);
}
.stats-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.stats-metrics-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.stats-metrics-grid-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.stat-bar-item {
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: 16px;
}
.stat-bar-label {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.1em;
color: var(--text-dim);
text-transform: uppercase;
margin-bottom: 4px;
}
.stat-bar-value {
font-family: var(--font-mono);
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.stat-bar-chart {
margin-top: 8px;
min-height: 30px;
}
.stats-section {
background: rgba(10, 15, 26, 0.6);
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
padding: 20px;
margin-bottom: 16px;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
@media (max-width: 1199px) {
.metrics-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 767px) {
.metrics-grid { grid-template-columns: 1fr; }
}
.chart-box {
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: 16px;
}
.chart-label {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.1em;
color: var(--text-dim);
text-transform: uppercase;
margin-bottom: 12px;
}
.bar-chart-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.insights-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
@media (max-width: 1199px) {
.insights-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 767px) {
.insights-grid { grid-template-columns: 1fr; }
}
.insight-card {
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: 16px;
}
.insight-card h4 {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.1em;
color: var(--text-dim);
text-transform: uppercase;
margin-bottom: 12px;
}
.tag-list {
list-style: none;
padding: 0;
margin: 0;
}
.tag-list li {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid var(--border-dim);
font-size: 12px;
}
.tag-list li:last-child {
border-bottom: none;
}
.tag-name {
flex: 1;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-count {
font-family: var(--font-mono);
color: var(--accent-cyan);
font-size: 11px;
}
.token-trend {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 0;
}
.trend-value {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 24px;
font-weight: 600;
}
.trend-value.up {
color: var(--accent-red);
}
.trend-value.down {
color: var(--accent-green);
}
.trend-label {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.loading-state,
.error-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 12px;
gap: 12px;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border-dim);
border-top-color: var(--accent-cyan);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-state.small {
padding: 20px;
}
button {
padding: 8px 16px;
background: var(--accent-cyan-dim);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
transition: all var(--transition-fast);
}
button:hover {
background: rgba(0, 245, 212, 0.2);
box-shadow: var(--glow-cyan);
}
</style>

View File

@@ -1,419 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { todoApi, type Todo } from '@/api/todo'
import { CheckSquare, Plus, Sparkles, Calendar } from 'lucide-vue-next'
import { animate } from 'motion'
// 状态
const selectedDate = ref(new Date().toISOString().slice(0, 10)) // YYYY-MM-DD
const todos = ref<Todo[]>([])
const loading = ref(false)
const generating = ref(false)
const newTitle = ref('')
const isToday = computed(() => selectedDate.value === new Date().toISOString().slice(0, 10))
// 日期快捷切换
function formatDate(date: Date) {
return date.toISOString().slice(0, 10)
}
function goToday() {
selectedDate.value = formatDate(new Date())
}
function goYesterday() {
const d = new Date()
d.setDate(d.getDate() - 1)
selectedDate.value = formatDate(d)
}
function goBeforeYesterday() {
const d = new Date()
d.setDate(d.getDate() - 2)
selectedDate.value = formatDate(d)
}
// 加载数据
async function loadTodos() {
loading.value = true
try {
const res = await todoApi.list(selectedDate.value)
todos.value = res.data.items
} catch (e) {
console.error('加载待办失败', e)
} finally {
loading.value = false
}
}
// 新增
async function addTodo() {
if (!newTitle.value.trim()) return
try {
const res = await todoApi.create(newTitle.value.trim())
todos.value.unshift(res.data)
newTitle.value = ''
} catch (e) {
console.error('创建待办失败', e)
}
}
// 切换完成状态
async function toggleComplete(todo: Todo) {
if (!isToday.value) return
try {
const res = await todoApi.update(todo.id, { is_completed: !todo.is_completed })
const idx = todos.value.findIndex(t => t.id === todo.id)
if (idx !== -1) {
todos.value[idx] = res.data
// 播放动画
const el = document.querySelector(`[data-todo-id="${todo.id}"]`)
if (el) {
animate(el, { opacity: [0.5, 1] }, { duration: 0.3 }).play()
}
}
} catch (e) {
console.error('更新待办失败', e)
}
}
// 删除
async function deleteTodo(id: string) {
if (!isToday.value) return
try {
await todoApi.delete(id)
todos.value = todos.value.filter(t => t.id !== id)
} catch (e) {
console.error('删除待办失败', e)
}
}
// AI 生成
async function aiGenerate() {
generating.value = true
try {
const res = await todoApi.aiGenerate()
todos.value = res.data.items
} catch (e) {
console.error('AI 生成失败', e)
} finally {
generating.value = false
}
}
// 监听日期变化
watch(selectedDate, () => {
loadTodos()
})
onMounted(loadTodos)
</script>
<template>
<div class="todo-view scanlines">
<!-- 背景 -->
<div class="bg-grid"></div>
<div class="bg-glow"></div>
<!-- Header -->
<div class="view-header">
<div class="header-title">
<CheckSquare :size="16" />
<span class="title-bracket">[</span>
<span class="title-text">DAILY TODO</span>
<span class="title-bracket">]</span>
</div>
<div class="header-actions">
<div v-if="isToday" class="ai-btn" @click="aiGenerate" :class="{ loading: generating }">
<Sparkles :size="14" :class="{ 'ai-spin': generating }" />
<span>{{ generating ? '生成中...' : 'AI 规划今日' }}</span>
</div>
</div>
</div>
<!-- 日期导航 -->
<div class="date-nav">
<button class="date-btn" :class="{ active: !isToday }" @click="goBeforeYesterday">前天</button>
<button class="date-btn" @click="goYesterday">昨天</button>
<button class="date-btn primary" :class="{ active: isToday }" @click="goToday">
今天
<Calendar :size="12" />
</button>
</div>
<!-- 主内容 -->
<div class="todo-content">
<!-- 今日新增输入框 -->
<div v-if="isToday" class="add-form">
<input
v-model="newTitle"
class="add-input"
placeholder="输入待办事项,按回车添加..."
@keyup.enter="addTodo"
/>
<button class="add-btn" @click="addTodo">
<Plus :size="16" />
</button>
</div>
<!-- 待办列表 -->
<div class="todo-list">
<div
v-for="todo in todos"
:key="todo.id"
:data-todo-id="todo.id"
class="todo-item"
:class="{ completed: todo.is_completed, 'ai-source': todo.source !== 'manual' }"
>
<button class="check-btn" @click="toggleComplete(todo)" :disabled="!isToday">
<span class="check-box" :class="{ checked: todo.is_completed }">
<span v-if="todo.is_completed" class="check-mark">&#10003;</span>
</span>
</button>
<div class="todo-content">
<span class="todo-title">{{ todo.title }}</span>
<span v-if="todo.source_detail" class="todo-source">{{ todo.source_detail }}</span>
</div>
<button v-if="isToday" class="del-btn" @click="deleteTodo(todo.id)">
<span>&#215;</span>
</button>
</div>
<!-- 空状态 -->
<div v-if="!loading && todos.length === 0" class="empty-state">
<span class="empty-icon">[ ]</span>
<span class="empty-text">{{ isToday ? '今日待办为空,点击上方新增' : '该日无待办记录' }}</span>
</div>
<!-- 加载中 -->
<div v-if="loading" class="loading-state">
<span class="loading-text">LOADING...</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.todo-view {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-void);
position: relative;
overflow: hidden;
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(0,245,212,0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,245,212,0.04) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
.bg-glow {
position: absolute;
inset: 0;
background: radial-gradient(ellipse 80% 60% at 50% 40%, rgba(0,245,212,0.05) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
}
.view-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 24px;
border-bottom: 1px solid var(--border-dim);
background: rgba(5,8,16,0.6);
backdrop-filter: blur(8px);
position: relative;
z-index: 10;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-display);
font-size: 13px;
letter-spacing: 0.2em;
color: var(--text-primary);
}
.title-bracket { color: var(--accent-cyan); opacity: 0.6; }
.ai-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: rgba(249,168,37,0.08);
border: 1px solid rgba(249,168,37,0.3);
border-radius: var(--radius-sm);
color: var(--accent-amber);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.1em;
cursor: pointer;
transition: all var(--transition-fast);
}
.ai-btn:hover { background: rgba(249,168,37,0.15); border-color: var(--accent-amber); box-shadow: 0 0 12px rgba(249,168,37,0.2); }
.ai-btn.loading { opacity: 0.7; cursor: not-allowed; }
.ai-spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* 日期导航 */
.date-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--border-dim);
}
.date-btn {
padding: 5px 14px;
background: transparent;
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.1em;
cursor: pointer;
transition: all var(--transition-fast);
display: flex;
align-items: center;
gap: 6px;
}
.date-btn:hover { border-color: var(--accent-cyan); color: var(--accent-cyan); }
.date-btn.active { border-color: var(--accent-cyan); color: var(--accent-cyan); background: rgba(0,245,212,0.08); }
.date-btn.primary { font-weight: 600; }
/* 内容区 */
.todo-content {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
}
.add-form {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.add-input {
flex: 1;
padding: 10px 16px;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
transition: all var(--transition-fast);
}
.add-input:focus {
outline: none;
border-color: var(--accent-cyan);
box-shadow: 0 0 0 2px rgba(0,245,212,0.1);
}
.add-input::placeholder { color: var(--text-dim); }
.add-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,245,212,0.08);
border: 1px solid rgba(0,245,212,0.3);
border-radius: var(--radius-md);
color: var(--accent-cyan);
cursor: pointer;
transition: all var(--transition-fast);
}
.add-btn:hover { background: rgba(0,245,212,0.15); box-shadow: var(--glow-cyan); }
/* 待办列表 */
.todo-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.todo-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
transition: all var(--transition-fast);
}
.todo-item:hover { border-color: var(--border-mid); }
.todo-item.ai-source { border-left: 2px solid var(--accent-amber); }
.todo-item.completed { opacity: 0.5; }
.todo-item.completed .todo-title { text-decoration: line-through; color: var(--text-dim); }
.check-btn {
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
}
.check-btn:disabled { cursor: default; }
.check-box {
width: 18px;
height: 18px;
border: 1px solid var(--border-mid);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.check-box.checked { background: var(--accent-cyan); border-color: var(--accent-cyan); }
.check-mark { color: var(--bg-void); font-size: 12px; font-weight: bold; }
.todo-content { flex: 1; min-width: 0; }
.todo-title { display: block; font-size: 13px; color: var(--text-primary); font-family: var(--font-mono); }
.todo-source { display: block; font-size: 10px; color: var(--text-dim); margin-top: 3px; font-family: var(--font-mono); }
.del-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
font-size: 18px;
border-radius: 4px;
transition: all var(--transition-fast);
}
.del-btn:hover { color: var(--accent-red); background: rgba(255,71,87,0.1); }
/* 空/加载状态 */
.empty-state, .loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 12px;
}
.empty-icon { font-family: var(--font-mono); font-size: 32px; color: var(--text-dim); opacity: 0.3; }
.empty-text { font-family: var(--font-mono); font-size: 12px; color: var(--text-dim); letter-spacing: 0.1em; }
.loading-text { font-family: var(--font-mono); font-size: 11px; color: var(--accent-cyan); letter-spacing: 0.2em; animation: pulse 1s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
</style>

View File

@@ -116,12 +116,66 @@ body {
50% { transform: translateY(-4px); }
}
@keyframes grid-drift {
0% { transform: translate3d(0, 0, 0); }
50% { transform: translate3d(40px, 20px, 0); }
100% { transform: translate3d(80px, 40px, 0); }
}
@keyframes grid-scan-sweep {
0% {
opacity: 0;
transform: translate3d(-32%, 0, 0);
}
9% {
opacity: 0.85;
}
28% {
opacity: 0.42;
}
48% {
opacity: 0.22;
}
66% {
opacity: 0;
}
100% {
opacity: 0;
transform: translate3d(150%, 0, 0);
}
}
@keyframes grid-vertical-scan {
0% {
opacity: 0;
transform: translate3d(0, -32%, 0);
}
10% {
opacity: 0.78;
}
32% {
opacity: 0.38;
}
52% {
opacity: 0.18;
}
66% {
opacity: 0;
}
100% {
opacity: 0;
transform: translate3d(0, 150%, 0);
}
}
/* ── Grid background ── */
.grid-bg {
background-image:
linear-gradient(rgba(0, 245, 212, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 245, 212, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
linear-gradient(90deg, rgba(0, 245, 212, 0.03) 1px, transparent 1px),
radial-gradient(circle at 50% 18%, rgba(0, 245, 212, 0.08), transparent 34%);
background-size: 40px 40px, 40px 40px, 100% 100%;
background-position: 0 0, 0 0, center;
}
/* ── Button base ── */

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -1,5 +1,5 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"extends": "./node_modules/@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
@@ -14,5 +14,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/**/*.d.ts"]
}