Version: 0.7.8.dev.260325
后端: 迁移了schedule_plan逻辑并探索了新的架构组织思路 删除了一些Codex测试时产生的单测文件 前端: 做了一些改进
This commit is contained in:
115
frontend/package-lock.json
generated
115
frontend/package-lock.json
generated
@@ -11,11 +11,14 @@
|
||||
"@vue/shared": "^3.5.0",
|
||||
"axios": "^1.8.0",
|
||||
"element-plus": "^2.9.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"markdown-it": "^14.1.0",
|
||||
"pinia": "^2.2.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^22.10.0",
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"typescript": "^5.7.0",
|
||||
@@ -824,6 +827,13 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@types/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
|
||||
@@ -835,16 +845,36 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/markdown-it": {
|
||||
"version": "14.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@types/markdown-it/-/markdown-it-14.1.2.tgz",
|
||||
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/linkify-it": "^5",
|
||||
"@types/mdurl": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -1094,6 +1124,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/async-validator": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
|
||||
@@ -1167,6 +1203,18 @@
|
||||
"vue": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
@@ -1454,17 +1502,37 @@
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.11.1",
|
||||
"resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
|
||||
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash-unified": {
|
||||
"version": "1.0.3",
|
||||
@@ -1477,6 +1545,29 @@
|
||||
"lodash-es": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.0.tgz",
|
||||
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
"entities": "^4.4.0",
|
||||
"linkify-it": "^5.0.0",
|
||||
"mdurl": "^2.0.0",
|
||||
"punycode.js": "^2.3.1",
|
||||
"uc.micro": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"markdown-it": "bin/markdown-it.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
@@ -1547,6 +1638,15 @@
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -1588,6 +1688,7 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -1601,6 +1702,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -1609,6 +1711,12 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
@@ -1622,6 +1730,7 @@
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -1835,6 +1944,7 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -1938,6 +2048,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
|
||||
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.30",
|
||||
"@vue/compiler-sfc": "3.5.30",
|
||||
|
||||
@@ -12,11 +12,14 @@
|
||||
"@vue/shared": "^3.5.0",
|
||||
"axios": "^1.8.0",
|
||||
"element-plus": "^2.9.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"markdown-it": "^14.1.0",
|
||||
"pinia": "^2.2.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^22.10.0",
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"typescript": "^5.7.0",
|
||||
|
||||
@@ -42,6 +42,19 @@ interface StreamEventPayload {
|
||||
error?: StreamErrorPayload
|
||||
}
|
||||
|
||||
type ModelType = 'worker' | 'strategist'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
initialHistoryWidth?: number
|
||||
viewMode?: 'embedded' | 'standalone'
|
||||
}>(),
|
||||
{
|
||||
initialHistoryWidth: 228,
|
||||
viewMode: 'embedded',
|
||||
},
|
||||
)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const assistantBodyRef = ref<HTMLElement | null>(null)
|
||||
@@ -52,10 +65,10 @@ const conversationLoadingMore = ref(false)
|
||||
const chatLoading = ref(false)
|
||||
const historyExpanded = ref(true)
|
||||
const selectedConversationId = ref('')
|
||||
const selectedModel = ref<'worker' | 'strategist'>('worker')
|
||||
const selectedModel = ref<ModelType>('worker')
|
||||
const thinkingEnabled = ref(false)
|
||||
const messageInput = ref('')
|
||||
const historyPanelWidth = ref(228)
|
||||
const historyPanelWidth = ref(props.initialHistoryWidth)
|
||||
const activeStreamingMessageId = ref('')
|
||||
|
||||
const conversationPage = ref(1)
|
||||
@@ -69,6 +82,8 @@ const conversationMessagesMap = reactive<Record<string, AssistantMessage[]>>({})
|
||||
const unavailableHistoryMap = reactive<Record<string, boolean>>({})
|
||||
const thinkingMessageMap = reactive<Record<string, boolean>>({})
|
||||
const reasoningCollapsedMap = reactive<Record<string, boolean>>({})
|
||||
const reasoningStartedAtMap = reactive<Record<string, number>>({})
|
||||
const reasoningDurationMap = reactive<Record<string, number>>({})
|
||||
|
||||
const quickActions = [
|
||||
'帮我梳理今天最重要的三件事',
|
||||
@@ -77,11 +92,23 @@ const quickActions = [
|
||||
'给我一个更稳妥的推进方案',
|
||||
]
|
||||
|
||||
let messageScrollRaf = 0
|
||||
const MODEL_PREFERENCE_STORAGE_KEY = 'smartflow.assistant.model.byConversation.v1'
|
||||
|
||||
const assistantBodyStyle = computed(() => ({
|
||||
'--assistant-history-width': `${historyExpanded.value ? historyPanelWidth.value : 68}px`,
|
||||
}))
|
||||
let messageScrollRaf = 0
|
||||
let reasoningTicker = 0
|
||||
const reasoningDisplayNow = ref(Date.now())
|
||||
|
||||
const isStandaloneMode = computed(() => props.viewMode === 'standalone')
|
||||
|
||||
const assistantBodyStyle = computed(() => {
|
||||
if (isStandaloneMode.value) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
'--assistant-history-width': `${historyExpanded.value ? historyPanelWidth.value : 68}px`,
|
||||
}
|
||||
})
|
||||
|
||||
const selectedConversation = computed(() =>
|
||||
conversationList.value.find((item) => item.conversation_id === selectedConversationId.value),
|
||||
@@ -136,6 +163,86 @@ const shouldShowHistoryFallback = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
function isModelType(value: unknown): value is ModelType {
|
||||
return value === 'worker' || value === 'strategist'
|
||||
}
|
||||
|
||||
function loadModelPreferenceMap() {
|
||||
if (typeof window === 'undefined') {
|
||||
return {} as Record<string, ModelType>
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(MODEL_PREFERENCE_STORAGE_KEY)
|
||||
if (!raw) {
|
||||
return {} as Record<string, ModelType>
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
const normalized: Record<string, ModelType> = {}
|
||||
const entries = typeof parsed === 'object' && parsed ? Object.entries(parsed) : []
|
||||
|
||||
// 1. 只接收结构合法且值在白名单内的记录,避免脏数据把模型值污染为非法字符串。
|
||||
// 2. 键为空字符串的记录直接丢弃,防止“新建会话未落库”场景写入无效索引。
|
||||
// 3. 解析失败时回退为空对象,不阻塞聊天主流程。
|
||||
for (const [conversationId, model] of entries) {
|
||||
if (!conversationId || !isModelType(model)) {
|
||||
continue
|
||||
}
|
||||
normalized[conversationId] = model
|
||||
}
|
||||
|
||||
return normalized
|
||||
} catch {
|
||||
return {} as Record<string, ModelType>
|
||||
}
|
||||
}
|
||||
|
||||
const modelPreferenceMap = ref<Record<string, ModelType>>(loadModelPreferenceMap())
|
||||
|
||||
function persistModelPreferenceMap() {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(MODEL_PREFERENCE_STORAGE_KEY, JSON.stringify(modelPreferenceMap.value))
|
||||
} catch {
|
||||
// 1. 本地存储失败只影响“记忆体验”,不影响消息收发主链路。
|
||||
// 2. 这里静默处理,避免用户每次切模型都被错误提示打断。
|
||||
// 3. 若用户清理缓存或隐私模式限制写入,后续会自动退化为会话内临时选择。
|
||||
}
|
||||
}
|
||||
|
||||
function savePreferredModel(conversationId: string, model: ModelType) {
|
||||
if (!conversationId || modelPreferenceMap.value[conversationId] === model) {
|
||||
return
|
||||
}
|
||||
|
||||
modelPreferenceMap.value = {
|
||||
...modelPreferenceMap.value,
|
||||
[conversationId]: model,
|
||||
}
|
||||
persistModelPreferenceMap()
|
||||
}
|
||||
|
||||
function resolvePreferredModel(conversationId: string) {
|
||||
if (!conversationId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return modelPreferenceMap.value[conversationId] ?? null
|
||||
}
|
||||
|
||||
function applyPreferredModelForConversation(conversationId: string) {
|
||||
const preferredModel = resolvePreferredModel(conversationId)
|
||||
if (!preferredModel || preferredModel === selectedModel.value) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedModel.value = preferredModel
|
||||
}
|
||||
|
||||
function ensureConversationBucket(conversationId: string) {
|
||||
if (!conversationMessagesMap[conversationId]) {
|
||||
conversationMessagesMap[conversationId] = []
|
||||
@@ -196,6 +303,16 @@ function migrateConversationState(fromConversationId: string, toConversationId:
|
||||
delete conversationMetaMap[fromConversationId]
|
||||
}
|
||||
|
||||
if (modelPreferenceMap.value[fromConversationId]) {
|
||||
const migratedModelMap = { ...modelPreferenceMap.value }
|
||||
if (!migratedModelMap[toConversationId]) {
|
||||
migratedModelMap[toConversationId] = migratedModelMap[fromConversationId]!
|
||||
}
|
||||
delete migratedModelMap[fromConversationId]
|
||||
modelPreferenceMap.value = migratedModelMap
|
||||
persistModelPreferenceMap()
|
||||
}
|
||||
|
||||
const latestMap = new Map<string, ConversationListItem>()
|
||||
const deduplicated: ConversationListItem[] = []
|
||||
const seen = new Set<string>()
|
||||
@@ -276,7 +393,7 @@ function normalizeHistoryMessage(message: ConversationHistoryMessage, index: num
|
||||
reasoning: message.reasoning_content,
|
||||
}
|
||||
|
||||
thinkingMessageMap[id] = Boolean(message.reasoning_content?.trim())
|
||||
thinkingMessageMap[id] = false
|
||||
reasoningCollapsedMap[id] = Boolean(message.reasoning_content?.trim())
|
||||
return normalized
|
||||
}
|
||||
@@ -293,6 +410,47 @@ function isThinkingMessage(message: AssistantMessage) {
|
||||
return thinkingMessageMap[message.id] === true
|
||||
}
|
||||
|
||||
function markReasoningStart(message: AssistantMessage) {
|
||||
if (reasoningStartedAtMap[message.id]) {
|
||||
return
|
||||
}
|
||||
|
||||
const parsedCreatedAt = Date.parse(message.createdAt)
|
||||
reasoningStartedAtMap[message.id] = Number.isFinite(parsedCreatedAt) ? parsedCreatedAt : Date.now()
|
||||
}
|
||||
|
||||
function markReasoningFinished(message: AssistantMessage) {
|
||||
const startedAt = reasoningStartedAtMap[message.id]
|
||||
if (startedAt && !reasoningDurationMap[message.id]) {
|
||||
reasoningDurationMap[message.id] = Math.max(1, Math.round((Date.now() - startedAt) / 1000))
|
||||
}
|
||||
|
||||
thinkingMessageMap[message.id] = false
|
||||
}
|
||||
|
||||
function getReasoningDurationSeconds(message: AssistantMessage) {
|
||||
const fixedDuration = reasoningDurationMap[message.id]
|
||||
if (fixedDuration) {
|
||||
return fixedDuration
|
||||
}
|
||||
|
||||
const startedAt = reasoningStartedAtMap[message.id]
|
||||
if (!startedAt) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.max(1, Math.round((reasoningDisplayNow.value - startedAt) / 1000))
|
||||
}
|
||||
|
||||
function getReasoningStatusLabel(message: AssistantMessage) {
|
||||
const durationSeconds = getReasoningDurationSeconds(message)
|
||||
if (durationSeconds > 0) {
|
||||
return `已思考(用时 ${durationSeconds} 秒)`
|
||||
}
|
||||
|
||||
return isStreamingMessage(message) && isThinkingMessage(message) ? '思考中' : '已思考'
|
||||
}
|
||||
|
||||
function isReasoningCollapsed(messageId: string) {
|
||||
return reasoningCollapsedMap[messageId] === true
|
||||
}
|
||||
@@ -308,6 +466,10 @@ function shouldShowReasoningBox(message: AssistantMessage) {
|
||||
)
|
||||
}
|
||||
|
||||
function shouldShowAnsweringIndicator(message: AssistantMessage) {
|
||||
return isStreamingMessage(message) && !isThinkingMessage(message) && !message.content.trim()
|
||||
}
|
||||
|
||||
function scheduleScrollMessagesToBottom(smooth = false) {
|
||||
if (messageScrollRaf) {
|
||||
cancelAnimationFrame(messageScrollRaf)
|
||||
@@ -395,7 +557,7 @@ function handleHistoryScroll(event: Event) {
|
||||
// 3. 拖拽结束后统一解绑事件并清理全局样式,防止页面残留 col-resize 状态。
|
||||
function startResizeHistoryPanel(event: PointerEvent) {
|
||||
const body = assistantBodyRef.value
|
||||
if (!body || window.innerWidth <= 960 || !historyExpanded.value) {
|
||||
if (isStandaloneMode.value || !body || window.innerWidth <= 960 || !historyExpanded.value) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -463,6 +625,7 @@ async function ensureConversationMeta(conversationId: string) {
|
||||
|
||||
async function selectConversation(conversationId: string) {
|
||||
selectedConversationId.value = conversationId
|
||||
applyPreferredModelForConversation(conversationId)
|
||||
await Promise.allSettled([loadConversationMessages(conversationId), ensureConversationMeta(conversationId)])
|
||||
scheduleScrollMessagesToBottom(false)
|
||||
}
|
||||
@@ -542,6 +705,9 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) {
|
||||
}
|
||||
|
||||
if (payload === '[DONE]') {
|
||||
if (isThinkingMessage(assistantMessage)) {
|
||||
markReasoningFinished(assistantMessage)
|
||||
}
|
||||
activeStreamingMessageId.value = ''
|
||||
reasoningCollapsedMap[assistantMessage.id] = true
|
||||
return
|
||||
@@ -562,16 +728,30 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) {
|
||||
const delta = choice?.delta ?? parsed.delta ?? parsed
|
||||
const finishReason = choice?.finish_reason ?? parsed.finish_reason ?? null
|
||||
|
||||
if (typeof delta?.reasoning_content === 'string' && delta.reasoning_content) {
|
||||
if (
|
||||
typeof delta?.reasoning_content === 'string' &&
|
||||
delta.reasoning_content &&
|
||||
!assistantMessage.content.trim()
|
||||
) {
|
||||
markReasoningStart(assistantMessage)
|
||||
assistantMessage.reasoning = `${assistantMessage.reasoning || ''}${delta.reasoning_content}`
|
||||
thinkingMessageMap[assistantMessage.id] = true
|
||||
}
|
||||
|
||||
if (typeof delta?.content === 'string' && delta.content) {
|
||||
if (isThinkingMessage(assistantMessage)) {
|
||||
// 1. 一旦正文开始回流,立刻结束“思考中”阶段,避免两个等待动画同时出现。
|
||||
// 2. 这样视觉上始终保持“先思考,再输出正文”的单阶段感知。
|
||||
// 3. 若后端偶发交错发送 reasoning/content,也以前端阶段机兜底,优先保证阅读一致性。
|
||||
markReasoningFinished(assistantMessage)
|
||||
}
|
||||
assistantMessage.content += delta.content
|
||||
}
|
||||
|
||||
if (finishReason) {
|
||||
if (isThinkingMessage(assistantMessage)) {
|
||||
markReasoningFinished(assistantMessage)
|
||||
}
|
||||
activeStreamingMessageId.value = ''
|
||||
reasoningCollapsedMap[assistantMessage.id] = true
|
||||
}
|
||||
@@ -596,6 +776,7 @@ async function sendMessage(preset?: string) {
|
||||
if (!selectedConversationId.value) {
|
||||
selectedConversationId.value = draftConversationId
|
||||
}
|
||||
savePreferredModel(draftConversationId, selectedModel.value)
|
||||
|
||||
ensureConversationBucket(draftConversationId)
|
||||
unavailableHistoryMap[draftConversationId] = false
|
||||
@@ -691,7 +872,21 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
selectedModel,
|
||||
(nextModel) => {
|
||||
const conversationId = selectedConversationId.value
|
||||
if (!conversationId) {
|
||||
return
|
||||
}
|
||||
savePreferredModel(conversationId, nextModel)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
reasoningTicker = window.setInterval(() => {
|
||||
reasoningDisplayNow.value = Date.now()
|
||||
}, 1000)
|
||||
await loadConversationListData(true)
|
||||
})
|
||||
|
||||
@@ -699,12 +894,16 @@ onBeforeUnmount(() => {
|
||||
if (messageScrollRaf) {
|
||||
cancelAnimationFrame(messageScrollRaf)
|
||||
}
|
||||
if (reasoningTicker) {
|
||||
window.clearInterval(reasoningTicker)
|
||||
reasoningTicker = 0
|
||||
}
|
||||
document.body.classList.remove('dashboard-resizing')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="assistant-shell glass-panel">
|
||||
<aside class="assistant-shell glass-panel" :class="{ 'assistant-shell--standalone': isStandaloneMode }">
|
||||
<header class="assistant-header">
|
||||
<div class="assistant-header__text">
|
||||
<span class="assistant-header__eyebrow">AI 对话</span>
|
||||
@@ -717,7 +916,10 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
ref="assistantBodyRef"
|
||||
class="assistant-body"
|
||||
:class="{ 'assistant-body--collapsed': !historyExpanded }"
|
||||
:class="{
|
||||
'assistant-body--collapsed': !historyExpanded,
|
||||
'assistant-body--standalone': isStandaloneMode,
|
||||
}"
|
||||
:style="assistantBodyStyle"
|
||||
>
|
||||
<aside class="assistant-history" :class="{ 'assistant-history--collapsed': !historyExpanded }">
|
||||
@@ -764,7 +966,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div
|
||||
class="assistant-splitter"
|
||||
:class="{ 'assistant-splitter--hidden': !historyExpanded }"
|
||||
:class="{ 'assistant-splitter--hidden': !historyExpanded || isStandaloneMode }"
|
||||
role="separator"
|
||||
aria-label="调整会话列表宽度"
|
||||
@pointerdown.prevent="startResizeHistoryPanel"
|
||||
@@ -801,26 +1003,50 @@ onBeforeUnmount(() => {
|
||||
<div v-if="shouldShowReasoningBox(message)" class="chat-message__reasoning">
|
||||
<div class="chat-message__reasoning-head">
|
||||
<div class="chat-message__reasoning-title">
|
||||
<span class="chat-message__reasoning-dot" />
|
||||
<strong>{{ isStreamingMessage(message) ? '深度思考中' : '深度思考' }}</strong>
|
||||
<span class="chat-message__reasoning-icon">
|
||||
<svg
|
||||
class="chat-message__reasoning-icon-svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M8.00195 6.64454C8.75029 6.64454 9.35735 7.25169 9.35742 8.00001C9.35742 8.74838 8.75033 9.35548 8.00195 9.35548C7.2537 9.35533 6.64746 8.74829 6.64746 8.00001C6.64753 7.25178 7.25374 6.64468 8.00195 6.64454Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M9.97168 1.29981C11.5854 0.718916 13.271 0.642197 14.3145 1.68555C15.3578 2.72902 15.2811 4.41466 14.7002 6.02833C14.4708 6.66561 14.1505 7.32937 13.75 8.00001C14.1505 8.67062 14.4708 9.33444 14.7002 9.97169C15.2811 11.5854 15.3579 13.271 14.3145 14.3145C13.271 15.3579 11.5854 15.2811 9.97168 14.7002C9.33443 14.4708 8.67062 14.1505 8 13.75C7.32936 14.1505 6.66561 14.4708 6.02832 14.7002C4.41464 15.2811 2.72902 15.3578 1.68555 14.3145C0.642186 13.271 0.718901 11.5854 1.29981 9.97169C1.52918 9.33454 1.84868 8.67049 2.24902 8.00001C1.84869 7.32953 1.52918 6.66544 1.29981 6.02833C0.718882 4.41459 0.6421 2.729 1.68555 1.68555C2.729 0.642112 4.41459 0.718887 6.02832 1.29981C6.66544 1.52918 7.32953 1.8487 8 2.24903C8.67048 1.84869 9.33454 1.52919 9.97168 1.29981ZM12.9404 9.2129C12.4391 9.893 11.8616 10.5681 11.2148 11.2149C10.5681 11.8616 9.89299 12.4391 9.21289 12.9404C9.62535 13.1579 10.0271 13.338 10.4121 13.4766C11.9146 14.0174 12.9173 13.8738 13.3955 13.3955C13.8737 12.9173 14.0174 11.9146 13.4766 10.4121C13.338 10.0271 13.1579 9.62535 12.9404 9.2129ZM3.05859 9.2129C2.84124 9.62523 2.662 10.0272 2.52344 10.4121C1.98255 11.9146 2.1263 12.9172 2.60449 13.3955C3.08281 13.8737 4.08548 14.0174 5.58789 13.4766C5.97267 13.338 6.37392 13.1577 6.78613 12.9404C6.10627 12.4393 5.43171 11.8614 4.78516 11.2149C4.13826 10.5679 3.55995 9.89313 3.05859 9.2129ZM7.99902 3.792C7.23182 4.31419 6.45309 4.95512 5.7041 5.70411C4.95512 6.45309 4.31418 7.23184 3.79199 7.99903C4.31434 8.76666 4.95474 9.54653 5.7041 10.2959C6.45312 11.0449 7.23274 11.6848 8 12.207C8.76728 11.6848 9.54686 11.0449 10.2959 10.2959C11.0449 9.54686 11.6848 8.76729 12.207 8.00001C11.6848 7.23275 11.0449 6.45312 10.2959 5.70411C9.54653 4.95475 8.76665 4.31434 7.99902 3.792ZM5.58789 2.52344C4.08536 1.98255 3.08275 2.12625 2.60449 2.6045C2.12624 3.08275 1.98255 4.08536 2.52344 5.5879C2.66192 5.97253 2.84143 6.37409 3.05859 6.78614C3.55986 6.10611 4.13843 5.43189 4.78516 4.78516C5.4319 4.13843 6.10609 3.55987 6.78613 3.0586C6.37408 2.84144 5.97252 2.66192 5.58789 2.52344ZM13.3955 2.6045C12.9172 2.12631 11.9146 1.98257 10.4121 2.52344C10.0272 2.66201 9.62522 2.84125 9.21289 3.0586C9.89313 3.55996 10.5679 4.13827 11.2148 4.78516C11.8614 5.43172 12.4392 6.10627 12.9404 6.78614C13.1577 6.37393 13.338 5.97267 13.4766 5.5879C14.0174 4.08549 13.8736 3.08281 13.3955 2.6045Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<strong>{{ getReasoningStatusLabel(message) }}</strong>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="chat-message__reasoning-toggle"
|
||||
:aria-label="isReasoningCollapsed(message.id) ? '展开深度思考' : '折叠深度思考'"
|
||||
@click="toggleReasoningCollapse(message.id)"
|
||||
>
|
||||
{{ isReasoningCollapsed(message.id) ? '展开' : '折叠' }}
|
||||
<span
|
||||
class="chat-message__reasoning-chevron"
|
||||
:class="{ 'chat-message__reasoning-chevron--collapsed': isReasoningCollapsed(message.id) }"
|
||||
>
|
||||
⌄
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!isReasoningCollapsed(message.id)">
|
||||
<div v-if="!isReasoningCollapsed(message.id)" class="chat-message__reasoning-body">
|
||||
<div
|
||||
v-if="message.reasoning"
|
||||
class="chat-message__markdown chat-message__markdown--reasoning"
|
||||
v-html="renderMessageMarkdown(message.reasoning)"
|
||||
/>
|
||||
<div v-else class="chat-message__streaming">
|
||||
<span>正在接收 reasoning 增量...</span>
|
||||
<div v-else class="chat-message__streaming chat-message__streaming--reasoning">
|
||||
<div class="typing-indicator">
|
||||
<span />
|
||||
<span />
|
||||
@@ -833,8 +1059,7 @@ onBeforeUnmount(() => {
|
||||
<div v-if="message.content" class="chat-message__assistant-content">
|
||||
<div class="chat-message__markdown chat-message__markdown--assistant" v-html="renderMessageMarkdown(message.content)" />
|
||||
</div>
|
||||
<div v-else-if="isStreamingMessage(message)" class="chat-message__streaming chat-message__streaming--plain">
|
||||
<span>{{ message.reasoning ? '正在生成正文内容...' : '正在建立连接...' }}</span>
|
||||
<div v-else-if="shouldShowAnsweringIndicator(message)" class="chat-message__streaming chat-message__streaming--plain">
|
||||
<div class="typing-indicator">
|
||||
<span />
|
||||
<span />
|
||||
@@ -887,13 +1112,20 @@ onBeforeUnmount(() => {
|
||||
深度思考
|
||||
</button>
|
||||
|
||||
<label class="assistant-toolbar__pill assistant-toolbar__pill--select">
|
||||
<span>模型</span>
|
||||
<select v-model="selectedModel" class="assistant-toolbar__select">
|
||||
<option value="worker">标准</option>
|
||||
<option value="strategist">策略</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="assistant-toolbar__pill assistant-toolbar__pill--select">
|
||||
<span class="assistant-toolbar__select-label">模型</span>
|
||||
<el-select
|
||||
v-model="selectedModel"
|
||||
class="assistant-toolbar__select-box"
|
||||
size="small"
|
||||
popper-class="assistant-model-select-panel"
|
||||
placement="top-start"
|
||||
:teleported="true"
|
||||
>
|
||||
<el-option value="worker" label="标准" />
|
||||
<el-option value="strategist" label="策略" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -915,6 +1147,41 @@ onBeforeUnmount(() => {
|
||||
font-family: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Segoe UI Variable Text', sans-serif;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone {
|
||||
border-radius: 18px;
|
||||
border-color: rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-header,
|
||||
.assistant-shell--standalone .assistant-history__toolbar,
|
||||
.assistant-shell--standalone .assistant-actions,
|
||||
.assistant-shell--standalone .assistant-composer,
|
||||
.assistant-shell--standalone .assistant-toolbar {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-header {
|
||||
padding: 14px 18px 12px;
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
||||
background: #fafbfd;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-header__eyebrow {
|
||||
background: rgba(57, 99, 213, 0.1);
|
||||
color: #315ec2;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-header strong {
|
||||
margin-top: 8px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-header p {
|
||||
color: #7e8a9f;
|
||||
}
|
||||
|
||||
.assistant-header,
|
||||
.assistant-history__toolbar,
|
||||
.assistant-actions,
|
||||
@@ -984,6 +1251,14 @@ onBeforeUnmount(() => {
|
||||
grid-template-columns: 68px 0 minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.assistant-body--standalone {
|
||||
grid-template-columns: minmax(212px, 1fr) 8px minmax(0, 5fr);
|
||||
}
|
||||
|
||||
.assistant-body--standalone.assistant-body--collapsed {
|
||||
grid-template-columns: 68px 0 minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.assistant-history {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -1067,6 +1342,17 @@ onBeforeUnmount(() => {
|
||||
background: linear-gradient(180deg, #f5f9ff, #eef5ff);
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-history {
|
||||
background: linear-gradient(180deg, #f8f9fc 0%, #f4f6fa 100%);
|
||||
border-right: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-history__item--active {
|
||||
border-color: rgba(49, 96, 202, 0.2);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 6px 16px rgba(36, 67, 127, 0.08);
|
||||
}
|
||||
|
||||
.assistant-history--collapsed .assistant-history__new,
|
||||
.assistant-history--collapsed .assistant-history__item {
|
||||
padding: 10px;
|
||||
@@ -1130,6 +1416,10 @@ onBeforeUnmount(() => {
|
||||
grid-template-rows: minmax(0, 1fr) auto auto auto;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-chat {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.assistant-messages {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@@ -1142,6 +1432,12 @@ onBeforeUnmount(() => {
|
||||
radial-gradient(circle at top center, rgba(129, 171, 255, 0.1), transparent 34%);
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-messages {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(252, 253, 255, 1), rgba(255, 255, 255, 1)),
|
||||
radial-gradient(circle at top center, rgba(126, 150, 199, 0.08), transparent 36%);
|
||||
}
|
||||
|
||||
.assistant-chat__fallback,
|
||||
.chat-message__reasoning {
|
||||
border-radius: 16px;
|
||||
@@ -1210,9 +1506,9 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.chat-message__reasoning {
|
||||
padding: 14px 16px;
|
||||
border-color: rgba(92, 122, 170, 0.14);
|
||||
background: linear-gradient(180deg, rgba(245, 247, 251, 0.96), rgba(239, 243, 248, 0.98));
|
||||
padding: 2px 0 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-message__reasoning-head,
|
||||
@@ -1226,31 +1522,57 @@ onBeforeUnmount(() => {
|
||||
.chat-message__reasoning-head {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chat-message__reasoning-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #4b596d;
|
||||
}
|
||||
|
||||
.chat-message__reasoning-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #5a98ff;
|
||||
box-shadow: 0 0 0 0 rgba(90, 152, 255, 0.34);
|
||||
animation: pulse-dot 1.6s ease-in-out infinite;
|
||||
.chat-message__reasoning-title strong {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chat-message__reasoning-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
color: #4f76ea;
|
||||
}
|
||||
|
||||
.chat-message__reasoning-icon-svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chat-message__reasoning-toggle {
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: #5f728b;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
color: #7b8798;
|
||||
font-size: 18px;
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-message__reasoning-chevron {
|
||||
display: inline-block;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.chat-message__reasoning-chevron--collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.chat-message__reasoning-body {
|
||||
margin-left: 7px;
|
||||
padding-left: 14px;
|
||||
border-left: 2px solid rgba(120, 134, 156, 0.24);
|
||||
}
|
||||
|
||||
.chat-message__markdown {
|
||||
@@ -1272,8 +1594,9 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.chat-message__markdown--reasoning {
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
color: #5b6676;
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(p) {
|
||||
@@ -1339,15 +1662,58 @@ onBeforeUnmount(() => {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(.md-pre .hljs) {
|
||||
display: block;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(.md-table-wrap) {
|
||||
margin: 0;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||
overflow-x: auto;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(.md-table) {
|
||||
width: 100%;
|
||||
min-width: 520px;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(.md-table th),
|
||||
.chat-message__markdown :deep(.md-table td) {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(.md-table th) {
|
||||
background: rgba(68, 98, 158, 0.08);
|
||||
color: #1f2f47;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chat-message__markdown :deep(.md-table tr:last-child td) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.chat-message__streaming {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 26px;
|
||||
justify-content: flex-start;
|
||||
gap: 0;
|
||||
min-height: 22px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-message__streaming--plain {
|
||||
padding-right: 10px;
|
||||
padding: 2px 10px 2px 0;
|
||||
}
|
||||
|
||||
.chat-message__streaming--reasoning {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.chat-message__time,
|
||||
@@ -1429,15 +1795,43 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.assistant-toolbar__pill--select {
|
||||
padding-right: 10px;
|
||||
padding: 0 10px 0 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.assistant-toolbar__select {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
.assistant-toolbar__select-label {
|
||||
color: #64758b;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.assistant-toolbar__select-box {
|
||||
min-width: 84px;
|
||||
}
|
||||
|
||||
.assistant-toolbar__select-box :deep(.el-select__wrapper) {
|
||||
min-height: 30px;
|
||||
padding: 0 7px 0 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: none;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.assistant-toolbar__select-box:hover :deep(.el-select__wrapper) {
|
||||
border-color: rgba(36, 102, 220, 0.18);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.assistant-toolbar__select-box :deep(.el-select__selected-item) {
|
||||
color: #42526a;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.assistant-toolbar__select-box :deep(.el-select__caret) {
|
||||
color: #627593;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
@@ -1514,3 +1908,32 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.assistant-model-select-panel.el-popper {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.14);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.assistant-model-select-panel .el-select-dropdown__item {
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
border-radius: 8px;
|
||||
padding: 0 12px;
|
||||
color: #4d5d73;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assistant-model-select-panel .el-select-dropdown__item.hover,
|
||||
.assistant-model-select-panel .el-select-dropdown__item:hover {
|
||||
background: rgba(51, 95, 194, 0.1);
|
||||
}
|
||||
|
||||
.assistant-model-select-panel .el-select-dropdown__item.is-selected {
|
||||
color: #2f56b0;
|
||||
background: rgba(51, 95, 194, 0.16);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,42 +4,77 @@ import { computed } from 'vue'
|
||||
import type { TodayEvent } from '@/types/dashboard'
|
||||
import { formatTimeRange } from '@/utils/date'
|
||||
|
||||
interface TimelineSlot {
|
||||
interface BaseSlot {
|
||||
key: string
|
||||
kind: 'event' | 'pause'
|
||||
label: string
|
||||
timeText?: string
|
||||
eventOrder?: number
|
||||
title: string
|
||||
}
|
||||
|
||||
interface EventSlot extends BaseSlot {
|
||||
kind: 'event'
|
||||
startTime: string
|
||||
endTime: string
|
||||
}
|
||||
|
||||
interface PauseSlot extends BaseSlot {
|
||||
kind: 'pause'
|
||||
}
|
||||
|
||||
type TimelineSlot = EventSlot | PauseSlot
|
||||
|
||||
interface RenderEventSlot {
|
||||
key: string
|
||||
kind: 'event'
|
||||
timeText: string
|
||||
title: string
|
||||
locationText: string
|
||||
tone: string
|
||||
}
|
||||
|
||||
interface RenderPauseSlot {
|
||||
key: string
|
||||
kind: 'pause'
|
||||
title: string
|
||||
hint: string
|
||||
}
|
||||
|
||||
type RenderSlot = RenderEventSlot | RenderPauseSlot
|
||||
|
||||
const props = defineProps<{
|
||||
events: TodayEvent[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
// 1. 时间轴始终固定为 8 个槽位,顺序不再受当天是否有课影响。
|
||||
// 2. 课程槽位缺数据时显示“无课”,而不是直接消失,避免把后续块位挤乱。
|
||||
// 3. 午休和晚餐是纯占位块,不展示时间文本,只负责占住用户指定的位置。
|
||||
const slotBlueprint: TimelineSlot[] = [
|
||||
{ key: 'slot-1', kind: 'event', label: '上午', timeText: '08:00 - 09:40', eventOrder: 1 },
|
||||
{ key: 'slot-2', kind: 'event', label: '上午', timeText: '10:15 - 11:55', eventOrder: 2 },
|
||||
{ key: 'slot-noon', kind: 'pause', label: '午休' },
|
||||
{ key: 'slot-4', kind: 'event', label: '下午', timeText: '14:00 - 15:40', eventOrder: 4 },
|
||||
// 1. 晚餐块固定放在 7-8 节与 9-10 节之间,作为晚间课程前的过渡占位。
|
||||
// 2. 根据用户最新要求,它要出现在“17:55 结束的课块之后、19:00 黄色块之前”。
|
||||
// 3. 用户要求该块只保留单独卡片,不展示时间文本。
|
||||
{ key: 'slot-dinner', kind: 'pause', label: '晚餐' },
|
||||
{ key: 'slot-5', kind: 'event', label: '下午', timeText: '16:15 - 17:55', eventOrder: 5 },
|
||||
{ key: 'slot-6', kind: 'event', label: '晚间', timeText: '19:00 - 20:40', eventOrder: 6 },
|
||||
{ key: 'slot-7', kind: 'event', label: '晚间', timeText: '20:50 - 22:30', eventOrder: 7 },
|
||||
{ key: 'slot-1', kind: 'event', title: '1-2节', startTime: '08:00', endTime: '09:40' },
|
||||
{ key: 'slot-2', kind: 'event', title: '3-4节', startTime: '10:15', endTime: '11:55' },
|
||||
{ key: 'slot-noon', kind: 'pause', title: '午休' },
|
||||
{ key: 'slot-4', kind: 'event', title: '5-6节', startTime: '14:00', endTime: '15:40' },
|
||||
{ key: 'slot-5', kind: 'event', title: '7-8节', startTime: '16:15', endTime: '17:55' },
|
||||
{ key: 'slot-dinner', kind: 'pause', title: '晚餐' },
|
||||
{ key: 'slot-6', kind: 'event', title: '9-10节', startTime: '19:00', endTime: '20:40' },
|
||||
{ key: 'slot-7', kind: 'event', title: '11-12节', startTime: '20:50', endTime: '22:30' },
|
||||
]
|
||||
|
||||
function buildTimeKey(start?: string | null, end?: string | null) {
|
||||
return `${(start || '').trim()}|${(end || '').trim()}`
|
||||
}
|
||||
|
||||
const eventMap = computed(() => {
|
||||
const map = new Map<number, TodayEvent>()
|
||||
const map = new Map<string, TodayEvent>()
|
||||
for (const event of props.events ?? []) {
|
||||
map.set(event.order, event)
|
||||
map.set(buildTimeKey(event.start_time, event.end_time), event)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function resolveCardTone(event: TodayEvent) {
|
||||
function resolveCardTone(event: TodayEvent | null) {
|
||||
if (!event) {
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
if (event.type === 'course') {
|
||||
return 'course'
|
||||
}
|
||||
@@ -48,19 +83,36 @@ function resolveCardTone(event: TodayEvent) {
|
||||
1: 'sky',
|
||||
2: 'violet',
|
||||
4: 'mint',
|
||||
5: 'amber',
|
||||
5: 'emerald',
|
||||
6: 'amber',
|
||||
7: 'cyan',
|
||||
}
|
||||
|
||||
return orderToneMap[event.order] ?? 'neutral'
|
||||
}
|
||||
|
||||
function resolveSlotEvent(slot: TimelineSlot) {
|
||||
if (typeof slot.eventOrder !== 'number') {
|
||||
return null
|
||||
}
|
||||
return eventMap.value.get(slot.eventOrder) ?? null
|
||||
}
|
||||
const renderSlots = computed<RenderSlot[]>(() =>
|
||||
slotBlueprint.map((slot) => {
|
||||
if (slot.kind === 'pause') {
|
||||
return {
|
||||
key: slot.key,
|
||||
kind: 'pause',
|
||||
title: slot.title,
|
||||
hint: '为中段留出缓冲与恢复时间',
|
||||
}
|
||||
}
|
||||
|
||||
const event = eventMap.value.get(buildTimeKey(slot.startTime, slot.endTime)) ?? null
|
||||
return {
|
||||
key: slot.key,
|
||||
kind: 'event',
|
||||
timeText: formatTimeRange(event?.start_time || slot.startTime, event?.end_time || slot.endTime),
|
||||
title: event?.name || '无课',
|
||||
locationText: event?.location || '休息时间',
|
||||
tone: resolveCardTone(event),
|
||||
}
|
||||
}),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -78,36 +130,20 @@ function resolveSlotEvent(slot: TimelineSlot) {
|
||||
</div>
|
||||
|
||||
<div v-else class="timeline-grid">
|
||||
<template v-for="slot in slotBlueprint" :key="slot.key">
|
||||
<article v-if="slot.kind === 'pause'" class="timeline-placeholder timeline-placeholder--pause">
|
||||
<span v-if="slot.timeText" class="timeline-placeholder__time">{{ slot.timeText }}</span>
|
||||
<strong class="timeline-placeholder__title">{{ slot.label }}</strong>
|
||||
<span class="timeline-placeholder__hint">为中段留出缓冲与恢复时间</span>
|
||||
</article>
|
||||
|
||||
<template v-for="slot in renderSlots" :key="slot.key">
|
||||
<article
|
||||
v-else-if="resolveSlotEvent(slot)"
|
||||
v-if="slot.kind === 'event'"
|
||||
class="timeline-event"
|
||||
:class="`timeline-event--${resolveCardTone(resolveSlotEvent(slot)!)}`"
|
||||
:class="`timeline-event--${slot.tone}`"
|
||||
>
|
||||
<span class="timeline-event__time">
|
||||
{{
|
||||
formatTimeRange(
|
||||
resolveSlotEvent(slot)?.start_time,
|
||||
resolveSlotEvent(slot)?.end_time,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<strong class="timeline-event__title">{{ resolveSlotEvent(slot)?.name }}</strong>
|
||||
<span class="timeline-event__location">
|
||||
{{ resolveSlotEvent(slot)?.location || '休息时间' }}
|
||||
</span>
|
||||
<span class="timeline-event__time">{{ slot.timeText }}</span>
|
||||
<strong class="timeline-event__title">{{ slot.title }}</strong>
|
||||
<span class="timeline-event__location">{{ slot.locationText }}</span>
|
||||
</article>
|
||||
|
||||
<article v-else class="timeline-event timeline-event--neutral">
|
||||
<span class="timeline-event__time">{{ slot.timeText }}</span>
|
||||
<strong class="timeline-event__title">无课</strong>
|
||||
<span class="timeline-event__location">休息时间</span>
|
||||
<article v-else class="timeline-placeholder timeline-placeholder--pause">
|
||||
<strong class="timeline-placeholder__title">{{ slot.title }}</strong>
|
||||
<span class="timeline-placeholder__hint">{{ slot.hint }}</span>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
@@ -154,9 +190,9 @@ function resolveSlotEvent(slot: TimelineSlot) {
|
||||
.timeline-grid {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
/* 1. 改为 auto-fit 自适应列数,避免固定列数把左侧主区整体撑宽。 */
|
||||
/* 2. 每张卡片保留可读最小宽度,空间不足时自动换行,而不是出现横向滚动条。 */
|
||||
/* 3. 这样在左右近似二分的布局下,左侧信息板也能保持完整可见。 */
|
||||
/* 1. 使用自适应列数,避免固定列数把左侧主区撑爆。 */
|
||||
/* 2. 但槽位顺序固定,换行只影响视觉换行,不影响时间先后顺序。 */
|
||||
/* 3. 这样无论是否缺课,8 个槽位都会按既定顺序逐个渲染。 */
|
||||
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
||||
gap: 12px;
|
||||
overflow: visible;
|
||||
@@ -217,6 +253,14 @@ function resolveSlotEvent(slot: TimelineSlot) {
|
||||
background: #1669c1;
|
||||
}
|
||||
|
||||
.timeline-event--sky {
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #f3f7fc 100%);
|
||||
}
|
||||
|
||||
.timeline-event--sky::before {
|
||||
background: #c8d6e8;
|
||||
}
|
||||
|
||||
.timeline-event--violet {
|
||||
background: linear-gradient(180deg, #eef0ff 0%, #e6e8ff 100%);
|
||||
}
|
||||
@@ -226,10 +270,18 @@ function resolveSlotEvent(slot: TimelineSlot) {
|
||||
}
|
||||
|
||||
.timeline-event--mint {
|
||||
background: linear-gradient(180deg, #e6f8f1 0%, #def5ec 100%);
|
||||
background: linear-gradient(180deg, #e6f2ff 0%, #dceaff 100%);
|
||||
}
|
||||
|
||||
.timeline-event--mint::before {
|
||||
background: #2f7de1;
|
||||
}
|
||||
|
||||
.timeline-event--emerald {
|
||||
background: linear-gradient(180deg, #e6f8f1 0%, #def5ec 100%);
|
||||
}
|
||||
|
||||
.timeline-event--emerald::before {
|
||||
background: #27b482;
|
||||
}
|
||||
|
||||
@@ -273,12 +325,6 @@ function resolveSlotEvent(slot: TimelineSlot) {
|
||||
background: linear-gradient(180deg, #f5f9ff 0%, #eef4fb 100%);
|
||||
}
|
||||
|
||||
.timeline-placeholder__time {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #4c6c97;
|
||||
}
|
||||
|
||||
.timeline-placeholder__title {
|
||||
font-size: 16px;
|
||||
color: #22324b;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AuthView from '@/views/AuthView.vue'
|
||||
import AssistantView from '@/views/AssistantView.vue'
|
||||
import DashboardView from '@/views/DashboardView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
@@ -27,6 +28,14 @@ const router = createRouter({
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/assistant',
|
||||
name: 'assistant',
|
||||
component: AssistantView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import hljs from 'highlight.js/lib/common'
|
||||
import 'highlight.js/styles/github.css'
|
||||
|
||||
function escapeHtml(input: string) {
|
||||
return input
|
||||
.replaceAll('&', '&')
|
||||
@@ -7,152 +11,77 @@ function escapeHtml(input: string) {
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
function parseInlineMarkdown(input: string) {
|
||||
const inlineCodeBlocks: string[] = []
|
||||
const htmlBreakToken = '@@HTML_BREAK@@'
|
||||
let content = input.replace(/<br\s*\/?>/gi, htmlBreakToken)
|
||||
function renderHighlightedCode(sourceCode: string, language: string) {
|
||||
const normalizedLanguage = language.trim()
|
||||
const safeLanguageClass = normalizedLanguage ? ` language-${escapeHtml(normalizedLanguage)}` : ''
|
||||
|
||||
// 1. 先抽离行内代码,避免代码片段里的 Markdown / HTML 被后续规则误处理。
|
||||
// 2. <br> 只做白名单放行,其它原始 HTML 仍统一转义,避免把模型输出直接注入页面。
|
||||
// 3. 若用户就是想输入普通换行,外层段落逻辑仍会继续按 <br /> 渲染,不受这里影响。
|
||||
content = escapeHtml(content)
|
||||
try {
|
||||
if (normalizedLanguage && hljs.getLanguage(normalizedLanguage)) {
|
||||
const highlighted = hljs.highlight(sourceCode, {
|
||||
language: normalizedLanguage,
|
||||
ignoreIllegals: true,
|
||||
}).value
|
||||
return `<pre class="md-pre"><code class="md-code hljs${safeLanguageClass}">${highlighted}</code></pre>`
|
||||
}
|
||||
|
||||
content = content.replace(/`([^`]+)`/g, (_, code: string) => {
|
||||
const token = `@@INLINE_CODE_${inlineCodeBlocks.length}@@`
|
||||
inlineCodeBlocks.push(`<code>${escapeHtml(code.replaceAll(htmlBreakToken, '<br>'))}</code>`)
|
||||
return token
|
||||
})
|
||||
|
||||
content = content.replace(
|
||||
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
||||
(_, label: string, link: string) =>
|
||||
`<a href="${escapeHtml(link)}" target="_blank" rel="noreferrer noopener">${label}</a>`,
|
||||
)
|
||||
|
||||
content = content.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
content = content.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||
content = content.replace(/~~([^~]+)~~/g, '<del>$1</del>')
|
||||
content = content.replaceAll(htmlBreakToken, '<br />')
|
||||
|
||||
return content.replace(/@@INLINE_CODE_(\d+)@@/g, (_, index: string) => inlineCodeBlocks[Number(index)] ?? '')
|
||||
const highlighted = hljs.highlightAuto(sourceCode).value
|
||||
return `<pre class="md-pre"><code class="md-code hljs">${highlighted}</code></pre>`
|
||||
} catch {
|
||||
const escaped = escapeHtml(sourceCode)
|
||||
return `<pre class="md-pre"><code class="md-code${safeLanguageClass}">${escaped}</code></pre>`
|
||||
}
|
||||
}
|
||||
|
||||
// renderMarkdown 负责把常见 Markdown 文本安全转换为可展示 HTML。
|
||||
const markdownRenderer = new MarkdownIt({
|
||||
// 1. 禁止渲染原始 HTML,避免模型输出被直接注入页面。
|
||||
// 2. 保留换行语义,让对话消息中的软换行更接近聊天阅读习惯。
|
||||
// 3. 开启 linkify,自动识别纯文本链接,减少“写成网址却不可点”的情况。
|
||||
html: false,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
// 1. 统一在渲染阶段做代码高亮,避免在组件层重复处理字符串。
|
||||
// 2. 优先按模型返回的语言标记高亮,语言未知时自动推断。
|
||||
// 3. 高亮异常时自动降级为转义后的纯文本代码块,保证渲染不会中断。
|
||||
highlight(sourceCode: string, language: string) {
|
||||
return renderHighlightedCode(sourceCode, language)
|
||||
},
|
||||
})
|
||||
|
||||
const defaultLinkOpenRenderer =
|
||||
markdownRenderer.renderer.rules.link_open ??
|
||||
((tokens: any[], index: number, options: any, _env: any, self: any) =>
|
||||
self.renderToken(tokens, index, options))
|
||||
|
||||
markdownRenderer.renderer.rules.link_open = (
|
||||
tokens: any[],
|
||||
index: number,
|
||||
options: any,
|
||||
env: any,
|
||||
self: any,
|
||||
) => {
|
||||
const token = tokens[index]
|
||||
|
||||
// 1. 所有外链统一新窗口打开,避免覆盖当前对话页。
|
||||
// 2. 强制附加 rel,降低反向标签页劫持风险。
|
||||
token.attrSet('target', '_blank')
|
||||
token.attrSet('rel', 'noreferrer noopener')
|
||||
|
||||
return defaultLinkOpenRenderer(tokens, index, options, env, self)
|
||||
}
|
||||
|
||||
markdownRenderer.renderer.rules.table_open = () => '<div class="md-table-wrap"><table class="md-table">'
|
||||
markdownRenderer.renderer.rules.table_close = () => '</table></div>'
|
||||
|
||||
// renderMarkdown 负责把聊天消息里的 Markdown 渲染为安全 HTML。
|
||||
// 职责边界:
|
||||
// 1. 负责处理标题、列表、引用、代码块、链接、粗斜体等常见场景。
|
||||
// 2. 不追求完整 CommonMark 兼容,只覆盖聊天消息里最常见的展示需求。
|
||||
// 3. 所有原始文本都会先做 HTML 转义,避免把模型输出直接当成原生 HTML 注入页面。
|
||||
// 1. 负责常见 GFM 语法(包含表格、代码块)渲染,不负责业务字段裁剪与内容截断。
|
||||
// 2. 负责输出可直接插入 v-html 的字符串,不负责 DOM 挂载与样式布局。
|
||||
// 3. 若输入为空,仅返回空串,不抛异常阻断对话主链路。
|
||||
export function renderMarkdown(input: string) {
|
||||
const normalized = (input || '').replace(/\r\n?/g, '\n').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const fencedBlocks: string[] = []
|
||||
let source = normalized.replace(/```([a-zA-Z0-9_-]+)?\n?([\s\S]*?)```/g, (_, language: string, code: string) => {
|
||||
const token = `@@FENCED_BLOCK_${fencedBlocks.length}@@`
|
||||
const languageClass = language ? ` language-${escapeHtml(language)}` : ''
|
||||
fencedBlocks.push(
|
||||
`<pre class="md-pre"><code class="md-code${languageClass}">${escapeHtml(code.trimEnd())}</code></pre>`,
|
||||
)
|
||||
return token
|
||||
})
|
||||
|
||||
const lines = source.split('\n')
|
||||
const htmlParts: string[] = []
|
||||
let unorderedItems: string[] = []
|
||||
let orderedItems: string[] = []
|
||||
let quoteLines: string[] = []
|
||||
let paragraphLines: string[] = []
|
||||
|
||||
function flushParagraph() {
|
||||
if (paragraphLines.length === 0) {
|
||||
return
|
||||
}
|
||||
htmlParts.push(`<p>${parseInlineMarkdown(paragraphLines.join('<br />'))}</p>`)
|
||||
paragraphLines = []
|
||||
}
|
||||
|
||||
function flushUnorderedList() {
|
||||
if (unorderedItems.length === 0) {
|
||||
return
|
||||
}
|
||||
htmlParts.push(`<ul>${unorderedItems.map((item) => `<li>${parseInlineMarkdown(item)}</li>`).join('')}</ul>`)
|
||||
unorderedItems = []
|
||||
}
|
||||
|
||||
function flushOrderedList() {
|
||||
if (orderedItems.length === 0) {
|
||||
return
|
||||
}
|
||||
htmlParts.push(`<ol>${orderedItems.map((item) => `<li>${parseInlineMarkdown(item)}</li>`).join('')}</ol>`)
|
||||
orderedItems = []
|
||||
}
|
||||
|
||||
function flushBlockquote() {
|
||||
if (quoteLines.length === 0) {
|
||||
return
|
||||
}
|
||||
htmlParts.push(`<blockquote>${quoteLines.map((line) => `<p>${parseInlineMarkdown(line)}</p>`).join('')}</blockquote>`)
|
||||
quoteLines = []
|
||||
}
|
||||
|
||||
function flushAllBlocks() {
|
||||
flushParagraph()
|
||||
flushUnorderedList()
|
||||
flushOrderedList()
|
||||
flushBlockquote()
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
flushAllBlocks()
|
||||
continue
|
||||
}
|
||||
|
||||
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/)
|
||||
if (headingMatch) {
|
||||
flushAllBlocks()
|
||||
const level = headingMatch[1].length
|
||||
htmlParts.push(`<h${level}>${parseInlineMarkdown(headingMatch[2])}</h${level}>`)
|
||||
continue
|
||||
}
|
||||
|
||||
const unorderedMatch = trimmed.match(/^[-*+]\s+(.*)$/)
|
||||
if (unorderedMatch) {
|
||||
flushParagraph()
|
||||
flushOrderedList()
|
||||
flushBlockquote()
|
||||
unorderedItems.push(unorderedMatch[1])
|
||||
continue
|
||||
}
|
||||
|
||||
const orderedMatch = trimmed.match(/^\d+\.\s+(.*)$/)
|
||||
if (orderedMatch) {
|
||||
flushParagraph()
|
||||
flushUnorderedList()
|
||||
flushBlockquote()
|
||||
orderedItems.push(orderedMatch[1])
|
||||
continue
|
||||
}
|
||||
|
||||
const quoteMatch = trimmed.match(/^>\s?(.*)$/)
|
||||
if (quoteMatch) {
|
||||
flushParagraph()
|
||||
flushUnorderedList()
|
||||
flushOrderedList()
|
||||
quoteLines.push(quoteMatch[1])
|
||||
continue
|
||||
}
|
||||
|
||||
paragraphLines.push(trimmed)
|
||||
}
|
||||
|
||||
flushAllBlocks()
|
||||
|
||||
return htmlParts
|
||||
.join('')
|
||||
.replace(/@@FENCED_BLOCK_(\d+)@@/g, (_, index: string) => fencedBlocks[Number(index)] ?? '')
|
||||
return markdownRenderer.render(normalized)
|
||||
}
|
||||
|
||||
136
frontend/src/views/AssistantView.vue
Normal file
136
frontend/src/views/AssistantView.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AssistantPanel from '@/components/dashboard/AssistantPanel.vue'
|
||||
|
||||
interface PageSwitchItem {
|
||||
key: 'dashboard' | 'assistant'
|
||||
label: string
|
||||
short: string
|
||||
to: '/dashboard' | '/assistant'
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const switchItems: PageSwitchItem[] = [
|
||||
{
|
||||
key: 'dashboard',
|
||||
label: '排程',
|
||||
short: '排',
|
||||
to: '/dashboard',
|
||||
},
|
||||
{
|
||||
key: 'assistant',
|
||||
label: '对话',
|
||||
short: 'AI',
|
||||
to: '/assistant',
|
||||
},
|
||||
]
|
||||
|
||||
const activeSwitchKey = computed<PageSwitchItem['key']>(() =>
|
||||
route.path.startsWith('/assistant') ? 'assistant' : 'dashboard',
|
||||
)
|
||||
|
||||
function handlePageSwitch(targetPath: PageSwitchItem['to']) {
|
||||
if (route.path !== targetPath) {
|
||||
router.push(targetPath)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="assistant-view">
|
||||
<section class="assistant-view__layout">
|
||||
<aside class="assistant-view__switch-rail glass-panel">
|
||||
<button
|
||||
v-for="item in switchItems"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="assistant-view__switch-item"
|
||||
:class="{ 'assistant-view__switch-item--active': activeSwitchKey === item.key }"
|
||||
@click="handlePageSwitch(item.to)"
|
||||
>
|
||||
<span>{{ item.short }}</span>
|
||||
<small>{{ item.label }}</small>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<AssistantPanel class="assistant-view__panel" view-mode="standalone" />
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.assistant-view {
|
||||
height: 100vh;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, #f6f8fb 0%, #eef2f7 100%);
|
||||
}
|
||||
|
||||
.assistant-view__layout {
|
||||
height: calc(100vh - 24px);
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(56px, 0.3fr) minmax(0, 6fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.assistant-view__switch-rail {
|
||||
min-height: 0;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
background: linear-gradient(180deg, rgba(249, 250, 252, 0.95), rgba(243, 247, 252, 0.98));
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||
padding: 14px 7px;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.assistant-view__switch-item {
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: #6b7789;
|
||||
padding: 10px 4px 9px;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.assistant-view__switch-item span {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
background: rgba(86, 101, 126, 0.1);
|
||||
}
|
||||
|
||||
.assistant-view__switch-item small {
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.assistant-view__switch-item--active {
|
||||
color: #335fc2;
|
||||
background: linear-gradient(180deg, rgba(88, 126, 224, 0.16), rgba(88, 126, 224, 0.08));
|
||||
}
|
||||
|
||||
.assistant-view__switch-item--active span {
|
||||
background: rgba(58, 95, 184, 0.2);
|
||||
}
|
||||
|
||||
.assistant-view__panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-radius: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AssistantPanel from '@/components/dashboard/AssistantPanel.vue'
|
||||
import TaskQuadrantCard from '@/components/dashboard/TaskQuadrantCard.vue'
|
||||
@@ -13,6 +13,7 @@ import type { TaskItem, TodayEvent } from '@/types/dashboard'
|
||||
import { formatHeaderDate } from '@/utils/date'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const pageLoading = ref(false)
|
||||
@@ -39,13 +40,27 @@ const taskForm = reactive<{
|
||||
deadline_at: null,
|
||||
})
|
||||
|
||||
const sidebarItems = [
|
||||
{ key: 'home', label: '总览', short: '总' },
|
||||
interface SidebarItem {
|
||||
key: 'home' | 'task' | 'calendar' | 'ai'
|
||||
label: string
|
||||
short: string
|
||||
to?: '/dashboard' | '/assistant'
|
||||
}
|
||||
|
||||
const sidebarItems: SidebarItem[] = [
|
||||
{ key: 'home', label: '总览', short: '总', to: '/dashboard' },
|
||||
{ key: 'task', label: '任务', short: '任' },
|
||||
{ key: 'calendar', label: '日程', short: '程' },
|
||||
{ key: 'ai', label: '助手', short: 'AI' },
|
||||
{ key: 'ai', label: '助手', short: 'AI', to: '/assistant' },
|
||||
]
|
||||
|
||||
const activeSidebarKey = computed<SidebarItem['key']>(() => {
|
||||
if (route.path.startsWith('/assistant')) {
|
||||
return 'ai'
|
||||
}
|
||||
return 'home'
|
||||
})
|
||||
|
||||
const quadrantOrder = [1, 2, 3, 4] as const
|
||||
|
||||
const quadrantMeta: Record<
|
||||
@@ -217,6 +232,20 @@ function handleCourseImportEntry() {
|
||||
ElMessage.info('课表导入入口已预留,下一步我可以继续把导入流程页接出来')
|
||||
}
|
||||
|
||||
function handleSidebarNavigate(item: SidebarItem) {
|
||||
// 1. 已接通路由的入口直接跳转,避免侧栏按钮成为“仅装饰”元素。
|
||||
// 2. 未接通的入口先给出明确提示,防止用户误以为点击失效。
|
||||
// 3. 同路由不重复 push,避免产生无意义导航与日志噪音。
|
||||
if (item.to) {
|
||||
if (route.path !== item.to) {
|
||||
void router.push(item.to)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.info(`${item.label} 页面正在开发中`)
|
||||
}
|
||||
|
||||
function clampSidebarWidth(nextWidth: number) {
|
||||
return Math.min(110, Math.max(68, nextWidth))
|
||||
}
|
||||
@@ -320,7 +349,8 @@ onBeforeUnmount(() => {
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="dashboard-sidebar__nav-item"
|
||||
:class="{ 'dashboard-sidebar__nav-item--active': item.key === 'home' }"
|
||||
:class="{ 'dashboard-sidebar__nav-item--active': item.key === activeSidebarKey }"
|
||||
@click="handleSidebarNavigate(item)"
|
||||
>
|
||||
<span>{{ item.short }}</span>
|
||||
<small>{{ item.label }}</small>
|
||||
|
||||
Reference in New Issue
Block a user