Web优化 1

This commit is contained in:
2026-06-12 14:01:55 +08:00
parent 17ca339c66
commit 7e9df04bf8
10 changed files with 138 additions and 73 deletions
+8 -6
View File
@@ -17,8 +17,8 @@ export interface UseAcpStreamReturn {
events: Ref<AcpEvent[]> events: Ref<AcpEvent[]>
pendingPermissions: Ref<PermissionRequest[]> pendingPermissions: Ref<PermissionRequest[]>
errorMsg: Ref<string | null> errorMsg: Ref<string | null>
prompt: (text: string, agentSessionID: string) => void prompt: (text: string, agentSessionID: string) => boolean
cancel: (agentSessionID: string) => void cancel: (agentSessionID: string) => boolean
reconnect: () => void reconnect: () => void
close: () => void close: () => void
} }
@@ -181,21 +181,23 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn {
} }
function send(method: string, params: Record<string, unknown>) { function send(method: string, params: Record<string, unknown>) {
if (!ws || ws.readyState !== WebSocket.OPEN) return if (!ws || ws.readyState !== WebSocket.OPEN) return false
const id = 'client-' + crypto.randomUUID() const uuid = crypto.randomUUID?.() ?? Math.random().toString(36).slice(2)
const id = 'client-' + uuid
const msg = { jsonrpc: '2.0', id, method, params } const msg = { jsonrpc: '2.0', id, method, params }
ws.send(JSON.stringify(msg)) ws.send(JSON.stringify(msg))
return true
} }
function prompt(text: string, agentSessionID: string) { function prompt(text: string, agentSessionID: string) {
send('session/prompt', { return send('session/prompt', {
sessionId: agentSessionID, sessionId: agentSessionID,
prompt: [{ type: 'text', text }], prompt: [{ type: 'text', text }],
}) })
} }
function cancel(agentSessionID: string) { function cancel(agentSessionID: string) {
send('session/cancel', { sessionId: agentSessionID }) return send('session/cancel', { sessionId: agentSessionID })
} }
function reconnect() { function reconnect() {
+19 -10
View File
@@ -74,20 +74,24 @@ export function useConversationSession() {
if (!currentConversation.value) return if (!currentConversation.value) return
if (streamingMessageId.value !== null) return if (streamingMessageId.value !== null) return
const conv = currentConversation.value const conv = currentConversation.value
const now = new Date().toISOString()
const userMsg: ChatMessage = {
id: -1,
conversation_id: conv.id,
role: 'user',
content,
status: 'pending',
created_at: now,
updated_at: now,
}
messages.value.push(userMsg)
try {
const resp = await chatApi.sendMessage(conv.id, { const resp = await chatApi.sendMessage(conv.id, {
content, content,
attachment_ids: attachmentIds, attachment_ids: attachmentIds,
}) })
const now = new Date().toISOString() userMsg.id = resp.user_message_id
messages.value.push({ userMsg.status = 'ok'
id: resp.user_message_id,
conversation_id: conv.id,
role: 'user',
content,
status: 'ok',
created_at: now,
updated_at: now,
})
messages.value.push({ messages.value.push({
id: resp.assistant_message_id, id: resp.assistant_message_id,
conversation_id: conv.id, conversation_id: conv.id,
@@ -99,6 +103,11 @@ export function useConversationSession() {
}) })
streamingMessageId.value = resp.assistant_message_id streamingMessageId.value = resp.assistant_message_id
attachStream(resp.stream_url, resp.user_message_id) attachStream(resp.stream_url, resp.user_message_id)
} catch (e) {
userMsg.status = 'error'
userMsg.error_message = e instanceof Error ? e.message : 'send failed'
throw e
}
} }
async function cancelMessage() { async function cancelMessage() {
+16 -3
View File
@@ -28,8 +28,10 @@ export const useUIStore = defineStore('ui', () => {
? window.matchMedia('(prefers-color-scheme: dark)') ? window.matchMedia('(prefers-color-scheme: dark)')
: null : null
const systemDark = ref(mediaQuery ? mediaQuery.matches : false) const systemDark = ref(mediaQuery ? mediaQuery.matches : false)
let mediaListener: ((e: MediaQueryListEvent) => void) | null = null
if (mediaQuery) { if (mediaQuery) {
mediaQuery.addEventListener('change', (e) => (systemDark.value = e.matches)) mediaListener = (e: MediaQueryListEvent) => (systemDark.value = e.matches)
mediaQuery.addEventListener('change', mediaListener)
} }
const effectiveDark = computed(() => const effectiveDark = computed(() =>
@@ -38,10 +40,21 @@ export const useUIStore = defineStore('ui', () => {
watch( watch(
effectiveDark, effectiveDark,
(v) => document.documentElement.classList.toggle('dark', v), (v) => {
if (typeof document !== 'undefined') {
document.documentElement.classList.toggle('dark', v)
}
},
{ immediate: true }, { immediate: true },
) )
function cleanupMediaQuery() {
if (mediaQuery && mediaListener) {
mediaQuery.removeEventListener('change', mediaListener)
mediaListener = null
}
}
function setThemeMode(mode: ThemeMode) { function setThemeMode(mode: ThemeMode) {
themeMode.value = mode themeMode.value = mode
safeWrite('themeMode', mode) safeWrite('themeMode', mode)
@@ -50,5 +63,5 @@ export const useUIStore = defineStore('ui', () => {
const sidebarCollapsed = ref(safeReadString('sidebarCollapsed') === 'true') const sidebarCollapsed = ref(safeReadString('sidebarCollapsed') === 'true')
watch(sidebarCollapsed, (v) => safeWrite('sidebarCollapsed', String(v))) watch(sidebarCollapsed, (v) => safeWrite('sidebarCollapsed', String(v)))
return { themeMode, effectiveDark, setThemeMode, sidebarCollapsed } return { themeMode, effectiveDark, setThemeMode, sidebarCollapsed, cleanupMediaQuery }
}) })
+24 -10
View File
@@ -72,21 +72,33 @@ onUnmounted(() => {
stream.value?.close() stream.value?.close()
}) })
const isPrompting = computed(() => { const lastPromptIndex = ref(-1)
const hasResponseAfterPrompt = ref(false)
const isPrompting = computed(() => lastPromptIndex.value >= 0 && !hasResponseAfterPrompt.value)
watch(
() => events.value.length,
() => {
const evs = events.value const evs = events.value
let lastPrompt = -1 if (lastPromptIndex.value < 0) {
for (let i = evs.length - 1; i >= 0; i--) { for (let i = evs.length - 1; i >= 0; i--) {
if (evs[i].method === 'session/prompt' && evs[i].direction === 'in') { if (evs[i].method === 'session/prompt' && evs[i].direction === 'in') {
lastPrompt = i lastPromptIndex.value = i
hasResponseAfterPrompt.value = false
break break
} }
} }
if (lastPrompt < 0) return false
for (let i = lastPrompt + 1; i < evs.length; i++) {
if (evs[i].kind === 'response') return false
} }
return true if (lastPromptIndex.value >= 0) {
}) for (let i = lastPromptIndex.value + 1; i < evs.length; i++) {
if (evs[i].kind === 'response') {
hasResponseAfterPrompt.value = true
break
}
}
}
},
)
function onPrompt(text: string) { function onPrompt(text: string) {
const sess = store.currentSession const sess = store.currentSession
@@ -95,13 +107,15 @@ function onPrompt(text: string) {
return return
} }
errorMsg.value = null errorMsg.value = null
stream.value?.prompt(text, sess.agent_session_id) const ok = stream.value?.prompt(text, sess.agent_session_id)
if (!ok) errorMsg.value = '发送失败:连接未就绪'
} }
function onCancel() { function onCancel() {
const sess = store.currentSession const sess = store.currentSession
if (!sess?.agent_session_id) return if (!sess?.agent_session_id) return
stream.value?.cancel(sess.agent_session_id) const ok = stream.value?.cancel(sess.agent_session_id)
if (!ok) errorMsg.value = '取消失败:连接未就绪'
} }
async function terminate() { async function terminate() {
+4 -1
View File
@@ -22,6 +22,7 @@
type="primary" type="primary"
attr-type="submit" attr-type="submit"
:loading="loading" :loading="loading"
:disabled="!isValid"
block block
> >
登录 登录
@@ -38,7 +39,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, computed } from 'vue'
import { NForm, NFormItem, NInput, NButton } from 'naive-ui' import { NForm, NFormItem, NInput, NButton } from 'naive-ui'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { authApi } from '@/api/auth' import { authApi } from '@/api/auth'
@@ -50,6 +51,8 @@ const password = ref('')
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
const isValid = computed(() => email.value.trim().length > 0 && password.value.length > 0)
const auth = useAuthStore() const auth = useAuthStore()
const router = useRouter() const router = useRouter()
+6
View File
@@ -215,13 +215,19 @@ watch(
) )
// 流式输出时 messages.length 不变,只 content 增长 —— 同时观察末消息内容长度。 // 流式输出时 messages.length 不变,只 content 增长 —— 同时观察末消息内容长度。
let scrollPending = false
watch( watch(
() => [chat.messages.length, chat.messages.at(-1)?.content?.length ?? 0], () => [chat.messages.length, chat.messages.at(-1)?.content?.length ?? 0],
() => { () => {
if (scrollPending) return
scrollPending = true
requestAnimationFrame(() => {
scrollPending = false
nextTick(() => { nextTick(() => {
const el = scrollEl.value const el = scrollEl.value
if (el) el.scrollTo(0, el.scrollHeight) if (el) el.scrollTo(0, el.scrollHeight)
}) })
})
}, },
) )
@@ -82,9 +82,8 @@ function remove(id: string) {
} }
watch( watch(
attachments, () => attachments.value.map((a) => a.id),
() => emit('change', attachments.value.map((a) => a.id)), () => emit('change', attachments.value.map((a) => a.id)),
{ deep: true },
) )
defineExpose({ defineExpose({
@@ -247,13 +247,19 @@ function onSaveMessage(id: number) {
} }
// 流式输出时自动滚到底部(同 ChatDetailView 的策略)。 // 流式输出时自动滚到底部(同 ChatDetailView 的策略)。
let scrollPending = false
watch( watch(
() => [session.messages.value.length, session.messages.value.at(-1)?.content?.length ?? 0], () => [session.messages.value.length, session.messages.value.at(-1)?.content?.length ?? 0],
() => { () => {
if (scrollPending) return
scrollPending = true
requestAnimationFrame(() => {
scrollPending = false
nextTick(() => { nextTick(() => {
const el = scrollEl.value const el = scrollEl.value
if (el) el.scrollTo(0, el.scrollHeight) if (el) el.scrollTo(0, el.scrollHeight)
}) })
})
}, },
) )
@@ -262,7 +268,10 @@ watch(
() => props.phase, () => props.phase,
() => { () => {
session.closeCurrent() session.closeCurrent()
draft.value = ''
showCreateForm.value = false showCreateForm.value = false
newSystemPrompt.value = ''
newFirstMessage.value = DEFAULT_FIRST_MESSAGE
}, },
) )
@@ -191,7 +191,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { import {
NCard, NCard,
@@ -263,7 +263,15 @@ const hasAnyContent = computed(() =>
) )
// 默认展开有内容的第一项(优先当前阶段) // 默认展开有内容的第一项(优先当前阶段)
const defaultExpandedNames = computed(() => { const defaultExpandedNames = ref<(ArtifactPhase | 'conversations' | 'acp-sessions')[]>([])
watch(
[
() => props.currentPhase,
() => artifacts.value.length,
() => conversations.value.length,
() => acpSessions.value.length,
],
() => {
const names: (ArtifactPhase | 'conversations' | 'acp-sessions')[] = [] const names: (ArtifactPhase | 'conversations' | 'acp-sessions')[] = []
if (props.currentPhase && (ARTIFACT_PHASES as Phase[]).includes(props.currentPhase)) { if (props.currentPhase && (ARTIFACT_PHASES as Phase[]).includes(props.currentPhase)) {
const cp = props.currentPhase as ArtifactPhase const cp = props.currentPhase as ArtifactPhase
@@ -276,8 +284,10 @@ const defaultExpandedNames = computed(() => {
} }
if (conversations.value.length > 0) names.push('conversations') if (conversations.value.length > 0) names.push('conversations')
if (acpSessions.value.length > 0) names.push('acp-sessions') if (acpSessions.value.length > 0) names.push('acp-sessions')
return names.slice(0, 2) // 最多默认展开前两项,避免过长 defaultExpandedNames.value = names.slice(0, 2) // 最多默认展开前两项,避免过长
}) },
{ immediate: true },
)
const renderedArtifact = computed(() => const renderedArtifact = computed(() =>
selectedArtifact.value ? md.render(selectedArtifact.value.content) : '', selectedArtifact.value ? md.render(selectedArtifact.value.content) : '',
@@ -67,17 +67,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, computed } from 'vue' import { onMounted, ref, computed, defineAsyncComponent } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { NTabs, NTabPane, NPageHeader, useMessage } from 'naive-ui' import { NTabs, NTabPane, NPageHeader, useMessage } from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue' import AppShell from '@/layouts/AppShell.vue'
import { useWorkspacesStore } from '@/stores/workspaces' import { useWorkspacesStore } from '@/stores/workspaces'
import SyncBar from './components/SyncBar.vue' import SyncBar from './components/SyncBar.vue'
import WorktreesTab from './components/WorktreesTab.vue' const WorktreesTab = defineAsyncComponent(() => import('./components/WorktreesTab.vue'))
import MainTab from './components/MainTab.vue' const MainTab = defineAsyncComponent(() => import('./components/MainTab.vue'))
import CommitHistoryTab from './components/CommitHistoryTab.vue' const CommitHistoryTab = defineAsyncComponent(() => import('./components/CommitHistoryTab.vue'))
import SettingsTab from './components/SettingsTab.vue' const SettingsTab = defineAsyncComponent(() => import('./components/SettingsTab.vue'))
import RunsTab from './components/RunsTab.vue' const RunsTab = defineAsyncComponent(() => import('./components/RunsTab.vue'))
const route = useRoute() const route = useRoute()
const store = useWorkspacesStore() const store = useWorkspacesStore()