Compare commits

..

5 Commits

Author SHA1 Message Date
q792602257 abcae3d963 Web优化 3 2026-06-12 14:44:39 +08:00
q792602257 61a1a4d368 Web优化 2 2026-06-12 14:36:41 +08:00
q792602257 7e9df04bf8 Web优化 1 2026-06-12 14:01:55 +08:00
q792602257 17ca339c66 ESLint fix 2026-06-12 13:41:23 +08:00
q792602257 522e88a6a2 前端优化 2026-06-12 13:36:39 +08:00
43 changed files with 782 additions and 938 deletions
+1
View File
@@ -548,6 +548,7 @@ func (h *Handler) sessionWS(w http.ResponseWriter, r *http.Request) {
conn, err := ws.Accept(w, r, &ws.AcceptOptions{
InsecureSkipVerify: false,
Subprotocols: []string{"acp-v1"},
})
if err != nil {
return
+14 -4
View File
@@ -100,16 +100,26 @@ func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) {
// because RFC 6750 specifies the literal "Bearer" scheme name and being
// strict here keeps the parsing surface small.
//
// As a fallback, ?token= query parameter is accepted for endpoints that
// cannot send custom headers (notably browser-native WebSocket connections).
// Callers must keep token lifetimes short so the URL-borne token is not a
// long-lived secret should it be logged by intermediaries.
// For browser-native WebSocket connections, custom HTTP headers cannot be
// sent during the handshake. Two fallbacks are supported, in order of
// preference:
// 1. Sec-WebSocket-Protocol: the client sends a subprotocol entry
// "acw-token.<tok>". The token is extracted from the header, which is
// not logged by proxies/servers as readily as a URL query parameter.
// 2. ?token= query parameter (legacy): kept for backward compatibility.
func extractBearer(r *http.Request) string {
h := r.Header.Get("Authorization")
const prefix = "Bearer "
if strings.HasPrefix(h, prefix) {
return strings.TrimSpace(h[len(prefix):])
}
const wsTokenPrefix = "acw-token."
for _, p := range strings.Split(r.Header.Get("Sec-WebSocket-Protocol"), ",") {
p = strings.TrimSpace(p)
if strings.HasPrefix(p, wsTokenPrefix) {
return p[len(wsTokenPrefix):]
}
}
if q := r.URL.Query().Get("token"); q != "" {
return q
}
@@ -77,3 +77,18 @@ func TestAuth_BadTokenReturns401(t *testing.T) {
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusUnauthorized, rec.Code)
}
func TestAuth_WebSocketTokenFromSubprotocol(t *testing.T) {
uid := uuid.New()
mw := Auth(fakeAuth{user: uid}, AuthOptions{})
h := mw(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
got, ok := UserIDFromContext(r.Context())
require.True(t, ok)
require.Equal(t, uid, got)
}))
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("Sec-WebSocket-Protocol", "acp-v1, acw-token.ws-tok")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
}
+3 -3
View File
@@ -12,7 +12,8 @@
"test:watch": "vitest",
"lint": "eslint .",
"format": "prettier -w .",
"typecheck": "vue-tsc --noEmit"
"typecheck": "vue-tsc --noEmit",
"analyze": "vite-bundle-visualizer"
},
"keywords": [],
"author": "",
@@ -34,16 +35,15 @@
"@types/node": "^25.6.0",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/test-utils": "^2.4.9",
"autoprefixer": "^10.5.0",
"eslint": "^10.2.1",
"eslint-plugin-vue": "^10.9.0",
"happy-dom": "^20.9.0",
"postcss": "^8.5.12",
"prettier": "^3.8.3",
"tailwindcss": "^4.2.4",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.1",
"vite": "^8.0.10",
"vite-bundle-visualizer": "^1.2.1",
"vitest": "^4.1.5",
"vue-eslint-parser": "^10.4.0",
"vue-tsc": "^3.2.7"
+150 -77
View File
@@ -48,9 +48,6 @@ importers:
'@vue/test-utils':
specifier: ^2.4.9
version: 2.4.9(@vue/compiler-dom@3.5.33)(@vue/server-renderer@3.5.33(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3))
autoprefixer:
specifier: ^10.5.0
version: 10.5.0(postcss@8.5.12)
eslint:
specifier: ^10.2.1
version: 10.2.1(jiti@2.6.1)
@@ -60,9 +57,6 @@ importers:
happy-dom:
specifier: ^20.9.0
version: 20.9.0
postcss:
specifier: ^8.5.12
version: 8.5.12
prettier:
specifier: ^3.8.3
version: 3.8.3
@@ -78,6 +72,9 @@ importers:
vite:
specifier: ^8.0.10
version: 8.0.10(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.3)
vite-bundle-visualizer:
specifier: ^1.2.1
version: 1.2.1
vitest:
specifier: ^4.1.5
version: 4.1.5(@types/node@25.6.0)(happy-dom@20.9.0)(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.3))
@@ -701,13 +698,6 @@ packages:
async-validator@4.2.5:
resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
autoprefixer@10.5.0:
resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==}
engines: {node: ^10 || ^12 || >=14}
hasBin: true
peerDependencies:
postcss: ^8.1.0
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -715,11 +705,6 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
baseline-browser-mapping@2.10.24:
resolution: {integrity: sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==}
engines: {node: '>=6.0.0'}
hasBin: true
birpc@2.9.0:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
@@ -733,13 +718,9 @@ packages:
resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
engines: {node: 18 || 20 || >=22}
browserslist@4.28.2:
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
caniuse-lite@1.0.30001791:
resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
@@ -749,6 +730,10 @@ packages:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -814,6 +799,10 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
define-lazy-prop@2.0.0:
resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
engines: {node: '>=8'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -826,9 +815,6 @@ packages:
engines: {node: '>=14'}
hasBin: true
electron-to-chromium@1.5.344:
resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -967,14 +953,15 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
glob-parent@6.0.2:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
@@ -1006,6 +993,13 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
import-from-esm@1.3.4:
resolution: {integrity: sha512-7EyUlPFC0HOlBDpUFGfYstsU7XHxZJKAAMzCT8wZ0hMW7b+hG51LIKTDcsgtz8Pu6YC0HqRVbX+rVUtsGMUKvg==}
engines: {node: '>=16.20'}
import-meta-resolve@4.2.0:
resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==}
imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
@@ -1013,6 +1007,11 @@ packages:
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
is-docker@2.2.1:
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
engines: {node: '>=8'}
hasBin: true
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -1029,6 +1028,10 @@ packages:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
engines: {node: '>=18'}
is-wsl@2.2.0:
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
engines: {node: '>=8'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -1220,9 +1223,6 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
node-releases@2.0.38:
resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==}
nopt@7.2.1:
resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -1234,6 +1234,10 @@ packages:
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
open@8.4.2:
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
engines: {node: '>=12'}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -1299,9 +1303,6 @@ packages:
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
engines: {node: '>=4'}
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.5.12:
resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==}
engines: {node: ^10 || ^12 || >=14}
@@ -1333,6 +1334,10 @@ packages:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
@@ -1341,6 +1346,19 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
rollup-plugin-visualizer@5.14.0:
resolution: {integrity: sha512-VlDXneTDaKsHIw8yzJAFWtrzguoJ/LnQ+lMpoVfYJ3jJF4Ihe5oYLAqLklIK/35lgUY+1yEzCkHyZ1j4A5w5fA==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
rolldown: 1.x
rollup: 2.x || 3.x || 4.x
peerDependenciesMeta:
rolldown:
optional: true
rollup:
optional: true
scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
@@ -1375,6 +1393,10 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
source-map@0.7.6:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
@@ -1427,6 +1449,10 @@ packages:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
tmp@0.2.7:
resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==}
engines: {node: '>=14.14'}
treemate@0.3.11:
resolution: {integrity: sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==}
@@ -1472,12 +1498,6 @@ packages:
resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==}
engines: {node: ^20.19.0 || >=22.12.0}
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@@ -1489,6 +1509,11 @@ packages:
peerDependencies:
vue: ^3.0.11
vite-bundle-visualizer@1.2.1:
resolution: {integrity: sha512-cwz/Pg6+95YbgIDp+RPwEToc4TKxfsFWSG/tsl2DSZd9YZicUag1tQXjJ5xcL7ydvEoaC2FOZeaXOU60t9BRXw==}
engines: {node: ^18.19.0 || >=20.6.0}
hasBin: true
vite@8.0.10:
resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1669,11 +1694,23 @@ packages:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
engines: {node: '>=12'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yaml@2.8.3:
resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==}
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -2296,21 +2333,10 @@ snapshots:
async-validator@4.2.5: {}
autoprefixer@10.5.0(postcss@8.5.12):
dependencies:
browserslist: 4.28.2
caniuse-lite: 1.0.30001791
fraction.js: 5.3.4
picocolors: 1.1.1
postcss: 8.5.12
postcss-value-parser: 4.2.0
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
baseline-browser-mapping@2.10.24: {}
birpc@2.9.0: {}
boolbase@1.0.0: {}
@@ -2323,15 +2349,7 @@ snapshots:
dependencies:
balanced-match: 4.0.4
browserslist@4.28.2:
dependencies:
baseline-browser-mapping: 2.10.24
caniuse-lite: 1.0.30001791
electron-to-chromium: 1.5.344
node-releases: 2.0.38
update-browserslist-db: 1.2.3(browserslist@4.28.2)
caniuse-lite@1.0.30001791: {}
cac@6.7.14: {}
chai@6.2.2: {}
@@ -2339,6 +2357,12 @@ snapshots:
dependencies:
readdirp: 5.0.0
cliui@8.0.1:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -2391,6 +2415,8 @@ snapshots:
deep-is@0.1.4: {}
define-lazy-prop@2.0.0: {}
detect-libc@2.1.2: {}
eastasianwidth@0.2.0: {}
@@ -2402,8 +2428,6 @@ snapshots:
minimatch: 9.0.9
semver: 7.7.4
electron-to-chromium@1.5.344: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
@@ -2545,11 +2569,11 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
fraction.js@5.3.4: {}
fsevents@2.3.3:
optional: true
get-caller-file@2.0.5: {}
glob-parent@6.0.2:
dependencies:
is-glob: 4.0.3
@@ -2585,10 +2609,21 @@ snapshots:
ignore@7.0.5: {}
import-from-esm@1.3.4:
dependencies:
debug: 4.4.3
import-meta-resolve: 4.2.0
transitivePeerDependencies:
- supports-color
import-meta-resolve@4.2.0: {}
imurmurhash@0.1.4: {}
ini@1.3.8: {}
is-docker@2.2.1: {}
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
@@ -2599,6 +2634,10 @@ snapshots:
is-what@5.5.0: {}
is-wsl@2.2.0:
dependencies:
is-docker: 2.2.1
isexe@2.0.0: {}
jackspeak@3.4.3:
@@ -2775,8 +2814,6 @@ snapshots:
natural-compare@1.4.0: {}
node-releases@2.0.38: {}
nopt@7.2.1:
dependencies:
abbrev: 2.0.0
@@ -2787,6 +2824,12 @@ snapshots:
obug@2.1.1: {}
open@8.4.2:
dependencies:
define-lazy-prop: 2.0.0
is-docker: 2.2.1
is-wsl: 2.2.0
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -2851,8 +2894,6 @@ snapshots:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss-value-parser@4.2.0: {}
postcss@8.5.12:
dependencies:
nanoid: 3.3.11
@@ -2873,6 +2914,8 @@ snapshots:
readdirp@5.0.0: {}
require-directory@2.1.1: {}
rfdc@1.4.1: {}
rolldown@1.0.0-rc.17:
@@ -2896,6 +2939,13 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17
rollup-plugin-visualizer@5.14.0:
dependencies:
open: 8.4.2
picomatch: 4.0.4
source-map: 0.7.6
yargs: 17.7.2
scule@1.3.0: {}
seemly@0.3.10: {}
@@ -2916,6 +2966,8 @@ snapshots:
source-map-js@1.2.1: {}
source-map@0.7.6: {}
speakingurl@14.0.1: {}
stackback@0.0.2: {}
@@ -2961,6 +3013,8 @@ snapshots:
tinyrainbow@3.1.0: {}
tmp@0.2.7: {}
treemate@0.3.11: {}
ts-api-utils@2.5.0(typescript@6.0.3):
@@ -3004,12 +3058,6 @@ snapshots:
picomatch: 4.0.4
webpack-virtual-modules: 0.6.2
update-browserslist-db@1.2.3(browserslist@4.28.2):
dependencies:
browserslist: 4.28.2
escalade: 3.2.0
picocolors: 1.1.1
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
@@ -3021,6 +3069,17 @@ snapshots:
evtd: 0.2.4
vue: 3.5.33(typescript@6.0.3)
vite-bundle-visualizer@1.2.1:
dependencies:
cac: 6.7.14
import-from-esm: 1.3.4
rollup-plugin-visualizer: 5.14.0
tmp: 0.2.7
transitivePeerDependencies:
- rolldown
- rollup
- supports-color
vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.3):
dependencies:
lightningcss: 1.32.0
@@ -3165,6 +3224,20 @@ snapshots:
xml-name-validator@4.0.0: {}
y18n@5.0.8: {}
yaml@2.8.3: {}
yargs-parser@21.1.1: {}
yargs@17.7.2:
dependencies:
cliui: 8.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
yocto-queue@0.1.0: {}
+2 -4
View File
@@ -1,5 +1,5 @@
<template>
<NConfigProvider :theme="ui.effectiveDark ? darkTheme : null">
<NConfigProvider>
<NMessageProvider>
<NDialogProvider>
<RouterView />
@@ -9,7 +9,5 @@
</template>
<script setup lang="ts">
import { NConfigProvider, NMessageProvider, NDialogProvider, darkTheme } from 'naive-ui'
import { useUIStore } from '@/stores/ui'
const ui = useUIStore()
import { NConfigProvider, NMessageProvider, NDialogProvider } from 'naive-ui'
</script>
+11 -12
View File
@@ -146,10 +146,8 @@ const enc = (v: string) => encodeURIComponent(v)
export const acpApi = {
agentKinds: {
list: () =>
request<(AgentKindAdmin | AgentKindPublic)[]>('/api/v1/acp/agent-kinds'),
get: (id: string) =>
request<AgentKindAdmin>(`/api/v1/acp/agent-kinds/${enc(id)}`),
list: () => request<(AgentKindAdmin | AgentKindPublic)[]>('/api/v1/acp/agent-kinds'),
get: (id: string) => request<AgentKindAdmin>(`/api/v1/acp/agent-kinds/${enc(id)}`),
create: (req: CreateAgentKindReq) =>
request<AgentKindAdmin>('/api/v1/acp/agent-kinds', { method: 'POST', body: req }),
update: (id: string, req: UpdateAgentKindReq) =>
@@ -180,8 +178,7 @@ export const acpApi = {
const qs = q.toString()
return request<AcpSession[]>('/api/v1/acp/sessions' + (qs ? '?' + qs : ''))
},
get: (id: string) =>
request<AcpSession>(`/api/v1/acp/sessions/${enc(id)}`),
get: (id: string) => request<AcpSession>(`/api/v1/acp/sessions/${enc(id)}`),
create: (req: CreateSessionReq) =>
request<AcpSession>('/api/v1/acp/sessions', { method: 'POST', body: req }),
terminate: (id: string) =>
@@ -205,13 +202,15 @@ export const acpApi = {
// openSessionWS opens a native WebSocket to /sessions/{id}/ws.
//
// Auth: browser WebSocket cannot send custom headers, so the token is sent
// via the ?token= query parameter. The backend auth middleware accepts it
// as a fallback to Authorization. Keep token lifetimes short.
// via the Sec-WebSocket-Protocol handshake as "acw-token.<token>". This is
// safer than a URL query parameter (which may be logged by proxies). The
// application subprotocol "acp-v1" is also advertised and selected by the
// server when supported.
export function openSessionWS(sessionId: string, since: number, token: string): WebSocket {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const host = window.location.host
const url =
`${proto}://${host}/api/v1/acp/sessions/${enc(sessionId)}` +
`/ws?since=${since}&token=${enc(token)}`
return new WebSocket(url)
const url = `${proto}://${host}/api/v1/acp/sessions/${enc(sessionId)}/ws?since=${since}`
const protocols = ['acp-v1']
if (token) protocols.push(`acw-token.${token}`)
return new WebSocket(url, protocols)
}
+6 -1
View File
@@ -34,11 +34,13 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS)
let onAbort: (() => void) | undefined
if (opts.signal) {
if (opts.signal.aborted) {
controller.abort()
} else {
opts.signal.addEventListener('abort', () => controller.abort(), { once: true })
onAbort = () => controller.abort()
opts.signal.addEventListener('abort', onAbort, { once: true })
}
}
@@ -78,6 +80,9 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
return data as T
} finally {
clearTimeout(timer)
if (opts.signal && onAbort) {
opts.signal.removeEventListener('abort', onAbort)
}
}
}
@@ -13,6 +13,7 @@ import { ref, computed, onMounted, watch } from 'vue'
import { NInput, NButton, NSelect, NSpin } from 'naive-ui'
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
import { acpApi, type AgentConfigFile, type AgentClientType } from '@/api/acp'
import { useConfirm } from '@/composables/useConfirm'
const props = defineProps<{
kindId: string
@@ -22,6 +23,7 @@ const props = defineProps<{
const files = ref<AgentConfigFile[]>([])
const loading = ref(false)
const error = ref('')
const confirm = useConfirm()
// ===== 客户端规范配置文件 =====
@@ -270,7 +272,8 @@ async function upsert(relPath: string, content: string) {
}
async function removeFile(relPath: string) {
if (!confirm(`删除配置文件 ${relPath}`)) return
const ok = await confirm(`删除配置文件 ${relPath}`)
if (!ok) return
error.value = ''
try {
await acpApi.configFiles.remove(props.kindId, relPath)
@@ -310,22 +313,18 @@ watch(() => props.clientType, loadForm)
</p>
</div>
<p
v-if="error"
class="text-sm text-red-600"
>
<p v-if="error" class="text-sm text-red-600">
{{ error }}
</p>
<NSpin :show="loading">
<!-- 常用配置表单已知客户端类型 -->
<div
v-if="canonicalPath"
class="border rounded p-4 space-y-3"
>
<div v-if="canonicalPath" class="border rounded p-4 space-y-3">
<div class="text-sm font-medium">
常用配置
<span class="text-xs text-gray-500 font-normal">写入 {{ canonicalPath }}其余字段保留</span>
<span class="text-xs text-gray-500 font-normal"
>写入 {{ canonicalPath }}其余字段保留</span
>
</div>
<div>
<label class="block text-sm mb-1">Model</label>
@@ -343,10 +342,7 @@ watch(() => props.clientType, loadForm)
<template v-if="clientType === 'claude_code'">
<div>
<label class="block text-sm mb-1">permissions.defaultMode</label>
<NInput
v-model:value="form.defaultMode"
placeholder="acceptEdits"
/>
<NInput v-model:value="form.defaultMode" placeholder="acceptEdits" />
</div>
<div class="grid grid-cols-3 gap-2">
<div>
@@ -390,32 +386,17 @@ watch(() => props.clientType, loadForm)
</div>
<div>
<label class="block text-sm mb-1">sandbox_mode</label>
<NSelect
v-model:value="form.sandboxMode"
:options="sandboxModeOptions"
clearable
/>
<NSelect v-model:value="form.sandboxMode" :options="sandboxModeOptions" clearable />
</div>
</div>
</template>
<NButton
size="small"
type="primary"
@click="saveForm"
>
保存常用配置
</NButton>
<NButton size="small" type="primary" @click="saveForm"> 保存常用配置 </NButton>
</div>
<!-- 高级模式文件列表 + 原始编辑 -->
<div class="border rounded p-4 space-y-3 mt-4">
<div class="text-sm font-medium">全部配置文件高级模式</div>
<div
v-if="files.length === 0"
class="text-sm text-gray-500"
>
暂无配置文件
</div>
<div v-if="files.length === 0" class="text-sm text-gray-500">暂无配置文件</div>
<div
v-for="f in files"
:key="f.rel_path"
@@ -424,31 +405,15 @@ watch(() => props.clientType, loadForm)
<code>{{ f.rel_path }}</code>
<div class="flex gap-2 items-center">
<span class="text-xs text-gray-400">{{ f.updated_at }}</span>
<NButton
size="tiny"
@click="startEdit(f)"
>
编辑
</NButton>
<NButton
size="tiny"
quaternary
type="error"
@click="removeFile(f.rel_path)"
>
<NButton size="tiny" @click="startEdit(f)"> 编辑 </NButton>
<NButton size="tiny" quaternary type="error" @click="removeFile(f.rel_path)">
删除
</NButton>
</div>
</div>
<div class="flex gap-2 flex-wrap">
<NButton
size="small"
dashed
@click="startNew()"
>
+ 新建文件
</NButton>
<NButton size="small" dashed @click="startNew()"> + 新建文件 </NButton>
<NButton
v-for="t in templates.filter((x) => !files.some((f) => f.rel_path === x.rel_path))"
:key="t.rel_path"
@@ -460,16 +425,12 @@ watch(() => props.clientType, loadForm)
</NButton>
</div>
<div
v-if="editingIsNew || editingPath !== null"
class="space-y-2 pt-2"
>
<div v-if="editingIsNew || editingPath !== null" class="space-y-2 pt-2">
<div v-if="editingIsNew">
<label class="block text-sm mb-1">文件路径相对受管 home .claude/settings.json</label>
<NInput
v-model:value="newRelPath"
placeholder=".claude/settings.json"
/>
<label class="block text-sm mb-1"
>文件路径相对受管 home .claude/settings.json</label
>
<NInput v-model:value="newRelPath" placeholder=".claude/settings.json" />
</div>
<div v-else>
<code class="text-sm">{{ editingPath }}</code>
@@ -480,27 +441,12 @@ watch(() => props.clientType, loadForm)
class="font-mono"
:autosize="{ minRows: 8, maxRows: 24 }"
/>
<p
v-if="syntaxError"
class="text-xs text-red-600"
>
语法错误{{ syntaxError }}
</p>
<p v-if="syntaxError" class="text-xs text-red-600">语法错误{{ syntaxError }}</p>
<div class="flex gap-2">
<NButton
size="small"
type="primary"
:disabled="!!syntaxError"
@click="saveEdit"
>
<NButton size="small" type="primary" :disabled="!!syntaxError" @click="saveEdit">
保存
</NButton>
<NButton
size="small"
@click="cancelEdit"
>
取消
</NButton>
<NButton size="small" @click="cancelEdit"> 取消 </NButton>
</div>
</div>
</div>
+45 -12
View File
@@ -74,22 +74,38 @@ function fsWriteSize(): number {
<template>
<div class="py-1">
<div v-if="eventType === 'user_prompt'" class="flex gap-2">
<div
v-if="eventType === 'user_prompt'"
class="flex gap-2"
>
<span class="text-xs text-gray-500 mt-0.5 w-12">user</span>
<div class="flex-1 px-2 py-1 bg-blue-50 rounded text-sm">{{ userPromptText() }}</div>
<div class="flex-1 px-2 py-1 bg-blue-50 rounded text-sm">
{{ userPromptText() }}
</div>
</div>
<div v-else-if="eventType === 'user_cancel'" class="flex gap-2 text-xs text-gray-500">
<div
v-else-if="eventType === 'user_cancel'"
class="flex gap-2 text-xs text-gray-500"
>
<span class="w-12">user</span>
<span>· cancel</span>
</div>
<div v-else-if="eventType === 'agent_text'" class="flex gap-2">
<div
v-else-if="eventType === 'agent_text'"
class="flex gap-2"
>
<span class="text-xs text-gray-500 mt-0.5 w-12">agent</span>
<div class="flex-1 text-sm whitespace-pre-wrap">{{ chunkText() }}</div>
<div class="flex-1 text-sm whitespace-pre-wrap">
{{ chunkText() }}
</div>
</div>
<div v-else-if="eventType === 'agent_thought'" class="flex gap-2">
<div
v-else-if="eventType === 'agent_thought'"
class="flex gap-2"
>
<span class="text-xs text-gray-500 mt-0.5 w-12">thought</span>
<div class="flex-1">
<button
@@ -107,21 +123,30 @@ function fsWriteSize(): number {
</div>
</div>
<div v-else-if="eventType === 'tool_fs_read'" class="flex gap-2 text-sm">
<div
v-else-if="eventType === 'tool_fs_read'"
class="flex gap-2 text-sm"
>
<span class="text-xs text-gray-500 mt-0.5 w-12">tool</span>
<div class="flex-1 px-2 py-0.5 bg-yellow-50 rounded">
read <code class="text-xs">{{ fsPath() }}</code>
</div>
</div>
<div v-else-if="eventType === 'tool_fs_write'" class="flex gap-2 text-sm">
<div
v-else-if="eventType === 'tool_fs_write'"
class="flex gap-2 text-sm"
>
<span class="text-xs text-gray-500 mt-0.5 w-12">tool</span>
<div class="flex-1 px-2 py-0.5 bg-orange-50 rounded">
write <code class="text-xs">{{ fsPath() }}</code> ({{ fsWriteSize() }} bytes)
</div>
</div>
<div v-else-if="eventType === 'tool_permission'" class="flex gap-2 text-sm">
<div
v-else-if="eventType === 'tool_permission'"
class="flex gap-2 text-sm"
>
<span class="text-xs text-gray-500 mt-0.5 w-12">perm</span>
<div class="flex-1 px-2 py-0.5 bg-purple-50 rounded">
permission requested (auto-rejected)
@@ -135,12 +160,20 @@ function fsWriteSize(): number {
session terminated
</div>
<div v-else-if="eventType === 'client_error'" class="flex gap-2 text-sm text-red-600">
<div
v-else-if="eventType === 'client_error'"
class="flex gap-2 text-sm text-red-600"
>
<span class="text-xs mt-0.5 w-12">error</span>
<div class="flex-1">{{ (payload as { message?: string }).message }}</div>
<div class="flex-1">
{{ (payload as { message?: string }).message }}
</div>
</div>
<div v-else class="flex gap-2 text-xs text-gray-400">
<div
v-else
class="flex gap-2 text-xs text-gray-400"
>
<span class="w-12">{{ eventType }}</span>
<code class="flex-1 truncate">{{ event.method }}</code>
</div>
+8 -4
View File
@@ -57,11 +57,12 @@ const allowlistText = computed({
})
interface EnvRow {
id: string
k: string
v: string
}
const envEntries = ref<EnvRow[]>(
Object.entries(local.value.env).map(([k, v]) => ({ k, v })),
Object.entries(local.value.env).map(([k, v]) => ({ id: crypto.randomUUID(), k, v })),
)
watch(
@@ -77,7 +78,7 @@ watch(
)
function addEnv() {
envEntries.value.push({ k: '', v: '' })
envEntries.value.push({ id: crypto.randomUUID(), k: '', v: '' })
}
function removeEnv(idx: number) {
envEntries.value.splice(idx, 1)
@@ -94,6 +95,7 @@ const mcpTypeOptions = [
]
interface McpRow {
id: string
name: string
type: 'stdio' | 'http' | 'sse'
command: string
@@ -123,6 +125,7 @@ function parseKV(s: string): Record<string, string> | undefined {
function specsToRows(specs: McpServerSpec[]): McpRow[] {
return specs.map((s) => ({
id: crypto.randomUUID(),
name: s.name,
type: s.type,
command: s.command ?? '',
@@ -173,6 +176,7 @@ watch(
function addMcpServer() {
mcpRows.value.push({
id: crypto.randomUUID(),
name: '',
type: 'http',
command: '',
@@ -258,7 +262,7 @@ function removeMcpServer(idx: number) {
</p>
<div
v-for="(e, idx) in envEntries"
:key="idx"
:key="e.id"
class="flex gap-2 mb-2"
>
<NInput
@@ -305,7 +309,7 @@ function removeMcpServer(idx: number) {
</label>
<div
v-for="(m, idx) in mcpRows"
:key="idx"
:key="m.id"
class="border rounded p-3 mb-2 space-y-2"
>
<div class="flex gap-2">
+4 -1
View File
@@ -35,7 +35,10 @@ function onKeydown(ev: KeyboardEvent) {
</script>
<template>
<form class="flex gap-2 items-end" @submit.prevent="submit">
<form
class="flex gap-2 items-end"
@submit.prevent="submit"
>
<textarea
v-model="text"
class="flex-1 resize-none border rounded px-2 py-1 text-sm"
+5 -2
View File
@@ -17,6 +17,7 @@ class MockWebSocket {
}
url: string
protocols: string[] | undefined
readyState = 0
onopen: ((ev: unknown) => void) | null = null
onmessage: ((ev: { data: string }) => void) | null = null
@@ -24,8 +25,9 @@ class MockWebSocket {
onclose: ((ev: unknown) => void) | null = null
sent: string[] = []
constructor(url: string) {
constructor(url: string, protocols?: string[]) {
this.url = url
this.protocols = protocols
MockWebSocket.instances.push(this)
queueMicrotask(() => {
this.readyState = 1
@@ -66,7 +68,8 @@ describe('useAcpStream', () => {
const ws = MockWebSocket.last()
expect(ws.url).toContain('/sessions/sess-1/ws')
expect(ws.url).toContain('since=0')
expect(ws.url).toContain('token=jwt-token')
expect(ws.url).not.toContain('token=')
expect(ws.protocols).toEqual(['acp-v1', 'acw-token.jwt-token'])
expect(stream.status.value).toBe('streaming')
stream.close()
})
+8 -6
View File
@@ -17,8 +17,8 @@ export interface UseAcpStreamReturn {
events: Ref<AcpEvent[]>
pendingPermissions: Ref<PermissionRequest[]>
errorMsg: Ref<string | null>
prompt: (text: string, agentSessionID: string) => void
cancel: (agentSessionID: string) => void
prompt: (text: string, agentSessionID: string) => boolean
cancel: (agentSessionID: string) => boolean
reconnect: () => void
close: () => void
}
@@ -181,21 +181,23 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn {
}
function send(method: string, params: Record<string, unknown>) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
const id = 'client-' + crypto.randomUUID()
if (!ws || ws.readyState !== WebSocket.OPEN) return false
const uuid = crypto.randomUUID?.() ?? Math.random().toString(36).slice(2)
const id = 'client-' + uuid
const msg = { jsonrpc: '2.0', id, method, params }
ws.send(JSON.stringify(msg))
return true
}
function prompt(text: string, agentSessionID: string) {
send('session/prompt', {
return send('session/prompt', {
sessionId: agentSessionID,
prompt: [{ type: 'text', text }],
})
}
function cancel(agentSessionID: string) {
send('session/cancel', { sessionId: agentSessionID })
return send('session/cancel', { sessionId: agentSessionID })
}
function reconnect() {
+18
View File
@@ -0,0 +1,18 @@
import { useMessage } from 'naive-ui'
export function useAlert() {
let message: ReturnType<typeof useMessage> | undefined
try {
message = useMessage()
} catch {
// No outer <n-message-provider />, fall back to window.alert in browser.
}
return (content: string) => {
if (message) {
message.error(content)
} else if (typeof window !== 'undefined') {
window.alert(content)
}
}
}
+30
View File
@@ -0,0 +1,30 @@
import { useDialog } from 'naive-ui'
export function useConfirm() {
let dialog: ReturnType<typeof useDialog> | undefined
try {
dialog = useDialog()
} catch {
// No outer <n-dialog-provider />, fall back to window.confirm in browser.
}
return (content: string, title = '确认') => {
if (!dialog) {
if (typeof window !== 'undefined') {
return Promise.resolve(window.confirm(content))
}
return Promise.resolve(false)
}
return new Promise<boolean>((resolve) => {
dialog!.warning({
title,
content,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: () => resolve(true),
onNegativeClick: () => resolve(false),
onClose: () => resolve(false),
})
})
}
}
+27 -18
View File
@@ -74,31 +74,40 @@ export function useConversationSession() {
if (!currentConversation.value) return
if (streamingMessageId.value !== null) return
const conv = currentConversation.value
const resp = await chatApi.sendMessage(conv.id, {
content,
attachment_ids: attachmentIds,
})
const now = new Date().toISOString()
messages.value.push({
id: resp.user_message_id,
const userMsg: ChatMessage = {
id: -1,
conversation_id: conv.id,
role: 'user',
content,
status: 'ok',
created_at: now,
updated_at: now,
})
messages.value.push({
id: resp.assistant_message_id,
conversation_id: conv.id,
role: 'assistant',
content: '',
status: 'pending',
created_at: now,
updated_at: now,
})
streamingMessageId.value = resp.assistant_message_id
attachStream(resp.stream_url, resp.user_message_id)
}
messages.value.push(userMsg)
try {
const resp = await chatApi.sendMessage(conv.id, {
content,
attachment_ids: attachmentIds,
})
userMsg.id = resp.user_message_id
userMsg.status = 'ok'
messages.value.push({
id: resp.assistant_message_id,
conversation_id: conv.id,
role: 'assistant',
content: '',
status: 'pending',
created_at: now,
updated_at: now,
})
streamingMessageId.value = resp.assistant_message_id
attachStream(resp.stream_url, resp.user_message_id)
} catch (e) {
userMsg.status = 'error'
userMsg.error_message = e instanceof Error ? e.message : 'send failed'
throw e
}
}
async function cancelMessage() {
+16 -3
View File
@@ -28,8 +28,10 @@ export const useUIStore = defineStore('ui', () => {
? window.matchMedia('(prefers-color-scheme: dark)')
: null
const systemDark = ref(mediaQuery ? mediaQuery.matches : false)
let mediaListener: ((e: MediaQueryListEvent) => void) | null = null
if (mediaQuery) {
mediaQuery.addEventListener('change', (e) => (systemDark.value = e.matches))
mediaListener = (e: MediaQueryListEvent) => (systemDark.value = e.matches)
mediaQuery.addEventListener('change', mediaListener)
}
const effectiveDark = computed(() =>
@@ -38,10 +40,21 @@ export const useUIStore = defineStore('ui', () => {
watch(
effectiveDark,
(v) => document.documentElement.classList.toggle('dark', v),
(v) => {
if (typeof document !== 'undefined') {
document.documentElement.classList.toggle('dark', v)
}
},
{ immediate: true },
)
function cleanupMediaQuery() {
if (mediaQuery && mediaListener) {
mediaQuery.removeEventListener('change', mediaListener)
mediaListener = null
}
}
function setThemeMode(mode: ThemeMode) {
themeMode.value = mode
safeWrite('themeMode', mode)
@@ -50,5 +63,5 @@ export const useUIStore = defineStore('ui', () => {
const sidebarCollapsed = ref(safeReadString('sidebarCollapsed') === 'true')
watch(sidebarCollapsed, (v) => safeWrite('sidebarCollapsed', String(v)))
return { themeMode, effectiveDark, setThemeMode, sidebarCollapsed }
return { themeMode, effectiveDark, setThemeMode, sidebarCollapsed, cleanupMediaQuery }
})
+5 -2
View File
@@ -13,6 +13,9 @@ import type {
CommitBody,
} from '@/api/types'
const DEFAULT_POLL_INTERVAL_MS = 1500
const DEFAULT_POLL_MAX_MS = 5 * 60 * 1000
export const useWorkspacesStore = defineStore('workspaces', () => {
const list = ref<Workspace[]>([])
const current = ref<Workspace | null>(null)
@@ -75,8 +78,8 @@ export const useWorkspacesStore = defineStore('workspaces', () => {
*/
async function pollUntilSettled(
wsID: string,
intervalMs = 1500,
maxMs = 5 * 60 * 1000,
intervalMs = DEFAULT_POLL_INTERVAL_MS,
maxMs = DEFAULT_POLL_MAX_MS,
): Promise<Workspace> {
const start = Date.now()
let lastStatus: Workspace['sync_status'] | 'unknown' = 'unknown'
+7
View File
@@ -0,0 +1,7 @@
export function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('zh-CN')
}
export function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
}
+3
View File
@@ -0,0 +1,3 @@
import MarkdownIt from 'markdown-it'
export const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
+22 -43
View File
@@ -2,44 +2,25 @@
<AppShell>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold">
欢迎{{ auth.user?.display_name }}
</h2>
<NButton
type="primary"
@click="router.push({ name: 'project-list' })"
>
新建 / 管理项目
</NButton>
<h2 class="text-xl font-semibold">欢迎{{ auth.user?.display_name }}</h2>
<NButton type="primary" @click="goToProjects"> 新建 / 管理项目 </NButton>
</div>
<!-- 统计卡片 -->
<NGrid
:cols="3"
:x-gap="12"
>
<NGrid :cols="3" :x-gap="12">
<NGi>
<NCard>
<NStatistic
label="活跃项目"
:value="activeCount"
/>
<NStatistic label="活跃项目" :value="activeCount" />
</NCard>
</NGi>
<NGi>
<NCard>
<NStatistic
label="归档项目"
:value="archivedCount"
/>
<NStatistic label="归档项目" :value="archivedCount" />
</NCard>
</NGi>
<NGi>
<NCard>
<NStatistic
label="分配给我的待办议题"
:value="myIssues.length"
/>
<NStatistic label="分配给我的待办议题" :value="myIssues.length" />
</NCard>
</NGi>
</NGrid>
@@ -47,16 +28,10 @@
<!-- 分配给我的议题 -->
<NCard title="分配给我的议题(未关闭)">
<NSpin :show="loading">
<div
v-if="myIssues.length === 0"
class="text-sm text-gray-400 py-2"
>
<div v-if="myIssues.length === 0" class="text-sm text-gray-400 py-2">
暂无分配给你的未关闭议题
</div>
<ul
v-else
class="divide-y"
>
<ul v-else class="divide-y">
<li
v-for="row in myIssues"
:key="row.issue.id"
@@ -64,7 +39,9 @@
@click="goIssue(row)"
>
<span class="text-sm">
<span class="font-mono text-gray-500 mr-2">{{ row.projectSlug }}#{{ row.issue.number }}</span>
<span class="font-mono text-gray-500 mr-2"
>{{ row.projectSlug }}#{{ row.issue.number }}</span
>
{{ row.issue.title }}
</span>
<span class="text-xs text-gray-400">{{ row.projectName }}</span>
@@ -76,21 +53,15 @@
<!-- 最近项目 -->
<NCard title="项目">
<NSpin :show="loading">
<div
v-if="activeProjects.length === 0"
class="text-sm text-gray-400 py-2"
>
<div v-if="activeProjects.length === 0" class="text-sm text-gray-400 py-2">
还没有项目去创建一个吧
</div>
<ul
v-else
class="divide-y"
>
<ul v-else class="divide-y">
<li
v-for="p in activeProjects"
:key="p.id"
class="py-2 flex items-center justify-between cursor-pointer hover:bg-gray-50 px-1"
@click="router.push({ name: 'project-home', params: { slug: p.slug } })"
@click="goToProject(p)"
>
<span class="text-sm font-medium">{{ p.name }}</span>
<span class="font-mono text-xs text-gray-400">{{ p.slug }}</span>
@@ -163,4 +134,12 @@ function goIssue(row: MyIssueRow) {
params: { slug: row.projectSlug, number: row.issue.number },
})
}
function goToProjects() {
router.push({ name: 'project-list' })
}
function goToProject(p: Project) {
router.push({ name: 'project-home', params: { slug: p.slug } })
}
</script>
+25 -11
View File
@@ -1,10 +1,16 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { NButton } from 'naive-ui'
import { useAcpStore } from '@/stores/acp'
import { useConfirm } from '@/composables/useConfirm'
import { useAlert } from '@/composables/useAlert'
import { formatDateTime } from '@/utils/date'
import AppShell from '@/layouts/AppShell.vue'
import SessionStatusBadge from '@/components/acp/SessionStatusBadge.vue'
const store = useAcpStore()
const confirm = useConfirm()
const alert = useAlert()
onMounted(() => {
// all=true → 管理员视图,列出所有用户的会话。
@@ -12,7 +18,8 @@ onMounted(() => {
})
async function terminate(id: string) {
if (!confirm('Terminate this session?')) return
const ok = await confirm('Terminate this session?')
if (!ok) return
try {
await store.terminateSession(id)
} catch (e) {
@@ -30,10 +37,10 @@ async function terminate(id: string) {
</div>
<div v-if="store.loading" class="text-gray-500">Loading...</div>
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
<div v-else-if="store.sessions.length === 0" class="text-gray-500">
No sessions.
<div v-else-if="store.error" class="text-red-600">
{{ store.error }}
</div>
<div v-else-if="store.sessions.length === 0" class="text-gray-500">No sessions.</div>
<table v-else class="w-full text-sm">
<thead class="text-left">
@@ -47,20 +54,27 @@ async function terminate(id: string) {
</thead>
<tbody>
<tr v-for="s in store.sessions" :key="s.id" class="border-t hover:bg-gray-50">
<td class="px-3 py-2 font-mono text-xs">{{ s.user_id }}</td>
<td class="px-3 py-2 font-mono text-xs">{{ s.branch }}</td>
<td class="px-3 py-2"><SessionStatusBadge :status="s.status" /></td>
<td class="px-3 py-2 font-mono text-xs">
{{ s.user_id }}
</td>
<td class="px-3 py-2 font-mono text-xs">
{{ s.branch }}
</td>
<td class="px-3 py-2">
<SessionStatusBadge :status="s.status" />
</td>
<td class="px-3 py-2 text-xs text-gray-500">
{{ new Date(s.started_at).toLocaleString() }}
{{ formatDateTime(s.started_at) }}
</td>
<td class="px-3 py-2 text-right space-x-2">
<button
<NButton
v-if="s.status === 'starting' || s.status === 'running'"
class="text-red-600 hover:underline"
text
type="error"
@click="terminate(s.id)"
>
Terminate
</button>
</NButton>
</td>
</tr>
</tbody>
+45 -42
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { ref, shallowRef, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { NButton } from 'naive-ui'
import { useAcpStore } from '@/stores/acp'
import { useAcpStream, type UseAcpStreamReturn } from '@/composables/useAcpStream'
import { useConfirm } from '@/composables/useConfirm'
import { acpApi, type AcpEvent, type PermissionRequest } from '@/api/acp'
import { useAuthStore } from '@/stores/auth'
import { workspacesApi } from '@/api/workspaces'
@@ -15,6 +17,7 @@ import CommitDialog from '@/views/workspaces/components/CommitDialog.vue'
const route = useRoute()
const store = useAcpStore()
const auth = useAuthStore()
const confirm = useConfirm()
const sessionId = computed(() => route.params.sessionId as string)
@@ -72,21 +75,33 @@ onUnmounted(() => {
stream.value?.close()
})
const isPrompting = computed(() => {
const evs = events.value
let lastPrompt = -1
for (let i = evs.length - 1; i >= 0; i--) {
if (evs[i].method === 'session/prompt' && evs[i].direction === 'in') {
lastPrompt = i
break
const lastPromptIndex = ref(-1)
const hasResponseAfterPrompt = ref(false)
const isPrompting = computed(() => lastPromptIndex.value >= 0 && !hasResponseAfterPrompt.value)
watch(
() => events.value.length,
() => {
const evs = events.value
if (lastPromptIndex.value < 0) {
for (let i = evs.length - 1; i >= 0; i--) {
if (evs[i].method === 'session/prompt' && evs[i].direction === 'in') {
lastPromptIndex.value = i
hasResponseAfterPrompt.value = false
break
}
}
}
}
if (lastPrompt < 0) return false
for (let i = lastPrompt + 1; i < evs.length; i++) {
if (evs[i].kind === 'response') return false
}
return true
})
if (lastPromptIndex.value >= 0) {
for (let i = lastPromptIndex.value + 1; i < evs.length; i++) {
if (evs[i].kind === 'response') {
hasResponseAfterPrompt.value = true
break
}
}
}
},
)
function onPrompt(text: string) {
const sess = store.currentSession
@@ -95,17 +110,20 @@ function onPrompt(text: string) {
return
}
errorMsg.value = null
stream.value?.prompt(text, sess.agent_session_id)
const ok = stream.value?.prompt(text, sess.agent_session_id)
if (!ok) errorMsg.value = '发送失败:连接未就绪'
}
function onCancel() {
const sess = store.currentSession
if (!sess?.agent_session_id) return
stream.value?.cancel(sess.agent_session_id)
const ok = stream.value?.cancel(sess.agent_session_id)
if (!ok) errorMsg.value = '取消失败:连接未就绪'
}
async function terminate() {
if (!confirm('Terminate this session?')) return
const ok = await confirm('Terminate this session?')
if (!ok) return
try {
await store.terminateSession(sessionId.value)
} catch (e) {
@@ -152,27 +170,22 @@ async function openCommit() {
<div class="space-x-3 text-sm">
<span class="font-mono">{{ store.currentSession?.branch }}</span>
<span class="text-gray-500">·</span>
<span
class="text-xs text-gray-500 font-mono truncate max-w-md inline-block align-middle"
>
<span class="text-xs text-gray-500 font-mono truncate max-w-md inline-block align-middle">
{{ store.currentSession?.cwd_path }}
</span>
</div>
<div class="flex items-center gap-2">
<SessionStatusBadge :status="sessionStatus" />
<button
class="px-2 py-1 text-xs rounded border text-blue-600 hover:bg-blue-50"
@click="openCommit"
>
提交代码
</button>
<button
<NButton text type="primary" size="small" @click="openCommit"> 提交代码 </NButton>
<NButton
v-if="sessionStatus === 'starting' || sessionStatus === 'running'"
class="px-2 py-1 text-xs rounded border text-red-600 hover:bg-red-50"
text
type="error"
size="small"
@click="terminate"
>
Terminate
</button>
</NButton>
</div>
</div>
@@ -194,19 +207,9 @@ async function openCommit() {
/>
<!-- Events stream -->
<div
ref="eventsContainer"
class="flex-1 overflow-y-auto px-4 py-3 space-y-1"
>
<AgentEventCard
v-for="ev in events"
:key="ev.id"
:event="ev"
/>
<div
v-if="events.length === 0"
class="text-center text-gray-400 py-8"
>
<div ref="eventsContainer" class="flex-1 overflow-y-auto px-4 py-3 space-y-1">
<AgentEventCard v-for="ev in events" :key="ev.id" :event="ev" />
<div v-if="events.length === 0" class="text-center text-gray-400 py-8">
Waiting for agent...
</div>
</div>
+26 -26
View File
@@ -1,31 +1,34 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { NButton } from 'naive-ui'
import { useAcpStore } from '@/stores/acp'
import { useConfirm } from '@/composables/useConfirm'
import { useAlert } from '@/composables/useAlert'
import { formatDateTime } from '@/utils/date'
import SessionStatusBadge from '@/components/acp/SessionStatusBadge.vue'
const route = useRoute()
const router = useRouter()
const store = useAcpStore()
const confirm = useConfirm()
const alert = useAlert()
onMounted(() => {
void store.listSessions(false)
})
function open(id: string) {
router.push(
`/p/${route.params.slug}/w/${route.params.wsSlug}/acp-sessions/${id}`,
)
router.push(`/p/${route.params.slug}/w/${route.params.wsSlug}/acp-sessions/${id}`)
}
function newSession() {
router.push(
`/p/${route.params.slug}/w/${route.params.wsSlug}/acp-sessions/new`,
)
router.push(`/p/${route.params.slug}/w/${route.params.wsSlug}/acp-sessions/new`)
}
async function terminate(id: string) {
if (!confirm('Terminate this session?')) return
const ok = await confirm('Terminate this session?')
if (!ok) return
try {
await store.terminateSession(id)
} catch (e) {
@@ -38,16 +41,13 @@ async function terminate(id: string) {
<div class="p-6 space-y-4">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">ACP Sessions</h1>
<button
class="px-3 py-1 rounded text-sm font-medium bg-blue-600 text-white hover:bg-blue-700"
@click="newSession"
>
+ New
</button>
<NButton type="primary" @click="newSession"> + New </NButton>
</div>
<div v-if="store.loading" class="text-gray-500">Loading...</div>
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
<div v-else-if="store.error" class="text-red-600">
{{ store.error }}
</div>
<div v-else-if="store.sessions.length === 0" class="text-gray-500">
No sessions yet. Click "+ New" to create one.
</div>
@@ -63,25 +63,25 @@ async function terminate(id: string) {
</thead>
<tbody>
<tr v-for="s in store.sessions" :key="s.id" class="border-t hover:bg-gray-50">
<td class="px-3 py-2 font-mono text-xs">{{ s.branch }}</td>
<td class="px-3 py-2"><SessionStatusBadge :status="s.status" /></td>
<td class="px-3 py-2 font-mono text-xs">
{{ s.branch }}
</td>
<td class="px-3 py-2">
<SessionStatusBadge :status="s.status" />
</td>
<td class="px-3 py-2 text-xs text-gray-500">
{{ new Date(s.started_at).toLocaleString() }}
{{ formatDateTime(s.started_at) }}
</td>
<td class="px-3 py-2 text-right space-x-2">
<button
class="text-blue-600 hover:underline"
@click="open(s.id)"
>
Open
</button>
<button
<NButton text type="primary" @click="open(s.id)"> Open </NButton>
<NButton
v-if="s.status === 'starting' || s.status === 'running'"
class="text-red-600 hover:underline"
text
type="error"
@click="terminate(s.id)"
>
Terminate
</button>
</NButton>
</td>
</tr>
</tbody>
+27 -53
View File
@@ -4,9 +4,13 @@ import { useRouter } from 'vue-router'
import { NButton, NSpin } from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue'
import { useAcpStore } from '@/stores/acp'
import { useConfirm } from '@/composables/useConfirm'
import { useAlert } from '@/composables/useAlert'
const router = useRouter()
const store = useAcpStore()
const confirm = useConfirm()
const alert = useAlert()
onMounted(() => store.listAgentKinds(true))
@@ -14,8 +18,17 @@ async function toggleEnabled(id: string, enabled: boolean) {
await store.updateAgentKind(id, { enabled })
}
function goNew() {
router.push('/admin/acp/agent-kinds/new')
}
function goEdit(id: string) {
router.push(`/admin/acp/agent-kinds/${id}`)
}
async function remove(id: string, name: string) {
if (!confirm(`Delete agent kind "${name}"?`)) return
const ok = await confirm(`Delete agent kind "${name}"?`)
if (!ok) return
try {
await store.deleteAgentKind(id)
} catch (e) {
@@ -29,51 +42,24 @@ async function remove(id: string, name: string) {
<NSpin :show="store.loading">
<div class="space-y-4">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">
ACP Agent Kinds
</h1>
<NButton
type="primary"
@click="router.push('/admin/acp/agent-kinds/new')"
>
+ New
</NButton>
<h1 class="text-2xl font-semibold">ACP Agent Kinds</h1>
<NButton type="primary" @click="goNew"> + New </NButton>
</div>
<div
v-if="store.error"
class="text-red-600"
>
<div v-if="store.error" class="text-red-600">
{{ store.error }}
</div>
<table
v-else
class="w-full text-sm"
>
<table v-else class="w-full text-sm">
<thead class="text-left">
<tr>
<th class="px-3 py-2">
Name
</th>
<th class="px-3 py-2">
Display
</th>
<th class="px-3 py-2">
Binary
</th>
<th class="px-3 py-2">
Enabled
</th>
<th class="px-3 py-2 text-right">
Actions
</th>
<th class="px-3 py-2">Name</th>
<th class="px-3 py-2">Display</th>
<th class="px-3 py-2">Binary</th>
<th class="px-3 py-2">Enabled</th>
<th class="px-3 py-2 text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr
v-for="k in store.agentKinds"
:key="k.id"
class="border-t"
>
<tr v-for="k in store.agentKinds" :key="k.id" class="border-t">
<td class="px-3 py-2 font-mono">
{{ k.name }}
</td>
@@ -88,23 +74,11 @@ async function remove(id: string, name: string) {
type="checkbox"
:checked="k.enabled"
@change="toggleEnabled(k.id, !k.enabled)"
>
/>
</td>
<td class="px-3 py-2 text-right space-x-2">
<NButton
text
type="primary"
@click="router.push(`/admin/acp/agent-kinds/${k.id}`)"
>
Edit
</NButton>
<NButton
text
type="error"
@click="remove(k.id, k.name)"
>
Delete
</NButton>
<NButton text type="primary" @click="goEdit(k.id)"> Edit </NButton>
<NButton text type="error" @click="remove(k.id, k.name)"> Delete </NButton>
</td>
</tr>
</tbody>
+11 -14
View File
@@ -5,11 +5,17 @@ import AppShell from '@/layouts/AppShell.vue'
import AgentKindForm from '@/components/acp/AgentKindForm.vue'
import AgentConfigFilesPanel from '@/components/acp/AgentConfigFilesPanel.vue'
import { useAcpStore } from '@/stores/acp'
import { useAlert } from '@/composables/useAlert'
import type { AgentClientType, McpServerSpec, UpdateAgentKindReq } from '@/api/acp'
const route = useRoute()
const router = useRouter()
const store = useAcpStore()
const alert = useAlert()
function goBack() {
router.push('/admin/acp/agent-kinds')
}
const id = computed(() => (route.params.id as string | undefined) ?? null)
const isEdit = computed(() => id.value !== null)
@@ -79,7 +85,7 @@ async function submit() {
mcp_servers: form.value.mcp_servers,
})
}
router.push('/admin/acp/agent-kinds')
goBack()
} catch (e) {
alert(`Save failed: ${e instanceof Error ? e.message : 'unknown'}`)
}
@@ -97,21 +103,12 @@ async function submit() {
:is-edit="isEdit"
:existing-env-keys="existingEnvKeys"
@submit="submit"
@cancel="router.push('/admin/acp/agent-kinds')"
@cancel="goBack"
/>
<div
v-if="isEdit && id"
class="mt-8"
>
<AgentConfigFilesPanel
:kind-id="id"
:client-type="form.client_type"
/>
<div v-if="isEdit && id" class="mt-8">
<AgentConfigFilesPanel :kind-id="id" :client-type="form.client_type" />
</div>
<p
v-else
class="text-xs text-gray-500 mt-6"
>
<p v-else class="text-xs text-gray-500 mt-6">
保存后可在编辑页配置该客户端的配置文件settings.json / config.toml
</p>
</div>
+7 -22
View File
@@ -5,11 +5,7 @@
{{ adminMode ? '用量(全局)' : '我的用量' }}
</h2>
<div class="flex gap-2 items-center flex-wrap">
<NDatePicker
v-model:value="range"
type="daterange"
clearable
/>
<NDatePicker v-model:value="range" type="daterange" clearable />
<NSelect
v-if="adminMode"
v-model:value="groupBy"
@@ -17,10 +13,7 @@
style="width: 160px"
/>
</div>
<NDataTable
:data="rows"
:columns="columns"
/>
<NDataTable :data="rows" :columns="columns" />
</div>
</AppShell>
</template>
@@ -31,12 +24,9 @@ import { NDataTable, NDatePicker, NSelect } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue'
import {
llmAdminApi,
type AdminUsageGroupBy,
type AdminUsageItem,
} from '@/api/llmAdmin'
import { llmAdminApi, type AdminUsageGroupBy, type AdminUsageItem } from '@/api/llmAdmin'
import { chatApi, type UsageDailyItem } from '@/api/chat'
import { formatDate } from '@/utils/date'
interface Props {
adminMode?: boolean
@@ -69,17 +59,12 @@ const columns = computed<DataTableColumns<Row>>(() => {
]
if (props.adminMode) {
const labelCol: DataTableColumns<Row>[number] = {
title:
groupBy.value === 'user'
? '用户'
: groupBy.value === 'model'
? '模型'
: '日期',
title: groupBy.value === 'user' ? '用户' : groupBy.value === 'model' ? '模型' : '日期',
key: 'key',
render: (r) => {
const k = (r as AdminUsageItem).key
if (groupBy.value === 'day') {
return new Date(k).toLocaleDateString('zh-CN')
return formatDate(k)
}
return k
},
@@ -90,7 +75,7 @@ const columns = computed<DataTableColumns<Row>>(() => {
{
title: '日期',
key: 'day',
render: (r) => new Date((r as UsageDailyItem).day).toLocaleDateString('zh-CN'),
render: (r) => formatDate((r as UsageDailyItem).day),
},
...tokenCols,
]
+4 -1
View File
@@ -22,6 +22,7 @@
type="primary"
attr-type="submit"
:loading="loading"
:disabled="!isValid"
block
>
登录
@@ -38,7 +39,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { NForm, NFormItem, NInput, NButton } from 'naive-ui'
import { useRouter } from 'vue-router'
import { authApi } from '@/api/auth'
@@ -50,6 +51,8 @@ const password = ref('')
const loading = ref(false)
const error = ref('')
const isValid = computed(() => email.value.trim().length > 0 && password.value.length > 0)
const auth = useAuthStore()
const router = useRouter()
+20 -40
View File
@@ -3,13 +3,7 @@
<div class="flex h-[calc(100vh-8rem)] -m-6">
<!-- 左侧会话列表 -->
<div class="w-72 border-r overflow-y-auto p-2 flex flex-col gap-1">
<NButton
block
class="mb-2"
@click="showCreate = true"
>
+ 新建对话
</NButton>
<NButton block class="mb-2" @click="showCreate = true"> + 新建对话 </NButton>
<div
v-for="c in chat.conversations"
:key="c.id"
@@ -27,10 +21,7 @@
{{ formatTime(c.updated_at) }}
</div>
</div>
<NPopconfirm
:show-icon="false"
@positive-click="chat.deleteConversation(c.id)"
>
<NPopconfirm :show-icon="false" @positive-click="chat.deleteConversation(c.id)">
<template #trigger>
<NButton
text
@@ -43,15 +34,10 @@
</template>
</NButton>
</template>
<template #default>
删除后无法恢复确定删除此会话
</template>
<template #default> 删除后无法恢复确定删除此会话 </template>
</NPopconfirm>
</div>
<div
v-if="chat.conversations.length === 0"
class="text-xs text-neutral-400 px-2"
>
<div v-if="chat.conversations.length === 0" class="text-xs text-neutral-400 px-2">
暂无对话
</div>
</div>
@@ -70,10 +56,7 @@
<div class="text-sm font-medium truncate flex-1">
{{ chat.currentConversation.title || '(未命名)' }}
</div>
<NPopconfirm
:show-icon="false"
@positive-click="deleteCurrentConversation"
>
<NPopconfirm :show-icon="false" @positive-click="deleteCurrentConversation">
<template #trigger>
<NButton text size="small">
<template #icon>
@@ -82,18 +65,14 @@
删除
</NButton>
</template>
<template #default>
删除后无法恢复确定删除此会话
</template>
<template #default> 删除后无法恢复确定删除此会话 </template>
</NPopconfirm>
</div>
<div
ref="scrollEl"
class="flex-1 overflow-y-auto p-4 space-y-3"
>
<div ref="scrollEl" class="flex-1 overflow-y-auto p-4 space-y-3">
<MessageBubble
v-for="m in chat.messages"
:key="m.id"
v-memo="[m.id, m.content, m.status]"
:message="m"
@retry="onRetry"
/>
@@ -121,13 +100,7 @@
>
发送
</NButton>
<NButton
v-else
type="warning"
@click="chat.cancelMessage"
>
中断
</NButton>
<NButton v-else type="warning" @click="chat.cancelMessage"> 中断 </NButton>
</div>
</div>
</template>
@@ -150,6 +123,7 @@ import { TrashOutline } from '@vicons/ionicons5'
import AppShell from '@/layouts/AppShell.vue'
import { useChatStore } from '@/stores/chat'
import { formatDateTime } from '@/utils/date'
const MessageBubble = defineAsyncComponent(() => import('./components/MessageBubble.vue'))
import AttachmentUploader from './components/AttachmentUploader.vue'
const ConversationCreateModal = defineAsyncComponent(() => import('./ConversationCreateModal.vue'))
@@ -211,12 +185,18 @@ watch(
)
// 流式输出时 messages.length 不变,只 content 增长 —— 同时观察末消息内容长度。
let scrollPending = false
watch(
() => [chat.messages.length, chat.messages.at(-1)?.content?.length ?? 0],
() => {
nextTick(() => {
const el = scrollEl.value
if (el) el.scrollTo(0, el.scrollHeight)
if (scrollPending) return
scrollPending = true
requestAnimationFrame(() => {
scrollPending = false
nextTick(() => {
const el = scrollEl.value
if (el) el.scrollTo(0, el.scrollHeight)
})
})
},
)
@@ -261,6 +241,6 @@ async function deleteCurrentConversation() {
}
function formatTime(iso: string) {
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
return formatDateTime(iso)
}
</script>
@@ -1,13 +1,7 @@
<template>
<div class="flex gap-2 items-center flex-wrap">
<NUpload
:custom-request="upload"
:show-file-list="false"
multiple
>
<NButton size="small">
添加附件
</NButton>
<NUpload :custom-request="upload" :show-file-list="false" multiple>
<NButton size="small"> 添加附件 </NButton>
</NUpload>
<div
v-for="a in attachments"
@@ -15,13 +9,7 @@
class="text-xs px-2 py-1 bg-neutral-100 rounded flex items-center gap-1"
>
<span>{{ a.filename }} ({{ Math.round(a.size_bytes / 1024) }} KB)</span>
<NButton
text
size="tiny"
@click="remove(a.id)"
>
×
</NButton>
<NButton text size="tiny" @click="remove(a.id)"> × </NButton>
</div>
</div>
</template>
@@ -41,6 +29,9 @@ const emit = defineEmits<{ change: [ids: string[]] }>()
const attachments = ref<Attachment[]>([])
const msg = useMessage()
const MAX_ATTACHMENT_SIZE_MB = 20
const MAX_ATTACHMENT_SIZE_BYTES = MAX_ATTACHMENT_SIZE_MB * 1024 * 1024
async function upload(opts: UploadCustomRequestOptions) {
const f = opts.file.file
if (!f) {
@@ -62,8 +53,8 @@ async function upload(opts: UploadCustomRequestOptions) {
opts.onError()
return
}
if (f.size > 20 * 1024 * 1024) {
msg.error('单文件不能超过 20 MB')
if (f.size > MAX_ATTACHMENT_SIZE_BYTES) {
msg.error(`单文件不能超过 ${MAX_ATTACHMENT_SIZE_MB} MB`)
opts.onError()
return
}
@@ -82,9 +73,12 @@ function remove(id: string) {
}
watch(
attachments,
() => emit('change', attachments.value.map((a) => a.id)),
{ deep: true },
() => attachments.value.map((a) => a.id),
() =>
emit(
'change',
attachments.value.map((a) => a.id),
),
)
defineExpose({
@@ -93,13 +93,10 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NCollapse, NCollapseItem, NButton } from 'naive-ui'
import MarkdownIt from 'markdown-it'
import type { ChatMessage } from '@/api/chat'
import { md } from '@/utils/markdown'
import { parseInteractiveMessage, type InteractiveQuestionAnswer } from '@/utils/interactiveMessage'
import QuestionCard from './QuestionCard.vue'
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
// savable:仅需求阶段对话面板传 true,对成功的 assistant 回复显示「保存为产物」。
const props = defineProps<{ message: ChatMessage; savable?: boolean; answerDisabled?: boolean }>()
const emit = defineEmits<{
@@ -1,10 +1,7 @@
<template>
<AppShell>
<NSpin :show="loading">
<div
v-if="data"
class="space-y-6"
>
<div v-if="data" class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
@@ -12,10 +9,7 @@
<h1 class="text-2xl font-semibold">
{{ data.requirement.title }}
</h1>
<NTag
:type="data.requirement.status === 'open' ? 'success' : 'default'"
size="small"
>
<NTag :type="data.requirement.status === 'open' ? 'success' : 'default'" size="small">
{{ data.requirement.status === 'open' ? '开放' : '关闭' }}
</NTag>
</div>
@@ -29,30 +23,16 @@
>
关闭需求
</NButton>
<NButton
v-else
type="primary"
size="small"
:loading="toggling"
@click="onReopen"
>
<NButton v-else type="primary" size="small" :loading="toggling" @click="onReopen">
重新开放
</NButton>
<NButton
size="small"
@click="router.back()"
>
返回
</NButton>
<NButton size="small" @click="router.back()"> 返回 </NButton>
</div>
</div>
<!-- Phase flow bar阶段跳转始终由用户确认触发 -->
<div class="flex items-center gap-1">
<template
v-for="(phase, idx) in PHASES"
:key="phase"
>
<template v-for="(phase, idx) in PHASES" :key="phase">
<NButton
:type="phase === data.requirement.phase ? 'primary' : 'default'"
:secondary="phase !== data.requirement.phase"
@@ -62,10 +42,7 @@
>
{{ PHASE_LABELS[phase] }}
</NButton>
<span
v-if="idx < PHASES.length - 1"
class="text-gray-300 text-xs"
></span>
<span v-if="idx < PHASES.length - 1" class="text-gray-300 text-xs"></span>
</template>
</div>
@@ -74,18 +51,10 @@
<div class="w-[45%] shrink-0 space-y-6">
<!-- Description -->
<NCard title="描述">
<p
v-if="data.requirement.description"
class="text-gray-700 whitespace-pre-wrap"
>
<p v-if="data.requirement.description" class="text-gray-700 whitespace-pre-wrap">
{{ data.requirement.description }}
</p>
<p
v-else
class="text-gray-400 text-sm"
>
暂无描述
</p>
<p v-else class="text-gray-400 text-sm">暂无描述</p>
</NCard>
<!-- 当前阶段产物 AI 对话三阶段显示保留编辑功能 -->
@@ -122,34 +91,22 @@
<!-- Linked Issues -->
<NCard title="关联 Issue">
<NList
v-if="data.issues.length > 0"
:show-divider="false"
>
<NListItem
v-for="issue in data.issues"
:key="issue.id"
>
<NList v-if="data.issues.length > 0" :show-divider="false">
<NListItem v-for="issue in data.issues" :key="issue.id">
<NThing>
<template #header>
<span class="font-mono text-xs text-gray-400 mr-2">#{{ issue.number }}</span>
<span class="text-sm font-medium">{{ issue.title }}</span>
</template>
<template #header-extra>
<NTag
:type="issue.status === 'open' ? 'success' : 'default'"
size="tiny"
>
<NTag :type="issue.status === 'open' ? 'success' : 'default'" size="tiny">
{{ issue.status === 'open' ? '开放' : '关闭' }}
</NTag>
</template>
</NThing>
</NListItem>
</NList>
<NEmpty
v-else
description="暂无关联 Issue"
/>
<NEmpty v-else description="暂无关联 Issue" />
</NCard>
</div>
@@ -172,19 +129,12 @@
:slug="slug"
:requirement="data.requirement"
/>
<RequirementSummaryPanel
v-else
:slug="slug"
:number="number"
/>
<RequirementSummaryPanel v-else :slug="slug" :number="number" />
</div>
</div>
</div>
<div
v-else-if="!loading"
class="text-gray-400 text-sm py-8 text-center"
>
<div v-else-if="!loading" class="text-gray-400 text-sm py-8 text-center">
需求不存在或无权访问。
</div>
</NSpin>
@@ -195,17 +145,7 @@
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDialog } from 'naive-ui'
import {
NCard,
NButton,
NTag,
NSpin,
NSelect,
NList,
NListItem,
NThing,
NEmpty,
} from 'naive-ui'
import { NCard, NButton, NTag, NSpin, NSelect, NList, NListItem, NThing, NEmpty } from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue'
import { projectsApi } from '@/api/projects'
import { useRequirementsStore } from '@/stores/requirements'
@@ -295,8 +235,7 @@ function onArtifactSaved() {
async function onWorkspaceChange(v: string | null) {
if (!data.value) return
await projectsApi.updateRequirement(slug, number, { workspace_id: v })
// Re-fetch to keep data.requirement and data.issues consistent (mirrors onPhaseClick)
data.value = await projectsApi.getRequirement(slug, number)
data.value.requirement.workspace_id = v
}
function onPhaseClick(target: Phase) {
@@ -308,9 +247,8 @@ function onPhaseClick(target: Phase) {
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await store.changePhase(slug, number, target)
// Refresh local data to reflect the change
data.value = await projectsApi.getRequirement(slug, number)
const updated = await store.changePhase(slug, number, target)
data.value!.requirement = updated
},
})
}
@@ -319,7 +257,7 @@ async function onClose() {
toggling.value = true
try {
await store.close(slug, number)
data.value = await projectsApi.getRequirement(slug, number)
data.value!.requirement.status = 'closed'
} finally {
toggling.value = false
}
@@ -329,7 +267,7 @@ async function onReopen() {
toggling.value = true
try {
await store.reopen(slug, number)
data.value = await projectsApi.getRequirement(slug, number)
data.value!.requirement.status = 'open'
} finally {
toggling.value = false
}
@@ -3,83 +3,48 @@
<div class="flex gap-4">
<!-- 版本列表 -->
<div class="w-44 shrink-0">
<NList
v-if="artifacts.length > 0"
:show-divider="false"
>
<NListItem
v-for="a in artifacts"
:key="a.id"
>
<NThing
class="cursor-pointer"
@click="selectedVersion = a.version"
>
<NList v-if="artifacts.length > 0" :show-divider="false">
<NListItem v-for="a in artifacts" :key="a.id">
<NThing class="cursor-pointer" @click="selectedVersion = a.version">
<template #header>
<span
class="text-sm font-medium"
:class="selectedVersion === a.version ? 'text-blue-600' : ''"
>v{{ a.version }}</span>
<NTag
v-if="a.source_message_id"
size="tiny"
class="ml-1"
>v{{ a.version }}</span
>
AI
</NTag>
<NTag v-if="a.source_message_id" size="tiny" class="ml-1"> AI </NTag>
</template>
<template #header-extra>
<span class="text-xs text-gray-400">
{{ new Date(a.created_at).toLocaleDateString() }}
</span>
</template>
<span
v-if="a.note"
class="text-xs text-gray-500"
>{{ a.note }}</span>
<span v-if="a.note" class="text-xs text-gray-500">{{ a.note }}</span>
</NThing>
</NListItem>
</NList>
<NEmpty
v-else
description="暂无产物版本"
size="small"
/>
<NEmpty v-else description="暂无产物版本" size="small" />
</div>
<!-- 选中版本内容markdown 渲染 -->
<div class="flex-1 min-w-0">
<!-- eslint-disable-next-line vue/no-v-html -->
<div
v-if="selectedArtifact"
class="prose prose-sm max-w-none"
v-html="rendered"
/>
<p
v-else
class="text-gray-400 text-sm"
>
选择左侧版本以查看内容
</p>
<!-- eslint-disable vue/no-v-html -->
<div v-if="selectedArtifact" class="prose prose-sm max-w-none" v-html="rendered" />
<!-- eslint-enable vue/no-v-html -->
<p v-else class="text-gray-400 text-sm">选择左侧版本以查看内容</p>
</div>
</div>
<!-- 手动提交新版本 -->
<NDivider />
<div
v-if="!readonly"
class="space-y-2"
>
<div v-if="!readonly" class="space-y-2">
<NInput
v-model:value="newContent"
type="textarea"
placeholder="产物内容(Markdown)"
:autosize="{ minRows: 3, maxRows: 10 }"
/>
<NInput
v-model:value="newNote"
placeholder="备注(可选)"
/>
<NInput v-model:value="newNote" placeholder="备注(可选)" />
<NButton
type="primary"
size="small"
@@ -90,31 +55,15 @@
提交新版本
</NButton>
</div>
<p
v-else
class="text-gray-400 text-sm"
>
需求已关闭无法提交新版本
</p>
<p v-else class="text-gray-400 text-sm">需求已关闭无法提交新版本</p>
</NCard>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import {
NButton,
NCard,
NDivider,
NEmpty,
NInput,
NList,
NListItem,
NTag,
NThing,
} from 'naive-ui'
import MarkdownIt from 'markdown-it'
import { NButton, NCard, NDivider, NEmpty, NInput, NList, NListItem, NTag, NThing } from 'naive-ui'
import type { Artifact, ArtifactPhase } from '@/api/projects'
import { md } from '@/utils/markdown'
import { useRequirementsStore } from '@/stores/requirements'
const PHASE_TITLE: Record<ArtifactPhase, string> = {
@@ -131,7 +80,6 @@ const props = defineProps<{
}>()
const store = useRequirementsStore()
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
const artifacts = ref<Artifact[]>([])
const selectedVersion = ref<number | null>(null)
@@ -4,21 +4,10 @@
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500">关联本需求的 agent 会话</span>
<div class="flex items-center gap-2">
<NButton
size="small"
:loading="loading"
@click="refresh"
>
刷新
</NButton>
<NButton size="small" :loading="loading" @click="refresh"> 刷新 </NButton>
<NTooltip :disabled="!!workspaceSlug">
<template #trigger>
<NButton
size="small"
type="primary"
:disabled="!workspaceSlug"
@click="goCreate"
>
<NButton size="small" type="primary" :disabled="!workspaceSlug" @click="goCreate">
发起{{ phaseLabel }}会话
</NButton>
</template>
@@ -27,18 +16,9 @@
</div>
</div>
<NList
v-if="sessions.length > 0"
:show-divider="false"
>
<NListItem
v-for="s in sessions"
:key="s.id"
>
<NThing
class="cursor-pointer"
@click="goDetail(s)"
>
<NList v-if="sessions.length > 0" :show-divider="false">
<NListItem v-for="s in sessions" :key="s.id">
<NThing class="cursor-pointer" @click="goDetail(s)">
<template #header>
<span class="font-mono text-sm">{{ s.branch }}</span>
</template>
@@ -46,17 +26,13 @@
<SessionStatusBadge :status="s.status" />
</template>
<span class="text-xs text-gray-400">
{{ new Date(s.started_at).toLocaleString('zh-CN', { hour12: false }) }}
{{ formatDateTime(s.started_at) }}
<template v-if="s.last_error"> · {{ s.last_error }}</template>
</span>
</NThing>
</NListItem>
</NList>
<NEmpty
v-else
description="暂无关联会话"
size="small"
/>
<NEmpty v-else description="暂无关联会话" size="small" />
</div>
</NCard>
</template>
@@ -68,6 +44,7 @@ import { NButton, NCard, NEmpty, NList, NListItem, NThing, NTooltip } from 'naiv
import { acpApi, type AcpSession } from '@/api/acp'
import type { Requirement } from '@/api/projects'
import { formatDateTime } from '@/utils/date'
import SessionStatusBadge from '@/components/acp/SessionStatusBadge.vue'
import { useWorkspacesStore } from '@/stores/workspaces'
@@ -82,9 +59,7 @@ const wsStore = useWorkspacesStore()
const sessions = ref<AcpSession[]>([])
const loading = ref(false)
const phaseLabel = computed(() =>
props.requirement.phase === 'reviewing' ? '验收' : '实施',
)
const phaseLabel = computed(() => (props.requirement.phase === 'reviewing' ? '验收' : '实施'))
// requirement.workspace_id → workspace slug(路由需要 wsSlug)。
const workspaceSlug = computed(() => {
@@ -15,21 +15,11 @@
:disabled="creating"
@update:value="onSelectConversation"
/>
<NButton
size="small"
type="primary"
secondary
@click="openCreateForm"
>
新建会话
</NButton>
<NButton size="small" type="primary" secondary @click="openCreateForm"> 新建会话 </NButton>
</div>
<!-- 新建会话表单 -->
<div
v-if="showCreateForm"
class="space-y-2 mb-3 border rounded p-3"
>
<div v-if="showCreateForm" class="space-y-2 mb-3 border rounded p-3">
<ModelSelector v-model:model-value="newModelId" />
<NInput
v-model:value="newSystemPrompt"
@@ -44,12 +34,7 @@
:autosize="{ minRows: 1, maxRows: 4 }"
/>
<div class="flex justify-end gap-2">
<NButton
size="small"
@click="showCreateForm = false"
>
取消
</NButton>
<NButton size="small" @click="showCreateForm = false"> 取消 </NButton>
<NButton
size="small"
type="primary"
@@ -71,6 +56,7 @@
<MessageBubble
v-for="m in session.messages.value"
:key="m.id"
v-memo="[m.id, m.content, m.status]"
:message="m"
:savable="!readonly"
:answer-disabled="readonly || session.isStreaming.value"
@@ -79,18 +65,12 @@
@answer-question="onAnswerQuestion"
/>
</div>
<div
v-else
class="flex-1 flex items-center justify-center text-neutral-400 text-sm"
>
<div v-else class="flex-1 flex items-center justify-center text-neutral-400 text-sm">
选择历史会话或新建会话开始与 AI 讨论本阶段内容
</div>
<!-- 输入区 -->
<div
v-if="session.currentConversation.value"
class="border-t pt-3 flex gap-2"
>
<div v-if="session.currentConversation.value" class="border-t pt-3 flex gap-2">
<NInput
v-model:value="draft"
type="textarea"
@@ -107,13 +87,7 @@
>
发送
</NButton>
<NButton
v-else
type="warning"
@click="session.cancelMessage"
>
中断
</NButton>
<NButton v-else type="warning" @click="session.cancelMessage"> 中断 </NButton>
</div>
<SaveArtifactModal
@@ -133,6 +107,7 @@ import { NButton, NCard, NInput, NSelect } from 'naive-ui'
import { chatApi, type ChatMessage, type Conversation } from '@/api/chat'
import type { Artifact, ArtifactPhase, Requirement } from '@/api/projects'
import { formatDateTime } from '@/utils/date'
import { useConversationSession } from '@/composables/useConversationSession'
import { buildRequirementSystemPrompt } from '@/constants/requirementPhasePrompts'
import {
@@ -180,7 +155,7 @@ const saveSource = ref<ChatMessage | null>(null)
const conversationOptions = computed(() =>
conversations.value.map((c) => ({
label: `${c.title || '(未命名)'} · ${new Date(c.updated_at).toLocaleString('zh-CN', { hour12: false })}`,
label: `${c.title || '(未命名)'} · ${formatDateTime(c.updated_at)}`,
value: c.id,
})),
)
@@ -246,12 +221,18 @@ function onSaveMessage(id: number) {
}
// 流式输出时自动滚到底部(同 ChatDetailView 的策略)。
let scrollPending = false
watch(
() => [session.messages.value.length, session.messages.value.at(-1)?.content?.length ?? 0],
() => {
nextTick(() => {
const el = scrollEl.value
if (el) el.scrollTo(0, el.scrollHeight)
if (scrollPending) return
scrollPending = true
requestAnimationFrame(() => {
scrollPending = false
nextTick(() => {
const el = scrollEl.value
if (el) el.scrollTo(0, el.scrollHeight)
})
})
},
)
@@ -261,7 +242,10 @@ watch(
() => props.phase,
() => {
session.closeCurrent()
draft.value = ''
showCreateForm.value = false
newSystemPrompt.value = ''
newFirstMessage.value = DEFAULT_FIRST_MESSAGE
},
)
@@ -1,10 +1,7 @@
<template>
<NCard title="产物与会话历史">
<NSpin :show="loading">
<NCollapse
v-if="hasAnyContent"
:default-expanded-names="defaultExpandedNames"
>
<NCollapse v-if="hasAnyContent" :default-expanded-names="defaultExpandedNames">
<!-- 产物按阶段分组 -->
<NCollapseItem
v-for="phase in ARTIFACT_PHASES"
@@ -15,100 +12,58 @@
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium">{{ PHASE_TITLE[phase] }}产物</span>
<NTag
size="tiny"
:type="phase === currentPhase ? 'primary' : 'default'"
>
<NTag size="tiny" :type="phase === currentPhase ? 'primary' : 'default'">
{{ artifactsByPhase[phase].length }}
</NTag>
<span
v-if="phase === currentPhase"
class="text-xs text-blue-500"
>当前</span>
<span v-if="phase === currentPhase" class="text-xs text-blue-500">当前</span>
</div>
</template>
<NList
v-if="artifactsByPhase[phase].length > 0"
:show-divider="false"
>
<NListItem
v-for="a in artifactsByPhase[phase]"
:key="a.id"
>
<NThing
class="cursor-pointer"
@click="toggleArtifact(a)"
>
<NList v-if="artifactsByPhase[phase].length > 0" :show-divider="false">
<NListItem v-for="a in artifactsByPhase[phase]" :key="a.id">
<NThing class="cursor-pointer" @click="toggleArtifact(a)">
<template #header>
<span
class="text-sm font-medium"
:class="selectedArtifact?.id === a.id ? 'text-blue-600' : ''"
>v{{ a.version }}</span>
<NTag
v-if="a.source_message_id"
size="tiny"
class="ml-1"
>v{{ a.version }}</span
>
AI
</NTag>
<NTag v-if="a.source_message_id" size="tiny" class="ml-1"> AI </NTag>
</template>
<template #header-extra>
<span class="text-xs text-gray-400">
{{ formatDate(a.created_at) }}
</span>
</template>
<span
v-if="a.note"
class="text-xs text-gray-500"
>{{ a.note }}</span>
<span v-if="a.note" class="text-xs text-gray-500">{{ a.note }}</span>
</NThing>
</NListItem>
</NList>
<NEmpty
v-else
description="暂无产物"
size="small"
/>
<NEmpty v-else description="暂无产物" size="small" />
<!-- 选中产物内容预览 -->
<div
v-if="selectedArtifact && selectedArtifact.phase === phase"
class="mt-3"
>
<div v-if="selectedArtifact && selectedArtifact.phase === phase" class="mt-3">
<NDivider />
<!-- eslint-disable-next-line vue/no-v-html -->
<div
class="prose prose-sm max-w-none"
v-html="renderedArtifact"
/>
<!-- eslint-disable vue/no-v-html -->
<div class="prose prose-sm max-w-none" v-html="renderedArtifact" />
<!-- eslint-enable vue/no-v-html -->
</div>
</NCollapseItem>
<!-- AI 对话 -->
<NCollapseItem
name="conversations"
:disabled="conversations.length === 0"
>
<NCollapseItem name="conversations" :disabled="conversations.length === 0">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium">AI 对话</span>
<NTag size="tiny">{{ conversations.length }}</NTag>
<NTag size="tiny">
{{ conversations.length }}
</NTag>
</div>
</template>
<NList
v-if="conversations.length > 0"
:show-divider="false"
>
<NListItem
v-for="c in conversations"
:key="c.id"
>
<NThing
class="cursor-pointer"
@click="goConversation(c)"
>
<NList v-if="conversations.length > 0" :show-divider="false">
<NListItem v-for="c in conversations" :key="c.id">
<NThing class="cursor-pointer" @click="goConversation(c)">
<template #header>
<span
class="text-sm font-medium"
@@ -125,33 +80,22 @@
</NThing>
</NListItem>
</NList>
<NEmpty
v-else
description="暂无对话"
size="small"
/>
<NEmpty v-else description="暂无对话" size="small" />
</NCollapseItem>
<!-- ACP 会话 -->
<NCollapseItem
name="acp-sessions"
:disabled="acpSessions.length === 0"
>
<NCollapseItem name="acp-sessions" :disabled="acpSessions.length === 0">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium">ACP 会话</span>
<NTag size="tiny">{{ acpSessions.length }}</NTag>
<NTag size="tiny">
{{ acpSessions.length }}
</NTag>
</div>
</template>
<NList
v-if="acpSessions.length > 0"
:show-divider="false"
>
<NListItem
v-for="s in acpSessions"
:key="s.id"
>
<NList v-if="acpSessions.length > 0" :show-divider="false">
<NListItem v-for="s in acpSessions" :key="s.id">
<NThing
:class="workspaceSlug ? 'cursor-pointer' : ''"
@click="workspaceSlug ? goAcpSession(s) : undefined"
@@ -169,25 +113,17 @@
</NThing>
</NListItem>
</NList>
<NEmpty
v-else
description="暂无会话"
size="small"
/>
<NEmpty v-else description="暂无会话" size="small" />
</NCollapseItem>
</NCollapse>
<NEmpty
v-else-if="!loading"
description="暂无历史产物与会话"
size="small"
/>
<NEmpty v-else-if="!loading" description="暂无历史产物与会话" size="small" />
</NSpin>
</NCard>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
NCard,
@@ -201,11 +137,12 @@ import {
NTag,
NThing,
} from 'naive-ui'
import MarkdownIt from 'markdown-it'
import { chatApi, type Conversation } from '@/api/chat'
import { acpApi, type AcpSession } from '@/api/acp'
import type { Artifact, ArtifactPhase, Phase } from '@/api/projects'
import { md } from '@/utils/markdown'
import { formatDate, formatDateTime } from '@/utils/date'
import { useRequirementsStore } from '@/stores/requirements'
import SessionStatusBadge from '@/components/acp/SessionStatusBadge.vue'
@@ -227,7 +164,6 @@ const props = defineProps<{
const router = useRouter()
const store = useRequirementsStore()
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
const loading = ref(false)
const artifacts = ref<Artifact[]>([])
@@ -255,26 +191,37 @@ const artifactsByPhase = computed(() => {
return map
})
const hasAnyContent = computed(() =>
artifacts.value.length > 0 || conversations.value.length > 0 || acpSessions.value.length > 0,
const hasAnyContent = computed(
() =>
artifacts.value.length > 0 || conversations.value.length > 0 || acpSessions.value.length > 0,
)
// 默认展开有内容的第一项(优先当前阶段)
const defaultExpandedNames = computed(() => {
const names: (ArtifactPhase | 'conversations' | 'acp-sessions')[] = []
if (props.currentPhase && (ARTIFACT_PHASES as Phase[]).includes(props.currentPhase)) {
const cp = props.currentPhase as ArtifactPhase
if (artifactsByPhase.value[cp].length > 0) names.push(cp)
}
for (const phase of ARTIFACT_PHASES) {
if (artifactsByPhase.value[phase].length > 0 && !names.includes(phase)) {
names.push(phase)
const defaultExpandedNames = ref<(ArtifactPhase | 'conversations' | 'acp-sessions')[]>([])
watch(
[
() => props.currentPhase,
() => artifacts.value.length,
() => conversations.value.length,
() => acpSessions.value.length,
],
() => {
const names: (ArtifactPhase | 'conversations' | 'acp-sessions')[] = []
if (props.currentPhase && (ARTIFACT_PHASES as Phase[]).includes(props.currentPhase)) {
const cp = props.currentPhase as ArtifactPhase
if (artifactsByPhase.value[cp].length > 0) names.push(cp)
}
}
if (conversations.value.length > 0) names.push('conversations')
if (acpSessions.value.length > 0) names.push('acp-sessions')
return names.slice(0, 2) // 最多默认展开前两项,避免过长
})
for (const phase of ARTIFACT_PHASES) {
if (artifactsByPhase.value[phase].length > 0 && !names.includes(phase)) {
names.push(phase)
}
}
if (conversations.value.length > 0) names.push('conversations')
if (acpSessions.value.length > 0) names.push('acp-sessions')
defaultExpandedNames.value = names.slice(0, 2) // 最多默认展开前两项,避免过长
},
{ immediate: true },
)
const renderedArtifact = computed(() =>
selectedArtifact.value ? md.render(selectedArtifact.value.content) : '',
@@ -300,14 +247,6 @@ function goAcpSession(s: AcpSession) {
})
}
function formatDate(d: string) {
return new Date(d).toLocaleDateString('zh-CN')
}
function formatDateTime(d: string) {
return new Date(d).toLocaleString('zh-CN', { hour12: false })
}
async function refresh() {
loading.value = true
try {
@@ -1,48 +1,28 @@
<template>
<NCard title="产物汇总">
<NSpin :show="loading">
<div
v-if="grouped.length > 0"
class="space-y-4"
>
<div
v-for="g in grouped"
:key="g.phase"
>
<div v-if="grouped.length > 0" class="space-y-4">
<div v-for="g in grouped" :key="g.phase">
<h3 class="text-sm font-semibold mb-2">
{{ PHASE_TITLE[g.phase] }}{{ g.items.length }} 个版本
</h3>
<NList :show-divider="false">
<NListItem
v-for="a in g.items"
:key="a.id"
>
<NThing
class="cursor-pointer"
@click="selected = selected?.id === a.id ? null : a"
>
<NListItem v-for="a in g.items" :key="a.id">
<NThing class="cursor-pointer" @click="selected = selected?.id === a.id ? null : a">
<template #header>
<span
class="text-sm font-medium"
:class="selected?.id === a.id ? 'text-blue-600' : ''"
>v{{ a.version }}</span>
<NTag
v-if="a.source_message_id"
size="tiny"
class="ml-1"
>v{{ a.version }}</span
>
AI
</NTag>
<NTag v-if="a.source_message_id" size="tiny" class="ml-1"> AI </NTag>
</template>
<template #header-extra>
<span class="text-xs text-gray-400">
{{ new Date(a.created_at).toLocaleString('zh-CN', { hour12: false }) }}
{{ formatDateTime(a.created_at) }}
</span>
</template>
<span
v-if="a.note"
class="text-xs text-gray-500"
>{{ a.note }}</span>
<span v-if="a.note" class="text-xs text-gray-500">{{ a.note }}</span>
</NThing>
</NListItem>
</NList>
@@ -50,17 +30,12 @@
<template v-if="selected">
<NDivider />
<!-- eslint-disable-next-line vue/no-v-html -->
<div
class="prose prose-sm max-w-none"
v-html="rendered"
/>
<!-- eslint-disable vue/no-v-html -->
<div class="prose prose-sm max-w-none" v-html="rendered" />
<!-- eslint-enable vue/no-v-html -->
</template>
</div>
<NEmpty
v-else-if="!loading"
description="暂无任何阶段产物"
/>
<NEmpty v-else-if="!loading" description="暂无任何阶段产物" />
</NSpin>
</NCard>
</template>
@@ -68,9 +43,9 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { NCard, NDivider, NEmpty, NList, NListItem, NSpin, NTag, NThing } from 'naive-ui'
import MarkdownIt from 'markdown-it'
import type { Artifact, ArtifactPhase } from '@/api/projects'
import { md } from '@/utils/markdown'
import { formatDateTime } from '@/utils/date'
import { useRequirementsStore } from '@/stores/requirements'
const PHASE_TITLE: Record<ArtifactPhase, string> = {
@@ -86,7 +61,6 @@ const props = defineProps<{
}>()
const store = useRequirementsStore()
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
const artifacts = ref<Artifact[]>([])
const loading = ref(false)
@@ -8,10 +8,7 @@
>
<div class="space-y-3">
<NTabs type="segment">
<NTabPane
name="edit"
tab="编辑"
>
<NTabPane name="edit" tab="编辑">
<NInput
v-model:value="content"
type="textarea"
@@ -19,31 +16,19 @@
:autosize="{ minRows: 12, maxRows: 24 }"
/>
</NTabPane>
<NTabPane
name="preview"
tab="预览"
>
<!-- eslint-disable-next-line vue/no-v-html -->
<NTabPane name="preview" tab="预览">
<!-- eslint-disable vue/no-v-html -->
<div
class="prose prose-sm max-w-none max-h-[60vh] overflow-y-auto border rounded p-3"
v-html="rendered"
/>
<!-- eslint-enable vue/no-v-html -->
</NTabPane>
</NTabs>
<NInput
v-model:value="note"
placeholder="版本备注(可选)"
/>
<NInput v-model:value="note" placeholder="版本备注(可选)" />
<div class="flex justify-end gap-2">
<NButton @click="emit('update:show', false)">
取消
</NButton>
<NButton
type="primary"
:loading="saving"
:disabled="!content.trim()"
@click="onSave"
>
<NButton @click="emit('update:show', false)"> 取消 </NButton>
<NButton type="primary" :loading="saving" :disabled="!content.trim()" @click="onSave">
保存为新版本
</NButton>
</div>
@@ -54,10 +39,10 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { NButton, NInput, NModal, NTabPane, NTabs, useMessage } from 'naive-ui'
import MarkdownIt from 'markdown-it'
import type { ArtifactPhase } from '@/api/projects'
import type { ChatMessage } from '@/api/chat'
import { md } from '@/utils/markdown'
import { useRequirementsStore } from '@/stores/requirements'
const PHASE_TITLE: Record<ArtifactPhase, string> = {
@@ -78,7 +63,6 @@ const emit = defineEmits<{ 'update:show': [v: boolean]; saved: [] }>()
const store = useRequirementsStore()
const message = useMessage()
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
const content = ref('')
const note = ref('')
@@ -67,17 +67,17 @@
</template>
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { onMounted, ref, computed, defineAsyncComponent } from 'vue'
import { useRoute } from 'vue-router'
import { NTabs, NTabPane, NPageHeader, useMessage } from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue'
import { useWorkspacesStore } from '@/stores/workspaces'
import SyncBar from './components/SyncBar.vue'
import WorktreesTab from './components/WorktreesTab.vue'
import MainTab from './components/MainTab.vue'
import CommitHistoryTab from './components/CommitHistoryTab.vue'
import SettingsTab from './components/SettingsTab.vue'
import RunsTab from './components/RunsTab.vue'
const WorktreesTab = defineAsyncComponent(() => import('./components/WorktreesTab.vue'))
const MainTab = defineAsyncComponent(() => import('./components/MainTab.vue'))
const CommitHistoryTab = defineAsyncComponent(() => import('./components/CommitHistoryTab.vue'))
const SettingsTab = defineAsyncComponent(() => import('./components/SettingsTab.vue'))
const RunsTab = defineAsyncComponent(() => import('./components/RunsTab.vue'))
const route = useRoute()
const store = useWorkspacesStore()
@@ -3,19 +3,8 @@
<!-- 目标选择main 或某个 worktree -->
<div class="flex items-center gap-3">
<span class="text-sm text-gray-500">查看目标</span>
<NSelect
v-model:value="target"
:options="targetOptions"
size="small"
class="w-64"
/>
<NButton
size="small"
:loading="loading"
@click="refresh"
>
刷新
</NButton>
<NSelect v-model:value="target" :options="targetOptions" size="small" class="w-64" />
<NButton size="small" :loading="loading" @click="refresh"> 刷新 </NButton>
</div>
<NDataTable
@@ -35,6 +24,7 @@ import type { DataTableColumns } from 'naive-ui'
import { workspacesApi } from '@/api/workspaces'
import { useWorkspacesStore } from '@/stores/workspaces'
import { formatDateTime } from '@/utils/date'
import type { CommitLog, Workspace } from '@/api/types'
const props = defineProps<{ ws: Workspace }>()
@@ -52,10 +42,6 @@ const targetOptions = computed(() => {
return opts
})
function formatDate(d: string) {
return new Date(d).toLocaleString('zh-CN', { hour12: false })
}
function shortHash(hash: string) {
return hash.slice(0, 8)
}
@@ -67,11 +53,7 @@ const columns = computed<DataTableColumns<CommitLog>>(() => [
width: 100,
fixed: 'left',
render(row: CommitLog) {
return h(
NTag,
{ size: 'tiny', type: 'default' },
{ default: () => shortHash(row.hash) },
)
return h(NTag, { size: 'tiny', type: 'default' }, { default: () => shortHash(row.hash) })
},
},
{
@@ -99,7 +81,7 @@ const columns = computed<DataTableColumns<CommitLog>>(() => [
key: 'date',
width: 160,
render(row: CommitLog) {
return h('span', { class: 'text-xs text-gray-500' }, formatDate(row.date))
return h('span', { class: 'text-xs text-gray-500' }, formatDateTime(row.date))
},
},
])
+1
View File
@@ -18,6 +18,7 @@
"@/*": ["./src/*"]
},
"baseUrl": ".",
"noEmit": true,
"ignoreDeprecations": "6.0"
},
"include": ["src/**/*", "test/**/*"],
+18
View File
@@ -20,5 +20,23 @@ export default defineConfig({
build: {
outDir: 'dist',
emptyOutDir: true,
sourcemap: true,
cssCodeSplit: true,
chunkSizeWarningLimit: 1000,
rollupOptions: {
output: {
manualChunks(id) {
if (
id.includes('node_modules/vue/') ||
id.includes('node_modules/vue-router/') ||
id.includes('node_modules/pinia/')
) {
return 'vendor'
}
if (id.includes('node_modules/naive-ui/')) return 'naive-ui'
if (id.includes('node_modules/markdown-it/')) return 'markdown'
},
},
},
},
})