+ + + diff --git a/web/src/components/ai-elements/Conversation.vue b/web/src/components/ai-elements/Conversation.vue new file mode 100644 index 0000000..a830066 --- /dev/null +++ b/web/src/components/ai-elements/Conversation.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/web/src/components/ai-elements/FileTree.vue b/web/src/components/ai-elements/FileTree.vue new file mode 100644 index 0000000..973e3d0 --- /dev/null +++ b/web/src/components/ai-elements/FileTree.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/web/src/components/ai-elements/Markdown.vue b/web/src/components/ai-elements/Markdown.vue new file mode 100644 index 0000000..c689f34 --- /dev/null +++ b/web/src/components/ai-elements/Markdown.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/web/src/components/ai-elements/Message.vue b/web/src/components/ai-elements/Message.vue new file mode 100644 index 0000000..fee478f --- /dev/null +++ b/web/src/components/ai-elements/Message.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/web/src/components/ai-elements/Reasoning.vue b/web/src/components/ai-elements/Reasoning.vue new file mode 100644 index 0000000..b1b6eac --- /dev/null +++ b/web/src/components/ai-elements/Reasoning.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/web/src/components/ai-elements/Task.vue b/web/src/components/ai-elements/Task.vue new file mode 100644 index 0000000..300fa29 --- /dev/null +++ b/web/src/components/ai-elements/Task.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/web/src/components/ai-elements/Terminal.vue b/web/src/components/ai-elements/Terminal.vue new file mode 100644 index 0000000..a79388b --- /dev/null +++ b/web/src/components/ai-elements/Terminal.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/web/src/components/ai-elements/Tool.vue b/web/src/components/ai-elements/Tool.vue new file mode 100644 index 0000000..0c95661 --- /dev/null +++ b/web/src/components/ai-elements/Tool.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/web/src/components/ai-elements/markdown-utils.ts b/web/src/components/ai-elements/markdown-utils.ts new file mode 100644 index 0000000..781d4dc --- /dev/null +++ b/web/src/components/ai-elements/markdown-utils.ts @@ -0,0 +1,73 @@ +export interface MarkdownTextSegment { + id: string + type: 'markdown' + content: string +} + +export interface MarkdownCodeSegment { + id: string + type: 'code' + code: string + language?: string +} + +export type MarkdownSegment = MarkdownTextSegment | MarkdownCodeSegment + +const CODE_FENCE_PATTERN = /```([^\n`]*)\n?([\s\S]*?)```/g + +export function escapeHtml(source: string): string { + return source + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +export function splitMarkdownSegments(source: string): MarkdownSegment[] { + if (!source) return [] + + const segments: MarkdownSegment[] = [] + let match: RegExpExecArray | null + let lastIndex = 0 + let segmentIndex = 0 + + while ((match = CODE_FENCE_PATTERN.exec(source)) !== null) { + const [fullMatch, info = '', rawCode = ''] = match + const matchIndex = match.index + + if (matchIndex > lastIndex) { + segments.push({ + id: `markdown-${segmentIndex++}`, + type: 'markdown', + content: source.slice(lastIndex, matchIndex), + }) + } + + const language = info.trim().split(/\s+/)[0] || undefined + const code = rawCode.endsWith('\n') + ? rawCode.slice(0, -1) + : rawCode + + segments.push({ + id: `code-${segmentIndex++}`, + type: 'code', + code, + language, + }) + + lastIndex = matchIndex + fullMatch.length + } + + if (lastIndex < source.length) { + segments.push({ + id: `markdown-${segmentIndex}`, + type: 'markdown', + content: source.slice(lastIndex), + }) + } + + return segments.filter(segment => + segment.type === 'code' || segment.content.length > 0 + ) +} diff --git a/web/src/components/ai-elements/shiki.ts b/web/src/components/ai-elements/shiki.ts new file mode 100644 index 0000000..aa5b376 --- /dev/null +++ b/web/src/components/ai-elements/shiki.ts @@ -0,0 +1,88 @@ +import { createHighlighterCore } from '@shikijs/core' +import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript' + +const SHIKI_THEME = 'github-light' + +const LANGUAGE_ALIASES: Record = { + js: 'javascript', + ts: 'typescript', + md: 'markdown', + yml: 'yaml', + shell: 'bash', + console: 'bash', + text: 'plaintext', + txt: 'plaintext', +} + +type ShikiHighlighter = Awaited> +type LanguageRegistrationModule = { + default: Parameters +} + +let highlighterPromise: Promise | null = null +const loadedLanguages = new Set(['plaintext', 'text', 'txt', 'plain']) + +const LANGUAGE_LOADERS: Record Promise> = { + bash: () => Promise.all([import('@shikijs/langs/bash')]), + json: () => Promise.all([import('@shikijs/langs/json')]), + yaml: () => Promise.all([import('@shikijs/langs/yaml')]), + markdown: () => Promise.all([import('@shikijs/langs/markdown')]), + typescript: () => Promise.all([import('@shikijs/langs/typescript')]), + tsx: () => Promise.all([import('@shikijs/langs/typescript'), import('@shikijs/langs/tsx')]), + javascript: () => Promise.all([import('@shikijs/langs/javascript')]), + jsx: () => Promise.all([import('@shikijs/langs/javascript'), import('@shikijs/langs/jsx')]), + vue: () => Promise.all([ + import('@shikijs/langs/vue'), + import('@shikijs/langs/html'), + import('@shikijs/langs/css'), + import('@shikijs/langs/javascript'), + import('@shikijs/langs/typescript'), + ]), + html: () => Promise.all([import('@shikijs/langs/html')]), + css: () => Promise.all([import('@shikijs/langs/css')]), + diff: () => Promise.all([import('@shikijs/langs/diff')]), + sql: () => Promise.all([import('@shikijs/langs/sql')]), +} + +export function normalizeCodeLanguage(language?: string): string { + if (!language) return 'plaintext' + const normalized = language.trim().toLowerCase() + return LANGUAGE_ALIASES[normalized] || normalized +} + +export function getShikiTheme(): string { + return SHIKI_THEME +} + +export async function getShikiHighlighter(): Promise { + if (!highlighterPromise) { + highlighterPromise = Promise.all([ + import('@shikijs/themes/github-light'), + ]).then(([githubLight]) => + createHighlighterCore({ + engine: createJavaScriptRegexEngine(), + themes: [githubLight.default], + }) + ) + } + + return highlighterPromise +} + +export async function ensureShikiLanguage(language?: string): Promise { + const normalized = normalizeCodeLanguage(language) + if (loadedLanguages.has(normalized)) { + return normalized + } + + const loader = LANGUAGE_LOADERS[normalized] + if (!loader) { + return 'plaintext' + } + + const highlighter = await getShikiHighlighter() + const modules = await loader() + await highlighter.loadLanguage(...modules.flatMap(module => module.default)) + loadedLanguages.add(normalized) + return normalized +} diff --git a/web/src/composables/chat-model.ts b/web/src/composables/chat-model.ts new file mode 100644 index 0000000..495c351 --- /dev/null +++ b/web/src/composables/chat-model.ts @@ -0,0 +1,370 @@ +import type { + ChatEvent, + ChatHistoryMessage, + ChatMessagePart, + ChatModelRef, + ChatPermissionRequest, + ChatTodoItem, + ChatTokenUsage, +} from '../api' + +export type ChatMessageStatus = 'streaming' | 'done' | 'error' +export type ChatStreamState = 'disconnected' | 'connecting' | 'connected' | 'idle' + +export interface ChatToolCallVm { + id: string + callId: string + name: string + title?: string + input?: unknown + output: string + status: 'running' | 'completed' | 'error' + isError: boolean + durationMs?: number +} + +export interface ChatMessageVm { + id: string + role: 'user' | 'assistant' + createdAt: number + text: string + reasoning: string + tools: ChatToolCallVm[] + status: ChatMessageStatus + usage?: ChatTokenUsage + finish?: string + error?: string + model?: ChatModelRef + agent?: string + optimistic?: boolean +} + +function isTodoWriteTool(name: string): boolean { + const normalized = name.trim().toLowerCase() + return normalized === 'todowrite' || normalized === 'todo_write' || normalized === 'todo-write' +} + +function toTodoItems(raw: unknown): ChatTodoItem[] | undefined { + if (!Array.isArray(raw)) return undefined + + const tasks: ChatTodoItem[] = [] + for (const item of raw) { + if (!item || typeof item !== 'object') continue + const record = item as Record + const id = typeof record.id === 'string' ? record.id.trim() : '' + const contentValue = record.content ?? record.title ?? record.text + const content = typeof contentValue === 'string' ? contentValue.trim() : '' + const status = typeof record.status === 'string' && record.status.trim() + ? record.status.trim() + : 'pending' + + if (!id || !content) continue + tasks.push({ + id, + content, + status, + priority: typeof record.priority === 'string' ? record.priority : undefined, + }) + } + + return tasks.length > 0 ? tasks : undefined +} + +function extractTodoItemsFromToolState(state: Extract['state']): ChatTodoItem[] | undefined { + if ('metadata' in state && state.metadata?.todos) { + const fromMetadata = toTodoItems(state.metadata.todos) + if (fromMetadata) return fromMetadata + } + + return toTodoItems(state.input?.todos) +} + +function toModelRef(message: ChatHistoryMessage['info']): ChatModelRef | undefined { + if (message.role === 'user' && message.model?.providerID && message.model?.modelID) { + return { + providerId: message.model.providerID, + modelId: message.model.modelID, + } + } + + if (message.role === 'assistant' && message.providerID && message.modelID) { + return { + providerId: message.providerID, + modelId: message.modelID, + } + } + + return undefined +} + +function toUsage(message: ChatHistoryMessage['info']): ChatTokenUsage | undefined { + const tokens = message.tokens + if (!tokens) return undefined + return { + input: tokens.input ?? 0, + output: tokens.output ?? 0, + reasoning: tokens.reasoning ?? 0, + cacheRead: tokens.cache?.read ?? 0, + cacheWrite: tokens.cache?.write ?? 0, + cost: message.cost, + } +} + +function appendToolPart(tools: ChatToolCallVm[], part: Extract): void { + const existing = tools.find(item => item.id === part.id || item.callId === part.callID) + const status = part.state.status === 'error' + ? 'error' + : part.state.status === 'completed' + ? 'completed' + : 'running' + const output = part.state.status === 'completed' + ? part.state.output + : part.state.status === 'error' + ? part.state.error + : '' + + const next: ChatToolCallVm = { + id: part.id, + callId: part.callID, + name: part.tool, + input: part.state.input, + title: 'title' in part.state ? part.state.title : undefined, + output, + status, + isError: part.state.status === 'error', + } + + if (existing) { + Object.assign(existing, next) + return + } + + tools.push(next) +} + +export function normalizeHistoryMessage(message: ChatHistoryMessage): ChatMessageVm { + const textParts: string[] = [] + const reasoningParts: string[] = [] + const tools: ChatToolCallVm[] = [] + + for (const part of message.parts ?? []) { + if (part.type === 'text') { + textParts.push(part.text ?? '') + continue + } + + if (part.type === 'reasoning') { + reasoningParts.push(part.text ?? '') + continue + } + + if (part.type === 'tool') { + appendToolPart(tools, part) + } + } + + const error = message.info.error?.data?.message + const assistantDone = message.info.role === 'assistant' && Boolean(message.info.time.completed) + + return { + id: message.info.id, + role: message.info.role, + createdAt: (message.info.time.created ?? Date.now() / 1000) * 1000, + text: textParts.join(''), + reasoning: reasoningParts.join(''), + tools, + status: error ? 'error' : assistantDone || message.info.role === 'user' ? 'done' : 'streaming', + usage: message.info.role === 'assistant' ? toUsage(message.info) : undefined, + finish: message.info.finish, + error, + model: toModelRef(message.info), + agent: message.info.role === 'assistant' ? message.info.mode : message.info.agent, + } +} + +export function extractTasksFromHistory(history: ChatHistoryMessage[]): ChatTodoItem[] { + let latestTasks: ChatTodoItem[] = [] + + for (const message of history) { + for (const part of message.parts ?? []) { + if (part.type !== 'tool' || !isTodoWriteTool(part.tool)) continue + const nextTasks = extractTodoItemsFromToolState(part.state) + if (nextTasks) { + latestTasks = nextTasks + } + } + } + + return latestTasks +} + +function createAssistantMessage(msgId: string, createdAt = Date.now()): ChatMessageVm { + return { + id: msgId, + role: 'assistant', + createdAt, + text: '', + reasoning: '', + tools: [], + status: 'streaming', + } +} + +function upsertMessage(messages: ChatMessageVm[], message: ChatMessageVm): ChatMessageVm { + const existing = messages.find(item => item.id === message.id) + if (existing) { + Object.assign(existing, message) + return existing + } + + messages.push(message) + messages.sort((left, right) => left.createdAt - right.createdAt) + return message +} + +function ensureAssistant(messages: ChatMessageVm[], msgId: string): ChatMessageVm { + const existing = messages.find(item => item.id === msgId) + if (existing) return existing + return upsertMessage(messages, createAssistantMessage(msgId)) +} + +function ensureTool(message: ChatMessageVm, toolId: string, callId: string, name: string): ChatToolCallVm { + let tool = message.tools.find(item => item.id === toolId || item.callId === callId) + if (!tool) { + tool = { + id: toolId, + callId, + name, + output: '', + status: 'running', + isError: false, + } + message.tools.push(tool) + } + return tool +} + +export function applyChatEvent(messages: ChatMessageVm[], event: ChatEvent): void { + switch (event.type) { + case 'message_start': + upsertMessage(messages, { + id: event.msg.id, + role: event.msg.role, + createdAt: event.msg.createdAt, + text: '', + reasoning: '', + tools: [], + status: 'streaming', + model: event.msg.model, + agent: event.msg.agent, + }) + return + + case 'text_delta': + ensureAssistant(messages, event.msgId).text += event.text + return + + case 'reasoning_delta': + ensureAssistant(messages, event.msgId).reasoning += event.text + return + + case 'tool_start': { + const message = ensureAssistant(messages, event.msgId) + const tool = ensureTool(message, event.tool.id, event.tool.callId, event.tool.name) + tool.input = event.tool.input + tool.title = event.tool.title + tool.status = 'running' + tool.isError = false + return + } + + case 'tool_delta': { + const message = ensureAssistant(messages, event.msgId) + const tool = ensureTool(message, event.toolId, event.toolId, 'tool') + tool.output += event.output + return + } + + case 'tool_end': { + const message = ensureAssistant(messages, event.msgId) + const tool = ensureTool(message, event.toolId, event.callId, event.name) + tool.title = event.title + tool.output = event.result + tool.status = event.isError ? 'error' : 'completed' + tool.isError = event.isError + tool.durationMs = event.durationMs + return + } + + case 'message_end': { + const message = ensureAssistant(messages, event.msgId) + message.status = event.error ? 'error' : 'done' + message.error = event.error + message.finish = event.finish + message.usage = event.usage + return + } + + default: + return + } +} + +export function createOptimisticUserMessage(text: string, model?: ChatModelRef): ChatMessageVm { + return { + id: `optimistic-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + role: 'user', + createdAt: Date.now(), + text, + reasoning: '', + tools: [], + status: 'done', + model, + optimistic: true, + } +} + +export function createErrorAssistantMessage(message: string): ChatMessageVm { + return { + id: `error-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + role: 'assistant', + createdAt: Date.now(), + text: '', + reasoning: '', + tools: [], + status: 'error', + error: message, + } +} + +export function mergeChatMessages(history: ChatMessageVm[], current: ChatMessageVm[]): ChatMessageVm[] { + const merged = new Map() + + for (const message of history) { + merged.set(message.id, { ...message, tools: message.tools.map(tool => ({ ...tool })) }) + } + + for (const message of current) { + const existing = merged.get(message.id) + if (!existing) { + merged.set(message.id, { ...message, tools: message.tools.map(tool => ({ ...tool })) }) + continue + } + + merged.set(message.id, { + ...existing, + ...message, + text: message.text.length >= existing.text.length ? message.text : existing.text, + reasoning: message.reasoning.length >= existing.reasoning.length ? message.reasoning : existing.reasoning, + tools: message.tools.length >= existing.tools.length ? message.tools : existing.tools, + usage: message.usage ?? existing.usage, + error: message.error ?? existing.error, + optimistic: message.optimistic || existing.optimistic, + status: message.status === 'streaming' ? 'streaming' : existing.status === 'error' ? 'error' : message.status, + }) + } + + return Array.from(merged.values()).sort((left, right) => left.createdAt - right.createdAt) +} + +export type { ChatPermissionRequest, ChatTodoItem } diff --git a/web/src/composables/useChatMessages.ts b/web/src/composables/useChatMessages.ts new file mode 100644 index 0000000..cbde769 --- /dev/null +++ b/web/src/composables/useChatMessages.ts @@ -0,0 +1,260 @@ +import { ref, watch, type Ref } from 'vue' +import { chatApi, type ChatEvent, type ChatModelRef } from '../api' +import { + applyChatEvent, + createErrorAssistantMessage, + createOptimisticUserMessage, + extractTasksFromHistory, + mergeChatMessages, + normalizeHistoryMessage, + type ChatMessageVm, + type ChatStreamState, + type ChatTodoItem, +} from './chat-model' +import { useChatStream } from './useChatStream' +import { usePermission } from './usePermission' + +const HISTORY_PAGE_SIZE = 10 + +export function useChatMessages(sessionId: Ref) { + const messages = ref([]) + const tasks = ref([]) + const loading = ref(false) + const loadingMore = ref(false) + const sending = ref(false) + const lastError = ref(null) + const streamState = ref('disconnected') + const total = ref(0) + const hasMore = ref(false) + const nextCursor = ref(null) + const permission = usePermission() + let requestVersion = 0 + + const stream = useChatStream(sessionId, { + onEvent(event: ChatEvent) { + applyIncomingEvent(event) + }, + }) + + async function fetchLatestMessages(currentVersion: number, targetSessionId: string): Promise { + const page = await chatApi.getMessages(targetSessionId, { limit: HISTORY_PAGE_SIZE }) + if (currentVersion !== requestVersion || targetSessionId !== sessionId.value) return + + const latestTasks = Array.isArray(page.tasks) ? page.tasks : [] + tasks.value = latestTasks.length > 0 ? latestTasks : extractTasksFromHistory(page.messages) + total.value = page.total + hasMore.value = page.hasMore + nextCursor.value = page.nextCursor + messages.value = mergeChatMessages(page.messages.map(normalizeHistoryMessage), []) + } + + watch( + () => stream.state.value, + value => { + streamState.value = value + }, + { immediate: true } + ) + + watch( + () => stream.lastError.value, + value => { + if (value) lastError.value = value + } + ) + + watch( + sessionId, + async nextSessionId => { + requestVersion += 1 + const currentVersion = requestVersion + messages.value = [] + tasks.value = [] + lastError.value = null + total.value = 0 + hasMore.value = false + nextCursor.value = null + permission.reset() + + if (!nextSessionId) { + return + } + + loading.value = true + try { + await fetchLatestMessages(currentVersion, nextSessionId) + } catch (error) { + if (currentVersion !== requestVersion) return + lastError.value = error instanceof Error ? error.message : '加载会话消息失败' + } finally { + if (currentVersion === requestVersion) { + loading.value = false + } + } + }, + { immediate: true } + ) + + function applyIncomingEvent(event: ChatEvent): void { + switch (event.type) { + case 'task_update': + tasks.value = event.todos + return + + case 'permission_ask': + permission.enqueue(event.req) + return + + case 'permission_resolved': + permission.resolve(event.reqId) + return + + case 'session_status': + if (event.status === 'idle') { + sending.value = false + } + return + + case 'error': + lastError.value = event.message + sending.value = false + messages.value = [...messages.value, createErrorAssistantMessage(event.message)] + return + + case 'session_idle': + sending.value = false + return + + case 'message_end': + sending.value = false + applyChatEvent(messages.value, event) + return + + default: + applyChatEvent(messages.value, event) + } + } + + async function loadMoreHistory(): Promise { + if (!sessionId.value || !nextCursor.value || loading.value || loadingMore.value) { + return + } + + const currentVersion = requestVersion + const currentSessionId = sessionId.value + loadingMore.value = true + try { + const page = await chatApi.getMessages(currentSessionId, { + limit: HISTORY_PAGE_SIZE, + cursor: nextCursor.value, + }) + if (currentVersion !== requestVersion || currentSessionId !== sessionId.value) return + const latestTasks = Array.isArray(page.tasks) ? page.tasks : [] + tasks.value = latestTasks.length > 0 ? latestTasks : tasks.value + total.value = page.total + hasMore.value = page.hasMore + nextCursor.value = page.nextCursor + messages.value = mergeChatMessages(page.messages.map(normalizeHistoryMessage), messages.value) + } catch (error) { + lastError.value = error instanceof Error ? error.message : '加载更多历史失败' + } finally { + loadingMore.value = false + } + } + + async function sendText(payload: { + sessionId: string + text: string + providerId?: string + modelId?: string + agent?: string + variant?: string + }): Promise { + const trimmed = payload.text.trim() + if (!trimmed) return + + const model: ChatModelRef | undefined = payload.providerId && payload.modelId + ? { + providerId: payload.providerId, + modelId: payload.modelId, + } + : undefined + + messages.value = [...messages.value, createOptimisticUserMessage(trimmed, model)] + sending.value = true + lastError.value = null + + try { + await chatApi.sendPrompt({ + sessionId: payload.sessionId, + text: trimmed, + providerId: payload.providerId, + modelId: payload.modelId, + agent: payload.agent, + variant: payload.variant, + }) + } catch (error) { + sending.value = false + const message = error instanceof Error ? error.message : '发送消息失败' + lastError.value = message + messages.value = [...messages.value, createErrorAssistantMessage(message)] + } + } + + async function reload(): Promise { + if (!sessionId.value) { + messages.value = [] + tasks.value = [] + total.value = 0 + hasMore.value = false + nextCursor.value = null + return + } + + requestVersion += 1 + const currentVersion = requestVersion + loading.value = true + lastError.value = null + + try { + await fetchLatestMessages(currentVersion, sessionId.value) + } catch (error) { + if (currentVersion !== requestVersion) return + lastError.value = error instanceof Error ? error.message : '刷新会话消息失败' + } finally { + if (currentVersion === requestVersion) { + loading.value = false + } + } + } + + function discardFromMessage(messageId: string): void { + const index = messages.value.findIndex(message => message.id === messageId) + if (index < 0) return + + const removedCount = messages.value.length - index + messages.value = messages.value.slice(0, index) + total.value = Math.max(messages.value.length, total.value - removedCount) + sending.value = false + } + + return { + messages, + tasks, + loading, + loadingMore, + sending, + lastError, + streamState, + total, + hasMore, + permissionQueue: permission.queue, + activePermission: permission.activeRequest, + resolvePermissionRequest: permission.resolve, + reconnectStream: stream.reconnect, + loadMoreHistory, + sendText, + reload, + discardFromMessage, + } +} diff --git a/web/src/composables/useChatSessions.ts b/web/src/composables/useChatSessions.ts new file mode 100644 index 0000000..dcfe70b --- /dev/null +++ b/web/src/composables/useChatSessions.ts @@ -0,0 +1,54 @@ +import { ref } from 'vue' +import { chatApi, type ChatSessionSummary } from '../api' + +export function useChatSessions() { + const sessions = ref([]) + const loading = ref(false) + + async function refresh(): Promise { + loading.value = true + try { + sessions.value = await chatApi.listSessions() + return sessions.value + } finally { + loading.value = false + } + } + + async function createSession(payload?: { title?: string; directory?: string }): Promise { + const session = await chatApi.createSession(payload) + sessions.value = [session, ...sessions.value.filter(item => item.id !== session.id)] + return session + } + + async function renameSession(sessionId: string, title: string): Promise { + await chatApi.renameSession(sessionId, title) + const target = sessions.value.find(item => item.id === sessionId) + if (target) { + target.title = title + target.updatedAt = Date.now() + } + } + + async function deleteSession(sessionId: string): Promise { + await chatApi.deleteSession(sessionId) + sessions.value = sessions.value.filter(item => item.id !== sessionId) + } + + function touchSession(sessionId: string): void { + const target = sessions.value.find(item => item.id === sessionId) + if (!target) return + target.updatedAt = Date.now() + sessions.value = [target, ...sessions.value.filter(item => item.id !== sessionId)] + } + + return { + sessions, + loading, + refresh, + createSession, + renameSession, + deleteSession, + touchSession, + } +} diff --git a/web/src/composables/useChatStream.ts b/web/src/composables/useChatStream.ts new file mode 100644 index 0000000..db30c24 --- /dev/null +++ b/web/src/composables/useChatStream.ts @@ -0,0 +1,169 @@ +import { onBeforeUnmount, ref, watch, type Ref } from 'vue' +import type { ChatEvent } from '../api' +import type { ChatStreamState } from './chat-model' + +const CHAT_EVENT_TYPES = [ + 'message_start', + 'text_delta', + 'reasoning_delta', + 'tool_start', + 'tool_delta', + 'tool_end', + 'message_end', + 'permission_ask', + 'permission_resolved', + 'task_update', + 'session_idle', + 'session_status', + 'error', + 'keepalive', +] as const + +/** Exponential backoff parameters */ +const RECONNECT_BASE_MS = 1000 +const RECONNECT_MAX_MS = 30000 +const RECONNECT_FACTOR = 2 + +export function useChatStream( + sessionId: Ref, + options: { + onEvent: (event: ChatEvent) => void + } +) { + const state = ref('disconnected') + const lastError = ref(null) + let source: EventSource | null = null + let reconnectTimer: ReturnType | null = null + let reconnectAttempt = 0 + let currentSessionId: string | null = null + let intentionalClose = false + + function clearReconnectTimer(): void { + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + } + + function close(): void { + intentionalClose = true + clearReconnectTimer() + if (!source) return + source.close() + source = null + state.value = 'disconnected' + } + + function scheduleReconnect(): void { + if (intentionalClose || !currentSessionId) return + clearReconnectTimer() + + const delay = Math.min( + RECONNECT_BASE_MS * Math.pow(RECONNECT_FACTOR, reconnectAttempt), + RECONNECT_MAX_MS + ) + reconnectAttempt++ + + console.log(`[ChatStream] 将在 ${delay}ms 后自动重连 (第 ${reconnectAttempt} 次)`) + reconnectTimer = setTimeout(() => { + reconnectTimer = null + if (currentSessionId && !intentionalClose) { + connect(currentSessionId) + } + }, delay) + } + + function connect(nextSessionId: string): void { + // Close previous without triggering reconnect + intentionalClose = true + clearReconnectTimer() + if (source) { + source.close() + source = null + } + intentionalClose = false + currentSessionId = nextSessionId + + const token = localStorage.getItem('admin_token') || '' + const params = new URLSearchParams({ + session_id: nextSessionId, + token, + }) + + state.value = 'connecting' + lastError.value = null + source = new EventSource(`/api/chat/events?${params.toString()}`) + + source.addEventListener('connected', () => { + state.value = 'connected' + reconnectAttempt = 0 // Reset backoff on successful connection + }) + + for (const eventType of CHAT_EVENT_TYPES) { + source.addEventListener(eventType, payload => { + try { + const event = JSON.parse((payload as MessageEvent).data) as ChatEvent + if (event.type === 'keepalive') return + if (event.type === 'session_idle') { + state.value = 'idle' + } else if (event.type === 'session_status' && event.status === 'idle') { + state.value = 'idle' + } else if (event.type === 'message_start' || event.type === 'text_delta') { + state.value = 'connected' + } + options.onEvent(event) + } catch (error) { + lastError.value = error instanceof Error ? error.message : '解析流事件失败' + } + }) + } + + source.addEventListener('error', payload => { + const messageEvent = payload as MessageEvent + if (typeof messageEvent.data === 'string' && messageEvent.data) { + try { + options.onEvent(JSON.parse(messageEvent.data) as ChatEvent) + } catch { + lastError.value = '解析错误事件失败' + } + return + } + + // EventSource auto-reconnects on transient errors, but if readyState is CLOSED + // the browser gave up — we need to manually reconnect with backoff. + if (source && source.readyState === EventSource.CLOSED) { + lastError.value = '流式连接已断开,正在自动重连…' + state.value = 'disconnected' + source.close() + source = null + scheduleReconnect() + } + }) + } + + watch( + sessionId, + nextSessionId => { + reconnectAttempt = 0 // Reset backoff on session switch + if (!nextSessionId) { + close() + currentSessionId = null + return + } + connect(nextSessionId) + }, + { immediate: true } + ) + + onBeforeUnmount(close) + + return { + state, + lastError, + reconnect: () => { + reconnectAttempt = 0 + if (sessionId.value) connect(sessionId.value) + }, + close, + } +} diff --git a/web/src/composables/usePermission.ts b/web/src/composables/usePermission.ts new file mode 100644 index 0000000..3a7da84 --- /dev/null +++ b/web/src/composables/usePermission.ts @@ -0,0 +1,29 @@ +import { computed, ref } from 'vue' +import type { ChatPermissionRequest } from '../api' + +export function usePermission() { + const queue = ref([]) + + const activeRequest = computed(() => queue.value[0] ?? null) + + function enqueue(request: ChatPermissionRequest): void { + if (queue.value.some(item => item.id === request.id)) return + queue.value = [...queue.value, request] + } + + function resolve(requestId: string): void { + queue.value = queue.value.filter(item => item.id !== requestId) + } + + function reset(): void { + queue.value = [] + } + + return { + queue, + activeRequest, + enqueue, + resolve, + reset, + } +} diff --git a/web/src/router/index.ts b/web/src/router/index.ts index c927e3d..8af79b5 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -6,6 +6,8 @@ const routes = [ { path: '/login', component: () => import('../views/Login.vue'), meta: { title: '登录' } }, { path: '/change-password', component: () => import('../views/ChangePassword.vue'), meta: { title: '修改密码' } }, { path: '/dashboard', component: () => import('../views/Dashboard.vue'), meta: { title: '系统状态' } }, + { path: '/chat', component: () => import('../views/chat/ChatWorkspace.vue'), meta: { title: 'AI 工作区' } }, + { path: '/chat/:sessionId', component: () => import('../views/chat/ChatWorkspace.vue'), meta: { title: 'AI 工作区' } }, { path: '/platforms', component: () => import('../views/Platforms.vue'), meta: { title: '平台接入' } }, { path: '/sessions', component: () => import('../views/Sessions.vue'), meta: { title: 'Session 管理' } }, { path: '/opencode', component: () => import('../views/OpenCode.vue'), meta: { title: 'OpenCode 对接' } }, @@ -98,4 +100,4 @@ router.beforeEach(async (to, _from, next) => { } next() -}) \ No newline at end of file +}) diff --git a/web/src/stores/config.ts b/web/src/stores/config.ts index 855b22b..ad28e55 100644 --- a/web/src/stores/config.ts +++ b/web/src/stores/config.ts @@ -68,7 +68,8 @@ export const useConfigStore = defineStore('config', () => { const telegramSessions = (data.telegram || []).map(s => ({ ...s, platform: 'telegram' as const })) const qqSessions = (data.qq || []).map(s => ({ ...s, platform: 'qq' as const })) const whatsappSessions = (data.whatsapp || []).map(s => ({ ...s, platform: 'whatsapp' as const })) - sessions.value = [...feishuSessions, ...discordSessions, ...wecomSessions, ...telegramSessions, ...qqSessions, ...whatsappSessions] + const weixinSessions = (data.weixin || []).map(s => ({ ...s, platform: 'weixin' as const })) + sessions.value = [...feishuSessions, ...discordSessions, ...wecomSessions, ...telegramSessions, ...qqSessions, ...whatsappSessions, ...weixinSessions] } async function fetchModels() { diff --git a/web/src/types/markdown-it.d.ts b/web/src/types/markdown-it.d.ts new file mode 100644 index 0000000..6414e9d --- /dev/null +++ b/web/src/types/markdown-it.d.ts @@ -0,0 +1,4 @@ +declare module 'markdown-it' { + const MarkdownIt: any + export default MarkdownIt +} diff --git a/web/src/views/ChangePassword.vue b/web/src/views/ChangePassword.vue index abe24ef..57e3446 100644 --- a/web/src/views/ChangePassword.vue +++ b/web/src/views/ChangePassword.vue @@ -81,7 +81,6 @@ import { ref, reactive, computed } from 'vue' import { useRouter, useRoute } from 'vue-router' import { ElMessage, type FormInstance, type FormRules } from 'element-plus' -import { WarningFilled, Lock } from '@element-plus/icons-vue' import axios from 'axios' const router = useRouter() @@ -220,4 +219,4 @@ async function handleSubmit() { justify-content: flex-end; gap: 12px; } - \ No newline at end of file + diff --git a/web/src/views/CoreRouting.vue b/web/src/views/CoreRouting.vue index f22c980..bd8eaba 100644 --- a/web/src/views/CoreRouting.vue +++ b/web/src/views/CoreRouting.vue @@ -327,6 +327,7 @@ + + diff --git a/web/src/views/chat/ChatWorkspace.vue b/web/src/views/chat/ChatWorkspace.vue new file mode 100644 index 0000000..45488b8 --- /dev/null +++ b/web/src/views/chat/ChatWorkspace.vue @@ -0,0 +1,1150 @@ + + + + + diff --git a/web/src/views/chat/MessageInput.vue b/web/src/views/chat/MessageInput.vue new file mode 100644 index 0000000..d31f43b --- /dev/null +++ b/web/src/views/chat/MessageInput.vue @@ -0,0 +1,517 @@ + + + + + diff --git a/web/src/views/chat/MessageItem.vue b/web/src/views/chat/MessageItem.vue new file mode 100644 index 0000000..25155c2 --- /dev/null +++ b/web/src/views/chat/MessageItem.vue @@ -0,0 +1,327 @@ + + + + + diff --git a/web/src/views/chat/MessageList.vue b/web/src/views/chat/MessageList.vue new file mode 100644 index 0000000..2ef32d2 --- /dev/null +++ b/web/src/views/chat/MessageList.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/web/src/views/chat/PermissionDialog.vue b/web/src/views/chat/PermissionDialog.vue new file mode 100644 index 0000000..560d417 --- /dev/null +++ b/web/src/views/chat/PermissionDialog.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/web/src/views/chat/SessionHeader.vue b/web/src/views/chat/SessionHeader.vue new file mode 100644 index 0000000..189c119 --- /dev/null +++ b/web/src/views/chat/SessionHeader.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/web/src/views/chat/SessionSidebar.vue b/web/src/views/chat/SessionSidebar.vue new file mode 100644 index 0000000..c60ea0c --- /dev/null +++ b/web/src/views/chat/SessionSidebar.vue @@ -0,0 +1,326 @@ + + + + + diff --git a/web/src/views/chat/SessionTreeNode.vue b/web/src/views/chat/SessionTreeNode.vue new file mode 100644 index 0000000..0d4cbdc --- /dev/null +++ b/web/src/views/chat/SessionTreeNode.vue @@ -0,0 +1,247 @@ + + + + + diff --git a/web/src/views/chat/StreamingMessage.vue b/web/src/views/chat/StreamingMessage.vue new file mode 100644 index 0000000..6b60ba5 --- /dev/null +++ b/web/src/views/chat/StreamingMessage.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/web/src/views/chat/TaskPanel.vue b/web/src/views/chat/TaskPanel.vue new file mode 100644 index 0000000..db8d53a --- /dev/null +++ b/web/src/views/chat/TaskPanel.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/web/src/views/chat/session-tree.ts b/web/src/views/chat/session-tree.ts new file mode 100644 index 0000000..d6dfb0e --- /dev/null +++ b/web/src/views/chat/session-tree.ts @@ -0,0 +1,13 @@ +import type { ChatSessionSummary } from '../../api' + +export interface SessionTreeNodeData { + id: string + type: 'folder' | 'session' + label: string + directory?: string + directoryLabel?: string + updatedAt?: number + count: number + session?: ChatSessionSummary + children: SessionTreeNodeData[] +} diff --git a/web/src/views/chat/side-panels/FileExplorer.vue b/web/src/views/chat/side-panels/FileExplorer.vue new file mode 100644 index 0000000..6650dde --- /dev/null +++ b/web/src/views/chat/side-panels/FileExplorer.vue @@ -0,0 +1,408 @@ + + + + + diff --git a/web/src/views/chat/side-panels/GitPanel.vue b/web/src/views/chat/side-panels/GitPanel.vue new file mode 100644 index 0000000..3bc92ea --- /dev/null +++ b/web/src/views/chat/side-panels/GitPanel.vue @@ -0,0 +1,726 @@ + + + + + diff --git a/web/src/views/chat/side-panels/TerminalPanel.vue b/web/src/views/chat/side-panels/TerminalPanel.vue new file mode 100644 index 0000000..2784e3e --- /dev/null +++ b/web/src/views/chat/side-panels/TerminalPanel.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/web/src/views/chat/slash-command-cache.ts b/web/src/views/chat/slash-command-cache.ts new file mode 100644 index 0000000..42925ea --- /dev/null +++ b/web/src/views/chat/slash-command-cache.ts @@ -0,0 +1,24 @@ +import { chatApi, type ChatCommandInfo } from '../../api' + +let commandCache: ChatCommandInfo[] | null = null +let commandCachePromise: Promise | null = null + +export async function getSlashCommands(): Promise { + if (commandCache) { + return commandCache + } + + if (!commandCachePromise) { + commandCachePromise = chatApi.listCommands() + .then(result => { + commandCache = result + return result + }) + .finally(() => { + commandCachePromise = null + }) + } + + const commands = await commandCachePromise + return Array.isArray(commands) ? commands : [] +} From 0452036d764de4561b6834102e3ddde118040205 Mon Sep 17 00:00:00 2001 From: HNGM-HP <542869290@qq.com> Date: Fri, 17 Apr 2026 21:18:24 +0800 Subject: [PATCH 06/78] beta1 --- web/src/views/chat/ChatWorkspace.vue | 86 +++++++------ web/src/views/chat/MessageInput.vue | 149 ++++++++++------------ web/src/views/chat/MessageItem.vue | 114 +++++++++-------- web/src/views/chat/MessageList.vue | 25 ++-- web/src/views/chat/SessionHeader.vue | 88 ++++++------- web/src/views/chat/SessionSidebar.vue | 46 ++----- web/src/views/chat/SessionTreeNode.vue | 163 +++++++++++++++++++------ 7 files changed, 379 insertions(+), 292 deletions(-) diff --git a/web/src/views/chat/ChatWorkspace.vue b/web/src/views/chat/ChatWorkspace.vue index 45488b8..406bf36 100644 --- a/web/src/views/chat/ChatWorkspace.vue +++ b/web/src/views/chat/ChatWorkspace.vue @@ -2,6 +2,10 @@
+ 新建项目 +
+ +
- -
- 新建项目 -
- - + +