You've already forked agentic-coding-workflow
feat(web): chat views (Home/Detail/CreateModal) + Bubble/Uploader/ModelSelector + markdown-it
This commit is contained in:
@@ -19,6 +19,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"packageManager": "pnpm@10.32.1",
|
"packageManager": "pnpm@10.32.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"markdown-it": "^14.1.1",
|
||||||
"naive-ui": "^2.44.1",
|
"naive-ui": "^2.44.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.33",
|
"vue": "^3.5.33",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tailwindcss/vite": "^4.2.4",
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
|
"@types/markdown-it": "^14.1.2",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
"@vue/test-utils": "^2.4.9",
|
"@vue/test-utils": "^2.4.9",
|
||||||
|
|||||||
Generated
+71
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
markdown-it:
|
||||||
|
specifier: ^14.1.1
|
||||||
|
version: 14.1.1
|
||||||
naive-ui:
|
naive-ui:
|
||||||
specifier: ^2.44.1
|
specifier: ^2.44.1
|
||||||
version: 2.44.1(vue@3.5.33(typescript@6.0.3))
|
version: 2.44.1(vue@3.5.33(typescript@6.0.3))
|
||||||
@@ -27,6 +30,9 @@ importers:
|
|||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.2.4
|
specifier: ^4.2.4
|
||||||
version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.3))
|
version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.3))
|
||||||
|
'@types/markdown-it':
|
||||||
|
specifier: ^14.1.2
|
||||||
|
version: 14.1.2
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^25.6.0
|
specifier: ^25.6.0
|
||||||
version: 25.6.0
|
version: 25.6.0
|
||||||
@@ -435,12 +441,21 @@ packages:
|
|||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
|
'@types/linkify-it@5.0.0':
|
||||||
|
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
||||||
|
|
||||||
'@types/lodash-es@4.17.12':
|
'@types/lodash-es@4.17.12':
|
||||||
resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
|
resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
|
||||||
|
|
||||||
'@types/lodash@4.17.24':
|
'@types/lodash@4.17.24':
|
||||||
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
|
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
|
||||||
|
|
||||||
|
'@types/markdown-it@14.1.2':
|
||||||
|
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
|
||||||
|
|
||||||
|
'@types/mdurl@2.0.0':
|
||||||
|
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
|
||||||
|
|
||||||
'@types/node@25.6.0':
|
'@types/node@25.6.0':
|
||||||
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
|
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
|
||||||
|
|
||||||
@@ -659,6 +674,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
argparse@2.0.1:
|
||||||
|
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||||
|
|
||||||
assertion-error@2.0.1:
|
assertion-error@2.0.1:
|
||||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -812,6 +830,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==}
|
resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
|
entities@4.5.0:
|
||||||
|
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
entities@7.0.1:
|
entities@7.0.1:
|
||||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
@@ -1117,6 +1139,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
|
|
||||||
|
linkify-it@5.0.0:
|
||||||
|
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
||||||
|
|
||||||
local-pkg@1.1.2:
|
local-pkg@1.1.2:
|
||||||
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
|
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -1141,6 +1166,13 @@ packages:
|
|||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
|
markdown-it@14.1.1:
|
||||||
|
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
mdurl@2.0.0:
|
||||||
|
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||||
|
|
||||||
minimatch@10.2.5:
|
minimatch@10.2.5:
|
||||||
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
@@ -1277,6 +1309,10 @@ packages:
|
|||||||
proto-list@1.2.4:
|
proto-list@1.2.4:
|
||||||
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
|
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
|
||||||
|
|
||||||
|
punycode.js@2.3.1:
|
||||||
|
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
punycode@2.3.1:
|
punycode@2.3.1:
|
||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1406,6 +1442,9 @@ packages:
|
|||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
uc.micro@2.1.0:
|
||||||
|
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||||
|
|
||||||
ufo@1.6.3:
|
ufo@1.6.3:
|
||||||
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
|
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
|
||||||
|
|
||||||
@@ -1910,12 +1949,21 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
|
'@types/linkify-it@5.0.0': {}
|
||||||
|
|
||||||
'@types/lodash-es@4.17.12':
|
'@types/lodash-es@4.17.12':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/lodash': 4.17.24
|
'@types/lodash': 4.17.24
|
||||||
|
|
||||||
'@types/lodash@4.17.24': {}
|
'@types/lodash@4.17.24': {}
|
||||||
|
|
||||||
|
'@types/markdown-it@14.1.2':
|
||||||
|
dependencies:
|
||||||
|
'@types/linkify-it': 5.0.0
|
||||||
|
'@types/mdurl': 2.0.0
|
||||||
|
|
||||||
|
'@types/mdurl@2.0.0': {}
|
||||||
|
|
||||||
'@types/node@25.6.0':
|
'@types/node@25.6.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.19.2
|
undici-types: 7.19.2
|
||||||
@@ -2217,6 +2265,8 @@ snapshots:
|
|||||||
|
|
||||||
ansi-styles@6.2.3: {}
|
ansi-styles@6.2.3: {}
|
||||||
|
|
||||||
|
argparse@2.0.1: {}
|
||||||
|
|
||||||
assertion-error@2.0.1: {}
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
ast-kit@2.2.0:
|
ast-kit@2.2.0:
|
||||||
@@ -2348,6 +2398,8 @@ snapshots:
|
|||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
tapable: 2.3.3
|
tapable: 2.3.3
|
||||||
|
|
||||||
|
entities@4.5.0: {}
|
||||||
|
|
||||||
entities@7.0.1: {}
|
entities@7.0.1: {}
|
||||||
|
|
||||||
es-module-lexer@2.1.0: {}
|
es-module-lexer@2.1.0: {}
|
||||||
@@ -2620,6 +2672,10 @@ snapshots:
|
|||||||
lightningcss-win32-arm64-msvc: 1.32.0
|
lightningcss-win32-arm64-msvc: 1.32.0
|
||||||
lightningcss-win32-x64-msvc: 1.32.0
|
lightningcss-win32-x64-msvc: 1.32.0
|
||||||
|
|
||||||
|
linkify-it@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
uc.micro: 2.1.0
|
||||||
|
|
||||||
local-pkg@1.1.2:
|
local-pkg@1.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
mlly: 1.8.2
|
mlly: 1.8.2
|
||||||
@@ -2644,6 +2700,17 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
markdown-it@14.1.1:
|
||||||
|
dependencies:
|
||||||
|
argparse: 2.0.1
|
||||||
|
entities: 4.5.0
|
||||||
|
linkify-it: 5.0.0
|
||||||
|
mdurl: 2.0.0
|
||||||
|
punycode.js: 2.3.1
|
||||||
|
uc.micro: 2.1.0
|
||||||
|
|
||||||
|
mdurl@2.0.0: {}
|
||||||
|
|
||||||
minimatch@10.2.5:
|
minimatch@10.2.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 5.0.5
|
brace-expansion: 5.0.5
|
||||||
@@ -2783,6 +2850,8 @@ snapshots:
|
|||||||
|
|
||||||
proto-list@1.2.4: {}
|
proto-list@1.2.4: {}
|
||||||
|
|
||||||
|
punycode.js@2.3.1: {}
|
||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
quansync@0.2.11: {}
|
quansync@0.2.11: {}
|
||||||
@@ -2901,6 +2970,8 @@ snapshots:
|
|||||||
|
|
||||||
typescript@6.0.3: {}
|
typescript@6.0.3: {}
|
||||||
|
|
||||||
|
uc.micro@2.1.0: {}
|
||||||
|
|
||||||
ufo@1.6.3: {}
|
ufo@1.6.3: {}
|
||||||
|
|
||||||
undici-types@7.19.2: {}
|
undici-types@7.19.2: {}
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<template>
|
||||||
|
<AppShell>
|
||||||
|
<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>
|
||||||
|
<div
|
||||||
|
v-for="c in chat.conversations"
|
||||||
|
:key="c.id"
|
||||||
|
:class="[
|
||||||
|
'p-2 rounded cursor-pointer hover:bg-neutral-100',
|
||||||
|
chat.currentConversation?.id === c.id ? 'bg-blue-50' : '',
|
||||||
|
]"
|
||||||
|
@click="open(c.id)"
|
||||||
|
>
|
||||||
|
<div class="truncate text-sm">
|
||||||
|
{{ c.title || '(未命名)' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-neutral-400">
|
||||||
|
{{ formatTime(c.updated_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="chat.conversations.length === 0"
|
||||||
|
class="text-xs text-neutral-400 px-2"
|
||||||
|
>
|
||||||
|
暂无对话
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧消息流 -->
|
||||||
|
<div class="flex-1 flex flex-col">
|
||||||
|
<div
|
||||||
|
v-if="!chat.currentConversation"
|
||||||
|
class="flex-1 flex items-center justify-center text-neutral-400"
|
||||||
|
>
|
||||||
|
选择或新建会话
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
ref="scrollEl"
|
||||||
|
class="flex-1 overflow-y-auto p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<MessageBubble
|
||||||
|
v-for="m in chat.messages"
|
||||||
|
:key="m.id"
|
||||||
|
:message="m"
|
||||||
|
@retry="onRetry"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="border-t p-3 space-y-2">
|
||||||
|
<AttachmentUploader
|
||||||
|
ref="uploaderRef"
|
||||||
|
:model-capabilities="currentModelCaps"
|
||||||
|
@change="onAttachmentChange"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<NInput
|
||||||
|
v-model:value="draft"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 1, maxRows: 6 }"
|
||||||
|
placeholder="发送消息(Enter 发送,Shift+Enter 换行)"
|
||||||
|
:disabled="chat.isStreaming"
|
||||||
|
@keydown.enter.exact.prevent="send"
|
||||||
|
/>
|
||||||
|
<NButton
|
||||||
|
v-if="!chat.isStreaming"
|
||||||
|
type="primary"
|
||||||
|
:disabled="!draft.trim()"
|
||||||
|
@click="send"
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
v-else
|
||||||
|
type="warning"
|
||||||
|
@click="chat.cancelMessage"
|
||||||
|
>
|
||||||
|
中断
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConversationCreateModal
|
||||||
|
v-model:show="showCreate"
|
||||||
|
:project-id="projectId"
|
||||||
|
@created="onCreated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { NButton, NInput } from 'naive-ui'
|
||||||
|
|
||||||
|
import AppShell from '@/layouts/AppShell.vue'
|
||||||
|
import { useChatStore } from '@/stores/chat'
|
||||||
|
import MessageBubble from './components/MessageBubble.vue'
|
||||||
|
import AttachmentUploader from './components/AttachmentUploader.vue'
|
||||||
|
import ConversationCreateModal from './ConversationCreateModal.vue'
|
||||||
|
import { llmAdminApi, type LLMModel, type ModelCapabilities } from '@/api/llmAdmin'
|
||||||
|
|
||||||
|
const chat = useChatStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const draft = ref('')
|
||||||
|
const attachmentIds = ref<string[]>([])
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const uploaderRef = ref<InstanceType<typeof AttachmentUploader> | null>(null)
|
||||||
|
const scrollEl = ref<HTMLElement | null>(null)
|
||||||
|
const allModels = ref<LLMModel[]>([])
|
||||||
|
|
||||||
|
const projectId = computed(() => {
|
||||||
|
const v = route.params.pid
|
||||||
|
return typeof v === 'string' && v !== '' ? v : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const defaultCaps: ModelCapabilities = {
|
||||||
|
image: false,
|
||||||
|
pdf: false,
|
||||||
|
audio: false,
|
||||||
|
reasoning: false,
|
||||||
|
tools: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentModelCaps = computed<ModelCapabilities>(() => {
|
||||||
|
const id = chat.currentConversation?.model_id
|
||||||
|
if (!id) return defaultCaps
|
||||||
|
const m = allModels.value.find((x) => x.id === id)
|
||||||
|
return m?.capabilities ?? defaultCaps
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
allModels.value = await llmAdminApi.listModels(true)
|
||||||
|
await chat.loadConversations({ project_id: projectId.value })
|
||||||
|
const cid = route.params.conversationId
|
||||||
|
if (typeof cid === 'string' && cid !== '') {
|
||||||
|
await open(cid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => chat.messages.length,
|
||||||
|
() => {
|
||||||
|
nextTick(() => {
|
||||||
|
const el = scrollEl.value
|
||||||
|
if (el) el.scrollTo(0, el.scrollHeight)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async function open(id: string) {
|
||||||
|
await chat.openConversation(id)
|
||||||
|
await router.replace({
|
||||||
|
path: projectId.value ? `/projects/${projectId.value}/chat/${id}` : `/chat/${id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
if (!draft.value.trim() || chat.isStreaming) return
|
||||||
|
const body = draft.value
|
||||||
|
const ids = [...attachmentIds.value]
|
||||||
|
draft.value = ''
|
||||||
|
uploaderRef.value?.clear()
|
||||||
|
attachmentIds.value = []
|
||||||
|
await chat.sendMessage(body, ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAttachmentChange(ids: string[]) {
|
||||||
|
attachmentIds.value = ids
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRetry(id: number) {
|
||||||
|
chat.retryMessage(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreated(id: string) {
|
||||||
|
await chat.loadConversations({ project_id: projectId.value })
|
||||||
|
await open(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string) {
|
||||||
|
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<ChatDetailView />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ChatDetailView from './ChatDetailView.vue'
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<NModal
|
||||||
|
:show="show"
|
||||||
|
preset="card"
|
||||||
|
title="新建对话"
|
||||||
|
style="width: 600px"
|
||||||
|
@update:show="update"
|
||||||
|
>
|
||||||
|
<NForm>
|
||||||
|
<NFormItem label="模型">
|
||||||
|
<ModelSelector v-model="form.model_id" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="System Prompt 模板">
|
||||||
|
<NSelect
|
||||||
|
v-model:value="form.template_id"
|
||||||
|
:options="templateOptions"
|
||||||
|
clearable
|
||||||
|
placeholder="可选;不选时使用下方自定义"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="自定义 System Prompt">
|
||||||
|
<NInput
|
||||||
|
v-model:value="form.system_prompt"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="(可选)覆盖模板内容"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="挂载到(可选)">
|
||||||
|
<div class="flex flex-col gap-2 w-full">
|
||||||
|
<NSelect
|
||||||
|
v-model:value="mountKind"
|
||||||
|
:options="mountKindOptions"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<NInput
|
||||||
|
v-if="mountKind"
|
||||||
|
v-model:value="mountID"
|
||||||
|
placeholder="UUID"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</NFormItem>
|
||||||
|
</NForm>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<NButton @click="update(false)">
|
||||||
|
取消
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
:loading="submitting"
|
||||||
|
:disabled="!form.model_id"
|
||||||
|
@click="submit"
|
||||||
|
>
|
||||||
|
创建
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { NButton, NForm, NFormItem, NInput, NModal, NSelect } from 'naive-ui'
|
||||||
|
|
||||||
|
import ModelSelector from './components/ModelSelector.vue'
|
||||||
|
import { chatApi, type CreateConversationBody } from '@/api/chat'
|
||||||
|
|
||||||
|
type MountKind = 'project' | 'workspace' | 'issue'
|
||||||
|
|
||||||
|
const props = defineProps<{ show: boolean; projectId?: string }>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:show': [v: boolean]
|
||||||
|
created: [conversationId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const show = computed(() => props.show)
|
||||||
|
|
||||||
|
const form = ref<{
|
||||||
|
model_id: string
|
||||||
|
template_id: string | null
|
||||||
|
system_prompt: string
|
||||||
|
}>({
|
||||||
|
model_id: '',
|
||||||
|
template_id: null,
|
||||||
|
system_prompt: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const mountKind = ref<MountKind | null>(null)
|
||||||
|
const mountID = ref('')
|
||||||
|
const templates = ref<{ label: string; value: string }[]>([])
|
||||||
|
const templateOptions = computed(() => templates.value)
|
||||||
|
const mountKindOptions: { label: string; value: MountKind }[] = [
|
||||||
|
{ label: 'Project', value: 'project' },
|
||||||
|
{ label: 'Workspace', value: 'workspace' },
|
||||||
|
{ label: 'Issue', value: 'issue' },
|
||||||
|
]
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const tpls = await chatApi.listTemplates()
|
||||||
|
templates.value = tpls.map((t) => ({
|
||||||
|
label: `[${t.scope}] ${t.name}`,
|
||||||
|
value: t.id,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.projectId,
|
||||||
|
(v) => {
|
||||||
|
if (v) {
|
||||||
|
mountKind.value = 'project'
|
||||||
|
mountID.value = v
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function update(v: boolean) {
|
||||||
|
emit('update:show', v)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!form.value.model_id) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const body: CreateConversationBody = { model_id: form.value.model_id }
|
||||||
|
if (form.value.template_id) body.template_id = form.value.template_id
|
||||||
|
if (form.value.system_prompt) body.system_prompt = form.value.system_prompt
|
||||||
|
if (mountKind.value && mountID.value) {
|
||||||
|
if (mountKind.value === 'project') body.project_id = mountID.value
|
||||||
|
else if (mountKind.value === 'workspace') body.workspace_id = mountID.value
|
||||||
|
else if (mountKind.value === 'issue') body.issue_id = mountID.value
|
||||||
|
}
|
||||||
|
const conv = await chatApi.createConversation(body)
|
||||||
|
emit('created', conv.id)
|
||||||
|
update(false)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex gap-2 items-center flex-wrap">
|
||||||
|
<NUpload
|
||||||
|
:custom-request="upload"
|
||||||
|
:show-file-list="false"
|
||||||
|
multiple
|
||||||
|
>
|
||||||
|
<NButton size="small">
|
||||||
|
添加附件
|
||||||
|
</NButton>
|
||||||
|
</NUpload>
|
||||||
|
<div
|
||||||
|
v-for="a in attachments"
|
||||||
|
:key="a.id"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { NUpload, NButton, useMessage } from 'naive-ui'
|
||||||
|
import type { UploadCustomRequestOptions } from 'naive-ui'
|
||||||
|
|
||||||
|
import { chatApi, type Attachment } from '@/api/chat'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelCapabilities?: { image: boolean; pdf: boolean; audio?: boolean }
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ change: [ids: string[]] }>()
|
||||||
|
|
||||||
|
const attachments = ref<Attachment[]>([])
|
||||||
|
const msg = useMessage()
|
||||||
|
|
||||||
|
async function upload(opts: UploadCustomRequestOptions) {
|
||||||
|
const f = opts.file.file
|
||||||
|
if (!f) {
|
||||||
|
opts.onError()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (f.type.startsWith('image/') && !props.modelCapabilities?.image) {
|
||||||
|
msg.error('当前模型不支持图片')
|
||||||
|
opts.onError()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (f.type === 'application/pdf' && !props.modelCapabilities?.pdf) {
|
||||||
|
msg.error('当前模型不支持 PDF')
|
||||||
|
opts.onError()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (f.size > 20 * 1024 * 1024) {
|
||||||
|
msg.error('单文件不能超过 20 MB')
|
||||||
|
opts.onError()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const a = await chatApi.uploadAttachment(f)
|
||||||
|
attachments.value.push(a)
|
||||||
|
opts.onFinish()
|
||||||
|
} catch (e) {
|
||||||
|
msg.error(e instanceof Error ? e.message : String(e))
|
||||||
|
opts.onError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id: string) {
|
||||||
|
attachments.value = attachments.value.filter((a) => a.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
attachments,
|
||||||
|
() => emit('change', attachments.value.map((a) => a.id)),
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
clear: () => {
|
||||||
|
attachments.value = []
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="['flex', message.role === 'user' ? 'justify-end' : 'justify-start']">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'rounded-lg p-3 max-w-2xl',
|
||||||
|
message.role === 'user' ? 'bg-blue-50' : 'bg-neutral-50',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="message.thinking"
|
||||||
|
class="mb-2"
|
||||||
|
>
|
||||||
|
<NCollapse>
|
||||||
|
<NCollapseItem
|
||||||
|
title="🧠 模型思考过程"
|
||||||
|
name="thinking"
|
||||||
|
>
|
||||||
|
<pre class="text-xs whitespace-pre-wrap">{{ message.thinking }}</pre>
|
||||||
|
</NCollapseItem>
|
||||||
|
</NCollapse>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="message.status === 'pending' && !message.content"
|
||||||
|
class="text-neutral-400"
|
||||||
|
>
|
||||||
|
…
|
||||||
|
</div>
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="prose prose-sm max-w-none"
|
||||||
|
v-html="rendered"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="message.status === 'error'"
|
||||||
|
class="text-red-500 text-xs mt-1 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span>⚠️ {{ message.error_message }}</span>
|
||||||
|
<NButton
|
||||||
|
size="tiny"
|
||||||
|
@click="emit('retry', message.id)"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="message.status === 'cancelled'"
|
||||||
|
class="text-orange-500 text-xs mt-1 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span>已取消</span>
|
||||||
|
<NButton
|
||||||
|
size="tiny"
|
||||||
|
@click="emit('retry', message.id)"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="message.prompt_tokens"
|
||||||
|
class="text-xs text-neutral-400 mt-1"
|
||||||
|
>
|
||||||
|
in {{ message.prompt_tokens }} · out {{ message.completion_tokens ?? 0 }}
|
||||||
|
<span v-if="message.thinking_tokens"> · think {{ message.thinking_tokens }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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'
|
||||||
|
|
||||||
|
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
|
||||||
|
const props = defineProps<{ message: ChatMessage }>()
|
||||||
|
const emit = defineEmits<{ retry: [id: number] }>()
|
||||||
|
const rendered = computed(() => md.render(props.message.content))
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<NSelect
|
||||||
|
:value="modelValue"
|
||||||
|
:options="options"
|
||||||
|
placeholder="选择模型"
|
||||||
|
@update:value="onChange"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="selected"
|
||||||
|
class="text-xs text-neutral-500 mt-1"
|
||||||
|
>
|
||||||
|
{{ capLabel }}({{ selected.context_window.toLocaleString() }} ctx)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { NSelect } from 'naive-ui'
|
||||||
|
|
||||||
|
import { llmAdminApi, type LLMModel } from '@/api/llmAdmin'
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue?: string }>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [v: string] }>()
|
||||||
|
|
||||||
|
const models = ref<LLMModel[]>([])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
models.value = await llmAdminApi.listModels(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = computed(() =>
|
||||||
|
models.value.map((m) => ({ label: m.display_name, value: m.id })),
|
||||||
|
)
|
||||||
|
const selected = computed(() =>
|
||||||
|
models.value.find((m) => m.id === props.modelValue),
|
||||||
|
)
|
||||||
|
const capLabel = computed(() => {
|
||||||
|
if (!selected.value) return ''
|
||||||
|
const c = selected.value.capabilities
|
||||||
|
const parts: string[] = []
|
||||||
|
if (c.image) parts.push('图片')
|
||||||
|
if (c.pdf) parts.push('PDF')
|
||||||
|
if (c.audio) parts.push('音频')
|
||||||
|
if (c.reasoning) parts.push('Thinking')
|
||||||
|
return parts.length > 0 ? parts.join(' · ') : '纯文本'
|
||||||
|
})
|
||||||
|
|
||||||
|
function onChange(v: string) {
|
||||||
|
emit('update:modelValue', v)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user