@veltra/ai 2.1.10 → 2.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat/use-chat.d.ts +15 -0
- package/dist/chat/use-chat.js +6 -0
- package/dist/chat/use-chat.js.map +1 -1
- package/dist/components/ai-chat/ai-chat.js +3 -1
- package/dist/components/ai-chat/ai-chat.js.map +1 -1
- package/dist/components/ai-chat/chat-input.js +155 -77
- package/dist/components/ai-chat/chat-input.js.map +1 -1
- package/dist/components/ai-chat/format-duration.js +13 -0
- package/dist/components/ai-chat/format-duration.js.map +1 -0
- package/dist/components/ai-chat/message-item.js +32 -23
- package/dist/components/ai-chat/message-item.js.map +1 -1
- package/dist/components/ai-chat/message-list.js +40 -5
- package/dist/components/ai-chat/message-list.js.map +1 -1
- package/dist/components/ai-chat/style2.css +71 -11
- package/dist/providers/types.d.ts +2 -0
- package/dist/types/ai-chat.d.ts +2 -1
- package/package.json +1 -1
- package/src/chat/use-chat.ts +6 -0
- package/src/components/ai-chat/__test__/ai-chat.test.ts +57 -19
- package/src/components/ai-chat/ai-chat.vue +2 -0
- package/src/components/ai-chat/chat-input.vue +81 -13
- package/src/components/ai-chat/format-duration.ts +8 -0
- package/src/components/ai-chat/message-item.vue +6 -0
- package/src/components/ai-chat/message-list.vue +48 -1
- package/src/components/ai-chat/style.scss +82 -10
- package/src/providers/types.ts +2 -0
- package/src/types/ai-chat.ts +2 -1
package/dist/chat/use-chat.d.ts
CHANGED
|
@@ -80,6 +80,21 @@ export declare function useChat(options: UseChatOptions): {
|
|
|
80
80
|
cacheMissTokens?: number | undefined;
|
|
81
81
|
cacheCreationTokens?: number | undefined;
|
|
82
82
|
} | null>;
|
|
83
|
+
lastRequestUsage: Ref<{
|
|
84
|
+
promptTokens: number;
|
|
85
|
+
completionTokens: number;
|
|
86
|
+
totalTokens: number;
|
|
87
|
+
cacheHitTokens?: number | undefined;
|
|
88
|
+
cacheMissTokens?: number | undefined;
|
|
89
|
+
cacheCreationTokens?: number | undefined;
|
|
90
|
+
} | null, ChatTokenUsage | {
|
|
91
|
+
promptTokens: number;
|
|
92
|
+
completionTokens: number;
|
|
93
|
+
totalTokens: number;
|
|
94
|
+
cacheHitTokens?: number | undefined;
|
|
95
|
+
cacheMissTokens?: number | undefined;
|
|
96
|
+
cacheCreationTokens?: number | undefined;
|
|
97
|
+
} | null>;
|
|
83
98
|
projections: Ref<Record<string, unknown>, Record<string, unknown>>;
|
|
84
99
|
title: Ref<string | null, string | null>;
|
|
85
100
|
pendingApprovals: Ref<{
|
package/dist/chat/use-chat.js
CHANGED
|
@@ -41,6 +41,8 @@ function useChat(options) {
|
|
|
41
41
|
const tokenUsage = ref(null);
|
|
42
42
|
/** 最近一轮用户对话(含工具多轮请求)的 token;该轮无 usage 时为 null */
|
|
43
43
|
const lastTurnUsage = ref(null);
|
|
44
|
+
/** 最近一次单次请求的 token(未跨轮累计),其 totalTokens 近似当前上下文占用 */
|
|
45
|
+
const lastRequestUsage = ref(null);
|
|
44
46
|
/** session 下由 jobs/snapshot 整体替换;函数 transport 保持空数组 */
|
|
45
47
|
const jobs = ref([]);
|
|
46
48
|
const projections = ref({});
|
|
@@ -280,6 +282,7 @@ function useChat(options) {
|
|
|
280
282
|
emit("tool-call", toolCall);
|
|
281
283
|
},
|
|
282
284
|
onUsage: (usage) => {
|
|
285
|
+
lastRequestUsage.value = usage;
|
|
283
286
|
lastTurnUsage.value = lastTurnUsage.value ? addTokenUsage(lastTurnUsage.value, usage) : usage;
|
|
284
287
|
tokenUsage.value = tokenUsage.value ? addTokenUsage(tokenUsage.value, usage) : usage;
|
|
285
288
|
},
|
|
@@ -366,6 +369,7 @@ function useChat(options) {
|
|
|
366
369
|
if (session()) {
|
|
367
370
|
sessionRuntime.resetLocal();
|
|
368
371
|
lastTurnUsage.value = null;
|
|
372
|
+
lastRequestUsage.value = null;
|
|
369
373
|
return;
|
|
370
374
|
}
|
|
371
375
|
abort();
|
|
@@ -373,6 +377,7 @@ function useChat(options) {
|
|
|
373
377
|
messages.value = [];
|
|
374
378
|
tokenUsage.value = null;
|
|
375
379
|
lastTurnUsage.value = null;
|
|
380
|
+
lastRequestUsage.value = null;
|
|
376
381
|
snapshot();
|
|
377
382
|
};
|
|
378
383
|
return {
|
|
@@ -384,6 +389,7 @@ function useChat(options) {
|
|
|
384
389
|
jobs,
|
|
385
390
|
tokenUsage,
|
|
386
391
|
lastTurnUsage,
|
|
392
|
+
lastRequestUsage,
|
|
387
393
|
projections,
|
|
388
394
|
title,
|
|
389
395
|
pendingApprovals,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-chat.js","names":[],"sources":["../../src/chat/use-chat.ts"],"sourcesContent":["import { useModel } from '@veltra/compositions'\nimport { reactive, ref, watch, type Ref } from 'vue'\n\nimport type { AskQuestionItem } from '../tools'\nimport type { AiChatEmits, AiChatProps } from '../types'\nimport type { ChatPendingApproval } from './fold'\nimport { isServerTransport, type ChatSessionTransport } from './session'\nimport type {\n ChatAttachment,\n ChatJob,\n ChatMessage,\n ChatQueuedMessage,\n ChatTokenUsage,\n ChatTool,\n ChatToolCall\n} from './types'\nimport { addTokenUsage, resolveTools, serializeResult } from './use-chat-helpers'\nimport { createSessionRuntime } from './use-chat-session'\n\nexport interface UseChatOptions {\n props: AiChatProps\n emit: AiChatEmits\n}\n\nconst uid = () => {\n return typeof crypto !== 'undefined' && crypto.randomUUID\n ? crypto.randomUUID()\n : `${Date.now()}-${Math.random().toString(36).slice(2)}`\n}\n\n/**\n * AI 对话核心状态机:消息管理、流式追加、工具调用循环编排。\n * 与 UI 解耦,ai-chat.vue 只负责渲染与交互转发。\n * 函数型 transport 注入内置工具;session transport 不注入、不执行客户端工具。\n */\nexport function useChat(options: UseChatOptions) {\n const { props, emit } = options\n\n const messages = useModel({\n props,\n propName: 'messages',\n emit,\n local: true,\n defaultValue: []\n }) as Ref<ChatMessage[]>\n\n const model = useModel({ props, propName: 'model', emit, local: true }) as Ref<string | undefined>\n\n const reasoningLevel = useModel({ props, propName: 'reasoningLevel', emit, local: true }) as Ref<\n string | undefined\n >\n\n /** 是否正在生成中(含工具执行与多轮循环) */\n const running = ref(false)\n\n /** 待发送队列:会话进行中提交的新消息按序排队,收尾后先进先出自动接续 */\n const queue = ref<ChatQueuedMessage[]>([])\n\n /** 当前会话累计 token;从未收到 usage 时为 null */\n const tokenUsage = ref<ChatTokenUsage | null>(null)\n /** 最近一轮用户对话(含工具多轮请求)的 token;该轮无 usage 时为 null */\n const lastTurnUsage = ref<ChatTokenUsage | null>(null)\n\n /** session 下由 jobs/snapshot 整体替换;函数 transport 保持空数组 */\n const jobs = ref<ChatJob[]>([])\n const projections = ref<Record<string, unknown>>({})\n const title = ref<string | null>(null)\n const pendingApprovals = ref<ChatPendingApproval[]>([])\n const pendingQuestion = ref<{ questions: AskQuestionItem[]; rpcId: string } | null>(null)\n\n const sessionRuntime = createSessionRuntime({\n messages,\n queue,\n jobs,\n tokenUsage,\n projections,\n title,\n running,\n pendingApprovals,\n pendingQuestion\n })\n\n const session = (): ChatSessionTransport | null =>\n isServerTransport(props.transport) ? props.transport : null\n\n let abortController: AbortController | null = null\n /** needsConfirm 工具的挂起确认器,key 为 toolCallId */\n const confirmResolvers = new Map<string, (approved: boolean) => void>()\n /** 当前会话是否自然完成(finish);决定收尾后是否自动消耗队列 */\n let finishedNaturally = false\n /** startQueued 触发的中断需要在收尾后立即接续队首 */\n let resumeAfterAbort = false\n\n /** 按当前模型校正推理等级(无 levels 则清空;值不合法则落到默认/首项) */\n const syncReasoningForModel = (modelId: string | undefined) => {\n const option = props.models?.find((m) => m.id === modelId)\n if (!option?.reasoningLevels?.length) {\n if (reasoningLevel.value !== undefined) {\n reasoningLevel.value = undefined\n }\n return\n }\n const levels = option.reasoningLevels\n if (reasoningLevel.value && levels.some((l) => l.value === reasoningLevel.value)) return\n reasoningLevel.value = option.defaultReasoningLevel ?? levels[0]?.value\n }\n\n // models / model 变化时:确保有合法选中模型,并校正推理等级\n watch(\n () => [props.models, model.value] as const,\n ([models, current]) => {\n if (!models?.length) return\n if (!current || !models.some((m) => m.id === current)) {\n model.value = models[0]!.id\n return\n }\n syncReasoningForModel(current)\n },\n { immediate: true }\n )\n\n // session:open + fetchHistory 播种;更换或卸载时 disposer\n watch(\n () => props.transport,\n (transport, _prev, onCleanup) => {\n if (!isServerTransport(transport)) return\n sessionRuntime.attach(transport, onCleanup)\n },\n { immediate: true }\n )\n\n // session 且已 open 时,模型切换走 selectModel\n watch(model, (modelId) => {\n const current = session()\n if (!sessionRuntime.opened || !current || !modelId) return\n const option = props.models?.find((m) => m.id === modelId)\n if (!option) return\n void current.selectModel(option.providerId, modelId)\n })\n\n /**\n * 向父组件同步消息快照(流式 delta 过于频繁,不同步,只在关键节点同步)。\n * 必须经 setter 赋值触发 emit,而非直接 emit 切片:setter 让本地数组与下发的\n * 快照保持同一引用,受控父级(v-model:messages)把快照回写到 props 时,\n * useModel 同步回来的仍是这个数组(流式期间新增的 assistant 占位已 push 在其上);\n * 若直接 emit 切片,本地数组与快照是两个对象,回显的旧快照会把本地新增的消息冲掉。\n */\n const snapshot = () => {\n messages.value = messages.value.slice()\n }\n\n /**\n * 发送一条用户消息。会话进行中时进入待发送队列(不丢失),\n * 会话自然结束后按 FIFO 自动接续;空闲时立即开始新会话。\n */\n const send = (content: string, attachments?: ChatAttachment[]) => {\n if (!content.trim() && !attachments?.length) return\n const current = session()\n if (current) {\n void current.send(content, attachments)\n return\n }\n enqueue(content, attachments)\n }\n\n /**\n * 向队列插入一条消息;beforeId 指定插到某条之前(缺省追加到尾部)。\n * 空闲时立即消耗队首(保持先来先发的合理顺序)。\n */\n const enqueue = (\n content: string,\n attachments?: ChatAttachment[],\n beforeId?: string\n ): ChatQueuedMessage => {\n const item: ChatQueuedMessage = { id: uid(), content, attachments }\n if (session()) return item\n const anchorIndex = beforeId ? queue.value.findIndex((q) => q.id === beforeId) : -1\n if (anchorIndex === -1) {\n queue.value.push(item)\n } else {\n queue.value.splice(anchorIndex, 0, item)\n }\n if (!running.value) drainQueue()\n return item\n }\n\n /** 消耗队首并开始新一轮会话 */\n const drainQueue = () => {\n const next = queue.value.shift()\n if (next) startUserTurn(next.content, next.attachments)\n }\n\n /** 立即执行队列中的某条:中断当前会话,该条插队为下一条(其余保持原顺序) */\n const startQueued = (id: string) => {\n if (session()) return\n const item = removeQueued(id)\n if (!item) return\n\n if (!running.value) {\n startUserTurn(item.content, item.attachments)\n return\n }\n\n // 插回队首并中断当前会话,收尾逻辑会自动接上它\n queue.value.unshift(item)\n resumeAfterAbort = true\n abort()\n }\n\n /** 从队列移除某条(返回被移除项;编辑场景由 UI 取回内容) */\n const removeQueued = (id: string): ChatQueuedMessage | undefined => {\n if (session()) return undefined\n const index = queue.value.findIndex((item) => item.id === id)\n if (index === -1) return undefined\n const [item] = queue.value.splice(index, 1)\n return item\n }\n\n /** 推送用户消息并启动对话循环 */\n const startUserTurn = (content: string, attachments?: ChatAttachment[]) => {\n const message = reactive<ChatMessage>({ id: uid(), role: 'user', content, attachments })\n messages.value.push(message)\n snapshot()\n emit('send', message)\n\n void runConversation()\n }\n\n /** 执行单个工具调用(含确认门控),结果以 tool 消息追加 */\n const executeToolCall = async (\n toolCall: ChatToolCall,\n signal: AbortSignal,\n tools: ChatTool[]\n ) => {\n const appendToolMessage = (content: string) => {\n messages.value.push(\n reactive<ChatMessage>({ id: uid(), role: 'tool', toolCallId: toolCall.id, content })\n )\n }\n\n const tool = tools.find((t) => t.name === toolCall.name)\n\n if (!tool) {\n toolCall.status = 'error'\n toolCall.error = `未找到名为 \"${toolCall.name}\" 的工具`\n appendToolMessage(`Error: tool \"${toolCall.name}\" is not available.`)\n return\n }\n\n if (tool.needsConfirm) {\n toolCall.status = 'awaiting-confirm'\n const approved = await new Promise<boolean>((resolve) => {\n confirmResolvers.set(toolCall.id, resolve)\n signal.addEventListener('abort', () => resolve(false), { once: true })\n })\n confirmResolvers.delete(toolCall.id)\n\n if (!approved) {\n toolCall.status = 'rejected'\n appendToolMessage(`The user rejected the execution of tool \"${toolCall.name}\".`)\n return\n }\n }\n\n toolCall.status = 'running'\n try {\n const args = toolCall.arguments ? JSON.parse(toolCall.arguments) : {}\n const result = await tool.execute(args, { toolCall, signal })\n toolCall.status = 'success'\n toolCall.result = serializeResult(result)\n appendToolMessage(toolCall.result)\n } catch (error) {\n toolCall.status = 'error'\n toolCall.error = error instanceof Error ? error.message : String(error)\n appendToolMessage(`Error: ${toolCall.error}`)\n }\n }\n\n /** 对话主循环入口:生成 → 工具执行 → 再生成,直至模型不再调用工具 */\n const runConversation = async () => {\n if (!props.transport) return\n\n running.value = true\n finishedNaturally = false\n lastTurnUsage.value = null\n const controller = new AbortController()\n abortController = controller\n\n try {\n await runRound(controller, 0)\n } finally {\n running.value = false\n abortController = null\n snapshot()\n\n // 队列接续:自然完成或 startQueued 中断插队时自动发下一条;\n // 手动停止 / 出错时保留队列,交由用户处置\n const shouldResume = finishedNaturally || resumeAfterAbort\n finishedNaturally = false\n resumeAfterAbort = false\n if (shouldResume) drainQueue()\n }\n }\n\n /** 单轮生成;存在工具调用时执行后递归进入下一轮(递归避免 await-in-loop) */\n const runRound = async (controller: AbortController, depth: number): Promise<void> => {\n const transport = props.transport\n if (typeof transport !== 'function') return\n const { signal } = controller\n const tools = resolveTools(props.tools as ChatTool[] | undefined)\n\n const assistant = reactive<ChatMessage>({\n id: uid(),\n role: 'assistant',\n content: '',\n status: 'streaming',\n toolCalls: []\n })\n messages.value.push(assistant)\n\n let requestError: Error | null = null\n\n try {\n await transport(\n {\n // 排除刚追加的 assistant 占位消息\n messages: messages.value.slice(0, -1),\n systemPrompt: props.systemPrompt,\n tools,\n model: model.value,\n reasoningLevel: reasoningLevel.value,\n signal\n },\n {\n onTextDelta: (delta) => {\n assistant.content += delta\n },\n onReasoningDelta: (delta) => {\n assistant.reasoning = (assistant.reasoning ?? '') + delta\n },\n onToolCall: (call) => {\n const toolCall = reactive<ChatToolCall>({ ...call, status: 'pending' })\n assistant.toolCalls!.push(toolCall)\n emit('tool-call', toolCall)\n },\n onUsage: (usage) => {\n lastTurnUsage.value = lastTurnUsage.value\n ? addTokenUsage(lastTurnUsage.value, usage)\n : usage\n tokenUsage.value = tokenUsage.value ? addTokenUsage(tokenUsage.value, usage) : usage\n },\n onError: (error) => {\n requestError = error\n }\n }\n )\n } catch (error) {\n requestError = error instanceof Error ? error : new Error(String(error))\n }\n\n if (signal.aborted) {\n assistant.status = 'aborted'\n return\n }\n\n if (requestError) {\n assistant.status = 'error'\n emit('error', requestError)\n return\n }\n\n assistant.status = 'done'\n\n const toolCalls = assistant.toolCalls ?? []\n if (!toolCalls.length) {\n completeAsFinish(assistant)\n return\n }\n\n // 工具串行执行(保持结果消息与调用顺序一致),用 Promise 链避免 await-in-loop\n await toolCalls.reduce<Promise<void>>(\n (prev, toolCall) =>\n prev.then(() => (signal.aborted ? undefined : executeToolCall(toolCall, signal, tools))),\n Promise.resolve()\n )\n snapshot()\n\n if (signal.aborted) return\n\n // 终结工具执行成功:工具 UI 即最终答复,不再回灌模型生成文字\n const hitTerminal = toolCalls.some(\n (call) => call.status === 'success' && tools.some((t) => t.name === call.name && t.terminal)\n )\n if (hitTerminal) {\n completeAsFinish(assistant)\n return\n }\n\n // 达到最大轮次上限:停止继续请求,防止模型失控循环调用工具\n if (depth + 1 >= (props.maxToolRounds ?? 10)) {\n completeAsFinish(assistant)\n return\n }\n\n return runRound(controller, depth + 1)\n }\n\n /** 标记本轮对话自然完成(收尾后据此自动消耗待发送队列) */\n const completeAsFinish = (assistant: ChatMessage) => {\n finishedNaturally = true\n emit('finish', assistant)\n }\n\n /** 中断当前生成,挂起的工具确认按拒绝处理 */\n const abort = () => {\n const current = session()\n if (current) {\n void current.cancel()\n return\n }\n abortController?.abort()\n }\n\n /** 响应 needsConfirm 工具的用户确认;session 下按 callId 找到 rpcId 后 respond */\n const respondToolCall = (toolCallId: string, approved: boolean) => {\n const current = session()\n if (current) {\n const pending = pendingApprovals.value.find((a) => a.callId === toolCallId)\n if (pending) void current.respond(pending.rpcId, approved)\n return\n }\n confirmResolvers.get(toolCallId)?.(approved)\n }\n\n /** session 审批横幅 / 提问提交:直接 respond(rpcId) */\n const respondSession = (rpcId: string, ok: boolean, value?: unknown) => {\n const current = session()\n if (current) void current.respond(rpcId, ok, value)\n }\n\n /** 重新生成:移除最后一条用户消息之后的所有消息,重新跑对话循环 */\n const regenerate = () => {\n if (session() || running.value) return\n\n const list = messages.value\n let lastUserIndex = -1\n for (let i = list.length - 1; i >= 0; i--) {\n if (list[i]?.role === 'user') {\n lastUserIndex = i\n break\n }\n }\n if (lastUserIndex === -1 || lastUserIndex === list.length - 1) return\n\n messages.value = list.slice(0, lastUserIndex + 1)\n snapshot()\n void runConversation()\n }\n\n /** 清空消息、待发送队列与 token 统计,生成中则先中断 */\n const clear = () => {\n if (session()) {\n sessionRuntime.resetLocal()\n lastTurnUsage.value = null\n return\n }\n abort()\n queue.value = []\n messages.value = []\n tokenUsage.value = null\n lastTurnUsage.value = null\n snapshot()\n }\n\n return {\n messages,\n model,\n reasoningLevel,\n running,\n queue,\n jobs,\n tokenUsage,\n lastTurnUsage,\n projections,\n title,\n pendingApprovals,\n pendingQuestion,\n send,\n abort,\n regenerate,\n clear,\n respondToolCall,\n respondSession,\n enqueue,\n startQueued,\n removeQueued\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"use-chat.js","names":[],"sources":["../../src/chat/use-chat.ts"],"sourcesContent":["import { useModel } from '@veltra/compositions'\nimport { reactive, ref, watch, type Ref } from 'vue'\n\nimport type { AskQuestionItem } from '../tools'\nimport type { AiChatEmits, AiChatProps } from '../types'\nimport type { ChatPendingApproval } from './fold'\nimport { isServerTransport, type ChatSessionTransport } from './session'\nimport type {\n ChatAttachment,\n ChatJob,\n ChatMessage,\n ChatQueuedMessage,\n ChatTokenUsage,\n ChatTool,\n ChatToolCall\n} from './types'\nimport { addTokenUsage, resolveTools, serializeResult } from './use-chat-helpers'\nimport { createSessionRuntime } from './use-chat-session'\n\nexport interface UseChatOptions {\n props: AiChatProps\n emit: AiChatEmits\n}\n\nconst uid = () => {\n return typeof crypto !== 'undefined' && crypto.randomUUID\n ? crypto.randomUUID()\n : `${Date.now()}-${Math.random().toString(36).slice(2)}`\n}\n\n/**\n * AI 对话核心状态机:消息管理、流式追加、工具调用循环编排。\n * 与 UI 解耦,ai-chat.vue 只负责渲染与交互转发。\n * 函数型 transport 注入内置工具;session transport 不注入、不执行客户端工具。\n */\nexport function useChat(options: UseChatOptions) {\n const { props, emit } = options\n\n const messages = useModel({\n props,\n propName: 'messages',\n emit,\n local: true,\n defaultValue: []\n }) as Ref<ChatMessage[]>\n\n const model = useModel({ props, propName: 'model', emit, local: true }) as Ref<string | undefined>\n\n const reasoningLevel = useModel({ props, propName: 'reasoningLevel', emit, local: true }) as Ref<\n string | undefined\n >\n\n /** 是否正在生成中(含工具执行与多轮循环) */\n const running = ref(false)\n\n /** 待发送队列:会话进行中提交的新消息按序排队,收尾后先进先出自动接续 */\n const queue = ref<ChatQueuedMessage[]>([])\n\n /** 当前会话累计 token;从未收到 usage 时为 null */\n const tokenUsage = ref<ChatTokenUsage | null>(null)\n /** 最近一轮用户对话(含工具多轮请求)的 token;该轮无 usage 时为 null */\n const lastTurnUsage = ref<ChatTokenUsage | null>(null)\n /** 最近一次单次请求的 token(未跨轮累计),其 totalTokens 近似当前上下文占用 */\n const lastRequestUsage = ref<ChatTokenUsage | null>(null)\n\n /** session 下由 jobs/snapshot 整体替换;函数 transport 保持空数组 */\n const jobs = ref<ChatJob[]>([])\n const projections = ref<Record<string, unknown>>({})\n const title = ref<string | null>(null)\n const pendingApprovals = ref<ChatPendingApproval[]>([])\n const pendingQuestion = ref<{ questions: AskQuestionItem[]; rpcId: string } | null>(null)\n\n const sessionRuntime = createSessionRuntime({\n messages,\n queue,\n jobs,\n tokenUsage,\n projections,\n title,\n running,\n pendingApprovals,\n pendingQuestion\n })\n\n const session = (): ChatSessionTransport | null =>\n isServerTransport(props.transport) ? props.transport : null\n\n let abortController: AbortController | null = null\n /** needsConfirm 工具的挂起确认器,key 为 toolCallId */\n const confirmResolvers = new Map<string, (approved: boolean) => void>()\n /** 当前会话是否自然完成(finish);决定收尾后是否自动消耗队列 */\n let finishedNaturally = false\n /** startQueued 触发的中断需要在收尾后立即接续队首 */\n let resumeAfterAbort = false\n\n /** 按当前模型校正推理等级(无 levels 则清空;值不合法则落到默认/首项) */\n const syncReasoningForModel = (modelId: string | undefined) => {\n const option = props.models?.find((m) => m.id === modelId)\n if (!option?.reasoningLevels?.length) {\n if (reasoningLevel.value !== undefined) {\n reasoningLevel.value = undefined\n }\n return\n }\n const levels = option.reasoningLevels\n if (reasoningLevel.value && levels.some((l) => l.value === reasoningLevel.value)) return\n reasoningLevel.value = option.defaultReasoningLevel ?? levels[0]?.value\n }\n\n // models / model 变化时:确保有合法选中模型,并校正推理等级\n watch(\n () => [props.models, model.value] as const,\n ([models, current]) => {\n if (!models?.length) return\n if (!current || !models.some((m) => m.id === current)) {\n model.value = models[0]!.id\n return\n }\n syncReasoningForModel(current)\n },\n { immediate: true }\n )\n\n // session:open + fetchHistory 播种;更换或卸载时 disposer\n watch(\n () => props.transport,\n (transport, _prev, onCleanup) => {\n if (!isServerTransport(transport)) return\n sessionRuntime.attach(transport, onCleanup)\n },\n { immediate: true }\n )\n\n // session 且已 open 时,模型切换走 selectModel\n watch(model, (modelId) => {\n const current = session()\n if (!sessionRuntime.opened || !current || !modelId) return\n const option = props.models?.find((m) => m.id === modelId)\n if (!option) return\n void current.selectModel(option.providerId, modelId)\n })\n\n /**\n * 向父组件同步消息快照(流式 delta 过于频繁,不同步,只在关键节点同步)。\n * 必须经 setter 赋值触发 emit,而非直接 emit 切片:setter 让本地数组与下发的\n * 快照保持同一引用,受控父级(v-model:messages)把快照回写到 props 时,\n * useModel 同步回来的仍是这个数组(流式期间新增的 assistant 占位已 push 在其上);\n * 若直接 emit 切片,本地数组与快照是两个对象,回显的旧快照会把本地新增的消息冲掉。\n */\n const snapshot = () => {\n messages.value = messages.value.slice()\n }\n\n /**\n * 发送一条用户消息。会话进行中时进入待发送队列(不丢失),\n * 会话自然结束后按 FIFO 自动接续;空闲时立即开始新会话。\n */\n const send = (content: string, attachments?: ChatAttachment[]) => {\n if (!content.trim() && !attachments?.length) return\n const current = session()\n if (current) {\n void current.send(content, attachments)\n return\n }\n enqueue(content, attachments)\n }\n\n /**\n * 向队列插入一条消息;beforeId 指定插到某条之前(缺省追加到尾部)。\n * 空闲时立即消耗队首(保持先来先发的合理顺序)。\n */\n const enqueue = (\n content: string,\n attachments?: ChatAttachment[],\n beforeId?: string\n ): ChatQueuedMessage => {\n const item: ChatQueuedMessage = { id: uid(), content, attachments }\n if (session()) return item\n const anchorIndex = beforeId ? queue.value.findIndex((q) => q.id === beforeId) : -1\n if (anchorIndex === -1) {\n queue.value.push(item)\n } else {\n queue.value.splice(anchorIndex, 0, item)\n }\n if (!running.value) drainQueue()\n return item\n }\n\n /** 消耗队首并开始新一轮会话 */\n const drainQueue = () => {\n const next = queue.value.shift()\n if (next) startUserTurn(next.content, next.attachments)\n }\n\n /** 立即执行队列中的某条:中断当前会话,该条插队为下一条(其余保持原顺序) */\n const startQueued = (id: string) => {\n if (session()) return\n const item = removeQueued(id)\n if (!item) return\n\n if (!running.value) {\n startUserTurn(item.content, item.attachments)\n return\n }\n\n // 插回队首并中断当前会话,收尾逻辑会自动接上它\n queue.value.unshift(item)\n resumeAfterAbort = true\n abort()\n }\n\n /** 从队列移除某条(返回被移除项;编辑场景由 UI 取回内容) */\n const removeQueued = (id: string): ChatQueuedMessage | undefined => {\n if (session()) return undefined\n const index = queue.value.findIndex((item) => item.id === id)\n if (index === -1) return undefined\n const [item] = queue.value.splice(index, 1)\n return item\n }\n\n /** 推送用户消息并启动对话循环 */\n const startUserTurn = (content: string, attachments?: ChatAttachment[]) => {\n const message = reactive<ChatMessage>({ id: uid(), role: 'user', content, attachments })\n messages.value.push(message)\n snapshot()\n emit('send', message)\n\n void runConversation()\n }\n\n /** 执行单个工具调用(含确认门控),结果以 tool 消息追加 */\n const executeToolCall = async (\n toolCall: ChatToolCall,\n signal: AbortSignal,\n tools: ChatTool[]\n ) => {\n const appendToolMessage = (content: string) => {\n messages.value.push(\n reactive<ChatMessage>({ id: uid(), role: 'tool', toolCallId: toolCall.id, content })\n )\n }\n\n const tool = tools.find((t) => t.name === toolCall.name)\n\n if (!tool) {\n toolCall.status = 'error'\n toolCall.error = `未找到名为 \"${toolCall.name}\" 的工具`\n appendToolMessage(`Error: tool \"${toolCall.name}\" is not available.`)\n return\n }\n\n if (tool.needsConfirm) {\n toolCall.status = 'awaiting-confirm'\n const approved = await new Promise<boolean>((resolve) => {\n confirmResolvers.set(toolCall.id, resolve)\n signal.addEventListener('abort', () => resolve(false), { once: true })\n })\n confirmResolvers.delete(toolCall.id)\n\n if (!approved) {\n toolCall.status = 'rejected'\n appendToolMessage(`The user rejected the execution of tool \"${toolCall.name}\".`)\n return\n }\n }\n\n toolCall.status = 'running'\n try {\n const args = toolCall.arguments ? JSON.parse(toolCall.arguments) : {}\n const result = await tool.execute(args, { toolCall, signal })\n toolCall.status = 'success'\n toolCall.result = serializeResult(result)\n appendToolMessage(toolCall.result)\n } catch (error) {\n toolCall.status = 'error'\n toolCall.error = error instanceof Error ? error.message : String(error)\n appendToolMessage(`Error: ${toolCall.error}`)\n }\n }\n\n /** 对话主循环入口:生成 → 工具执行 → 再生成,直至模型不再调用工具 */\n const runConversation = async () => {\n if (!props.transport) return\n\n running.value = true\n finishedNaturally = false\n lastTurnUsage.value = null\n const controller = new AbortController()\n abortController = controller\n\n try {\n await runRound(controller, 0)\n } finally {\n running.value = false\n abortController = null\n snapshot()\n\n // 队列接续:自然完成或 startQueued 中断插队时自动发下一条;\n // 手动停止 / 出错时保留队列,交由用户处置\n const shouldResume = finishedNaturally || resumeAfterAbort\n finishedNaturally = false\n resumeAfterAbort = false\n if (shouldResume) drainQueue()\n }\n }\n\n /** 单轮生成;存在工具调用时执行后递归进入下一轮(递归避免 await-in-loop) */\n const runRound = async (controller: AbortController, depth: number): Promise<void> => {\n const transport = props.transport\n if (typeof transport !== 'function') return\n const { signal } = controller\n const tools = resolveTools(props.tools as ChatTool[] | undefined)\n\n const assistant = reactive<ChatMessage>({\n id: uid(),\n role: 'assistant',\n content: '',\n status: 'streaming',\n toolCalls: []\n })\n messages.value.push(assistant)\n\n let requestError: Error | null = null\n\n try {\n await transport(\n {\n // 排除刚追加的 assistant 占位消息\n messages: messages.value.slice(0, -1),\n systemPrompt: props.systemPrompt,\n tools,\n model: model.value,\n reasoningLevel: reasoningLevel.value,\n signal\n },\n {\n onTextDelta: (delta) => {\n assistant.content += delta\n },\n onReasoningDelta: (delta) => {\n assistant.reasoning = (assistant.reasoning ?? '') + delta\n },\n onToolCall: (call) => {\n const toolCall = reactive<ChatToolCall>({ ...call, status: 'pending' })\n assistant.toolCalls!.push(toolCall)\n emit('tool-call', toolCall)\n },\n onUsage: (usage) => {\n lastRequestUsage.value = usage\n lastTurnUsage.value = lastTurnUsage.value\n ? addTokenUsage(lastTurnUsage.value, usage)\n : usage\n tokenUsage.value = tokenUsage.value ? addTokenUsage(tokenUsage.value, usage) : usage\n },\n onError: (error) => {\n requestError = error\n }\n }\n )\n } catch (error) {\n requestError = error instanceof Error ? error : new Error(String(error))\n }\n\n if (signal.aborted) {\n assistant.status = 'aborted'\n return\n }\n\n if (requestError) {\n assistant.status = 'error'\n emit('error', requestError)\n return\n }\n\n assistant.status = 'done'\n\n const toolCalls = assistant.toolCalls ?? []\n if (!toolCalls.length) {\n completeAsFinish(assistant)\n return\n }\n\n // 工具串行执行(保持结果消息与调用顺序一致),用 Promise 链避免 await-in-loop\n await toolCalls.reduce<Promise<void>>(\n (prev, toolCall) =>\n prev.then(() => (signal.aborted ? undefined : executeToolCall(toolCall, signal, tools))),\n Promise.resolve()\n )\n snapshot()\n\n if (signal.aborted) return\n\n // 终结工具执行成功:工具 UI 即最终答复,不再回灌模型生成文字\n const hitTerminal = toolCalls.some(\n (call) => call.status === 'success' && tools.some((t) => t.name === call.name && t.terminal)\n )\n if (hitTerminal) {\n completeAsFinish(assistant)\n return\n }\n\n // 达到最大轮次上限:停止继续请求,防止模型失控循环调用工具\n if (depth + 1 >= (props.maxToolRounds ?? 10)) {\n completeAsFinish(assistant)\n return\n }\n\n return runRound(controller, depth + 1)\n }\n\n /** 标记本轮对话自然完成(收尾后据此自动消耗待发送队列) */\n const completeAsFinish = (assistant: ChatMessage) => {\n finishedNaturally = true\n emit('finish', assistant)\n }\n\n /** 中断当前生成,挂起的工具确认按拒绝处理 */\n const abort = () => {\n const current = session()\n if (current) {\n void current.cancel()\n return\n }\n abortController?.abort()\n }\n\n /** 响应 needsConfirm 工具的用户确认;session 下按 callId 找到 rpcId 后 respond */\n const respondToolCall = (toolCallId: string, approved: boolean) => {\n const current = session()\n if (current) {\n const pending = pendingApprovals.value.find((a) => a.callId === toolCallId)\n if (pending) void current.respond(pending.rpcId, approved)\n return\n }\n confirmResolvers.get(toolCallId)?.(approved)\n }\n\n /** session 审批横幅 / 提问提交:直接 respond(rpcId) */\n const respondSession = (rpcId: string, ok: boolean, value?: unknown) => {\n const current = session()\n if (current) void current.respond(rpcId, ok, value)\n }\n\n /** 重新生成:移除最后一条用户消息之后的所有消息,重新跑对话循环 */\n const regenerate = () => {\n if (session() || running.value) return\n\n const list = messages.value\n let lastUserIndex = -1\n for (let i = list.length - 1; i >= 0; i--) {\n if (list[i]?.role === 'user') {\n lastUserIndex = i\n break\n }\n }\n if (lastUserIndex === -1 || lastUserIndex === list.length - 1) return\n\n messages.value = list.slice(0, lastUserIndex + 1)\n snapshot()\n void runConversation()\n }\n\n /** 清空消息、待发送队列与 token 统计,生成中则先中断 */\n const clear = () => {\n if (session()) {\n sessionRuntime.resetLocal()\n lastTurnUsage.value = null\n lastRequestUsage.value = null\n return\n }\n abort()\n queue.value = []\n messages.value = []\n tokenUsage.value = null\n lastTurnUsage.value = null\n lastRequestUsage.value = null\n snapshot()\n }\n\n return {\n messages,\n model,\n reasoningLevel,\n running,\n queue,\n jobs,\n tokenUsage,\n lastTurnUsage,\n lastRequestUsage,\n projections,\n title,\n pendingApprovals,\n pendingQuestion,\n send,\n abort,\n regenerate,\n clear,\n respondToolCall,\n respondSession,\n enqueue,\n startQueued,\n removeQueued\n }\n}\n"],"mappings":"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"}
|
|
@@ -65,7 +65,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
|
|
|
65
65
|
return Object.fromEntries(tools.map((t) => [t.name, t]));
|
|
66
66
|
});
|
|
67
67
|
const toolIcons = computed(() => props.toolIcons);
|
|
68
|
-
const { messages, model, reasoningLevel, running, queue, jobs, tokenUsage, lastTurnUsage, pendingApprovals, pendingQuestion, send, abort, regenerate, clear, respondToolCall, respondSession, enqueue, startQueued, removeQueued } = useChat({
|
|
68
|
+
const { messages, model, reasoningLevel, running, queue, jobs, tokenUsage, lastTurnUsage, lastRequestUsage, pendingApprovals, pendingQuestion, send, abort, regenerate, clear, respondToolCall, respondSession, enqueue, startQueued, removeQueued } = useChat({
|
|
69
69
|
props,
|
|
70
70
|
emit
|
|
71
71
|
});
|
|
@@ -273,6 +273,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
|
|
|
273
273
|
"max-attachment-size": __props.maxAttachmentSize,
|
|
274
274
|
clearable: clearable.value,
|
|
275
275
|
"token-usage": unref(tokenUsage),
|
|
276
|
+
"last-request-usage": unref(lastRequestUsage),
|
|
276
277
|
"token-usage-detail": __props.tokenUsageDetail,
|
|
277
278
|
onSend: handleSend,
|
|
278
279
|
onAbort: unref(abort),
|
|
@@ -287,6 +288,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
|
|
|
287
288
|
"max-attachment-size",
|
|
288
289
|
"clearable",
|
|
289
290
|
"token-usage",
|
|
291
|
+
"last-request-usage",
|
|
290
292
|
"token-usage-detail",
|
|
291
293
|
"onAbort",
|
|
292
294
|
"onClear"
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ai-chat.js","names":["$slots"],"sources":["../../../src/components/ai-chat/ai-chat.vue"],"sourcesContent":["<template>\n <ULayout\n ref=\"layoutRef\"\n :class=\"[cls.b, bem.is('resizing', resizing)]\"\n :cols=\"layoutCols\"\n rows=\"1fr\"\n :resizable=\"panelOpen\"\n :col-min-sizes=\"panelOpen ? [MAIN_MIN_WIDTH, PANEL_MIN_WIDTH] : undefined\"\n @resize-start=\"resizing = true\"\n @resize-end=\"resizing = false\"\n >\n <div :class=\"[cls.e('main'), bem.is('empty', isEmpty)]\">\n <MessageList\n :messages=\"messages\"\n :welcome=\"welcome\"\n :running=\"running\"\n :readonly=\"readonly\"\n :renderer-props=\"rendererProps\"\n @respond=\"respondToolCall\"\n @regenerate=\"regenerate\"\n @welcome-click=\"(text) => handleSend(text, [])\"\n >\n <template v-if=\"$slots.welcome\" #welcome>\n <slot name=\"welcome\" />\n </template>\n </MessageList>\n\n <div v-if=\"pendingQuestion\" :class=\"cls.e('session-question')\">\n <AskQuestion :questions=\"pendingQuestion.questions\" @submit=\"handleQuestionSubmit\" />\n </div>\n\n <ApprovalBanner :approvals=\"pendingApprovals\" @respond=\"respondSession\" />\n\n <QueueList\n :queue=\"queue\"\n :readonly=\"readonly\"\n @start-now=\"startQueued\"\n @edit=\"handleQueueEdit\"\n @remove=\"removeQueued\"\n />\n\n <JobBar :jobs=\"jobs\" />\n\n <ChatInput\n v-if=\"!readonly\"\n ref=\"chatInputRef\"\n v-model:model=\"model\"\n v-model:reasoning-level=\"reasoningLevel\"\n :running=\"running\"\n :models=\"models\"\n :placeholder=\"placeholder\"\n :accept=\"accept\"\n :max-attachment-size=\"maxAttachmentSize\"\n :clearable=\"clearable\"\n :token-usage=\"tokenUsage\"\n :token-usage-detail=\"tokenUsageDetail\"\n @send=\"handleSend\"\n @abort=\"abort\"\n @clear=\"clear\"\n />\n </div>\n\n <!-- 侧边面板(renderTo: 'panel' 的工具渲染区),与会话区宽度经 ULayout 拖拽手柄调节 -->\n <SidePanel\n v-if=\"activePanelCall\"\n ref=\"panelRef\"\n :tool-call=\"activePanelCall\"\n @close=\"closePanel\"\n />\n </ULayout>\n</template>\n\n<script lang=\"ts\" setup>\nimport { useResizeObserver } from '@veltra/compositions'\nimport { ULayout } from '@veltra/desktop'\nimport { bem } from '@veltra/utils'\nimport { computed, provide, ref, useSlots, useTemplateRef, watch } from 'vue'\n\nimport { isServerTransport } from '../../chat/session'\nimport type { ChatAttachment, ChatTool, ChatToolCall, ChatToolMeta } from '../../chat/types'\nimport { useChat } from '../../chat/use-chat'\nimport { createBuiltinTools } from '../../tools'\nimport type { AskQuestionAnswer, AskQuestionResult } from '../../tools/ask-question/ask-question'\nimport type { _AiChatExposed, AiChatEmits, AiChatProps } from '../../types'\nimport ApprovalBanner from './approval-banner.vue'\nimport AskQuestion from './ask-question.vue'\nimport ChatInput from './chat-input.vue'\nimport { AiChatDIKey } from './di'\nimport JobBar from './job-bar.vue'\nimport MessageList from './message-list.vue'\nimport QueueList from './queue-list.vue'\nimport SidePanel from './side-panel.vue'\n\ndefineOptions({ name: 'UAiChat' })\n\nconst props = defineProps<AiChatProps>()\n\nconst emit = defineEmits<AiChatEmits>()\n\ndefineSlots<{\n /** 空闲欢迎区插槽(输入框上方;工作中跳到列表末尾) */\n welcome(): any\n /** 按工具名自定义工具结果展示,如 tool-getWeather */\n [name: `tool-${string}`]: (scope: { toolCall: ChatToolCall }) => any\n}>()\n\nconst cls = bem('ai-chat')\n\nconst slots = useSlots()\n\n/**\n * 按 name 查表:session 下 tools 仅为渲染元信息(不注入内置工具,忽略 execute 等)。\n * 函数 transport 仍注入 createBuiltinTools(同名内置优先)。\n */\nconst toolMap = computed((): Record<string, ChatTool | ChatToolMeta | undefined> => {\n if (isServerTransport(props.transport)) {\n return Object.fromEntries((props.tools ?? []).map((t) => [t.name, t]))\n }\n const builtins = createBuiltinTools()\n const names = new Set(builtins.map((t) => t.name))\n const tools = [...builtins, ...(props.tools ?? []).filter((t) => !names.has(t.name))]\n return Object.fromEntries(tools.map((t) => [t.name, t]))\n})\n\nconst toolIcons = computed(() => props.toolIcons)\n\nconst {\n messages,\n model,\n reasoningLevel,\n running,\n queue,\n jobs,\n tokenUsage,\n lastTurnUsage,\n pendingApprovals,\n pendingQuestion,\n send,\n abort,\n regenerate,\n clear,\n respondToolCall,\n respondSession,\n enqueue,\n startQueued,\n removeQueued\n} = useChat({ props, emit })\n\n/** 空会话(无可见消息):输入区垂直居中;欢迎区钉在滚动容器外、贴于输入框上方 */\nconst isEmpty = computed(() => !messages.value.some((msg) => msg.role !== 'tool'))\n\n/** 有对话内容、队列或生成中才允许清除,避免误触空会话 */\nconst clearable = computed(\n () => messages.value.length > 0 || queue.value.length > 0 || running.value\n)\n\n/** 所有 renderTo: 'panel' 的工具调用(按消息顺序,面板内容的来源) */\nconst panelCalls = computed(() => {\n const calls: ChatToolCall[] = []\n for (const msg of messages.value) {\n if (msg.role !== 'assistant' || !msg.toolCalls) continue\n for (const call of msg.toolCalls) {\n if (toolMap.value[call.name]?.renderTo === 'panel') calls.push(call)\n }\n }\n return calls\n})\n\n/** 当前展示在右侧面板的工具调用 id;null 表示面板关闭 */\nconst activePanelCallId = ref<string | null>(null)\n\nconst activePanelCall = computed(() => {\n if (!activePanelCallId.value) return null\n return panelCalls.value.find((call) => call.id === activePanelCallId.value) ?? null\n})\n\nconst panelOpen = computed(() => activePanelCall.value != null)\n\n// 出现新的面板工具调用时自动打开并聚焦到它\nwatch(\n () => panelCalls.value[panelCalls.value.length - 1]?.id,\n (id) => {\n if (id) activePanelCallId.value = id\n }\n)\n\n// 聚焦的调用消失(清空消息 / 重新生成)时,回落到最近一次面板调用或关闭\nwatch(panelCalls, (calls) => {\n const id = activePanelCallId.value\n if (id && !calls.some((call) => call.id === id)) {\n activePanelCallId.value = calls[calls.length - 1]?.id ?? null\n }\n})\n\n/** 打开面板并聚焦到指定工具调用(工具卡片「查看面板」入口,经 DI 下发) */\nconst openPanel = (toolCallId: string) => {\n if (panelCalls.value.some((call) => call.id === toolCallId)) {\n activePanelCallId.value = toolCallId\n }\n}\n\nconst closePanel = () => {\n activePanelCallId.value = null\n}\n\nprovide(AiChatDIKey, { cls, slots, tools: toolMap, toolIcons, openPanel })\n\n/**\n * 面板宽度:经 ULayout 列轨施加(cols = 1fr + 面板宽度),\n * 拖拽手柄由 ULayout 提供(colMinSizes 约束会话区 / 面板最小宽度)。\n */\nconst PANEL_MIN_WIDTH = 320\nconst MAIN_MIN_WIDTH = 360\n/** 面板打开时默认给会话区留的宽度(即面板默认尽可能大) */\nconst MAIN_DEFAULT_WIDTH = 860\n\nconst layoutRef = useTemplateRef<InstanceType<typeof ULayout>>('layoutRef')\nconst panelRef = useTemplateRef<InstanceType<typeof SidePanel>>('panelRef')\nconst panelWidth = ref(PANEL_MIN_WIDTH)\nconst resizing = ref(false)\n\nconst layoutCols = computed(() => {\n return panelOpen.value ? ['1fr', `${panelWidth.value}px`] : ['1fr']\n})\n\nconst containerWidth = () => (layoutRef.value?.$el as HTMLElement | undefined)?.clientWidth ?? 0\n\n/** 面板默认宽度:容器宽减去会话区默认保留宽度(缺省给会话区留 MAIN_DEFAULT_WIDTH) */\nconst defaultPanelWidth = () => {\n const width = containerWidth()\n return width ? width - MAIN_DEFAULT_WIDTH : 420\n}\n\n/** 钳位面板宽度在 [PANEL_MIN_WIDTH, 容器宽 - MAIN_MIN_WIDTH] 区间 */\nconst clampPanelWidth = (width: number) => {\n const width0 = containerWidth()\n const maxWidth = width0\n ? Math.max(PANEL_MIN_WIDTH, width0 - MAIN_MIN_WIDTH)\n : Number.MAX_SAFE_INTEGER\n return Math.min(Math.max(width, PANEL_MIN_WIDTH), maxWidth)\n}\n\n// 聚焦到指定了 panelWidth 的工具调用时应用其宽度;面板从关闭到打开且工具未指定宽度时\n// 应用默认宽(会话区留 860px);面板已打开时切换聚焦保持当前宽度(含用户拖拽结果)\nwatch(activePanelCall, (call, prevCall) => {\n if (!call) return\n const toolWidth = toolMap.value[call.name]?.panelWidth\n const width = toolWidth ?? (prevCall ? undefined : defaultPanelWidth())\n if (typeof width !== 'number') return\n panelWidth.value = clampPanelWidth(width)\n})\n\n// 跟踪面板实际宽度(含用户拖拽结果):面板关闭重开、切换到未指定宽度的工具时保持\nuseResizeObserver({\n targets: computed(() => (panelRef.value?.$el as HTMLElement | undefined) ?? undefined),\n onResize(entries) {\n // borderBoxSize 随通知携带(含边框、零布局开销),不读 offsetWidth/clientWidth\n const inlineSize = entries[0]?.borderBoxSize?.[0]?.inlineSize\n if (!inlineSize) return\n const width = Math.round(inlineSize)\n // 滞回:忽略亚像素/边框级的微小偏差(< 2px),阻断任何「观测→回写→再观测」的微缩反馈回路\n if (Math.abs(width - panelWidth.value) < 2) return\n panelWidth.value = width\n }\n})\n\nconst chatInputRef = useTemplateRef<InstanceType<typeof ChatInput>>('chatInputRef')\n\n/**\n * 编辑中的队列项原后继 id(编辑态标记):\n * undefined 表示无编辑;null 表示原位置在队尾;否则重新提交时插回该后继之前,\n * 使前后项顺序不受编辑影响(前项已执行则该项自然成为最前待执行项)。\n */\nconst editingSuccessor = ref<string | null | undefined>(undefined)\n\n/** 编辑队列项:取回输入框重新编辑;已有未完成编辑时先按锚点还原,避免内容丢失 */\nconst handleQueueEdit = (id: string) => {\n restoreEditing()\n\n const index = queue.value.findIndex((item) => item.id === id)\n if (index === -1) return\n editingSuccessor.value = queue.value[index + 1]?.id ?? null\n\n const item = removeQueued(id)\n if (item) chatInputRef.value?.setContent(item.content)\n}\n\n/** 将输入框中未提交的编辑内容按原锚点插回队列 */\nconst restoreEditing = () => {\n if (editingSuccessor.value === undefined) return\n const content = chatInputRef.value?.getContent().trim()\n if (content) enqueue(content, undefined, editingSuccessor.value ?? undefined)\n editingSuccessor.value = undefined\n}\n\nconst handleSend = (content: string, attachments: ChatAttachment[]) => {\n if (props.readonly) return\n // 编辑态提交:插回原锚点位置(会话进行中则留在队列,空闲时由 enqueue 自动消耗队首)\n if (editingSuccessor.value !== undefined) {\n enqueue(content, attachments, editingSuccessor.value ?? undefined)\n editingSuccessor.value = undefined\n return\n }\n send(content, attachments)\n}\n\nconst handleQuestionSubmit = (value: AskQuestionAnswer[] | AskQuestionResult) => {\n const pending = pendingQuestion.value\n if (!pending) return\n respondSession(pending.rpcId, true, Array.isArray(value) ? value : value.answers)\n}\n\ndefineExpose<_AiChatExposed>({\n send,\n abort,\n regenerate,\n clear,\n tokenUsage,\n lastTurnUsage,\n queue,\n startQueued,\n removeQueued,\n enqueue\n})\n</script>\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ai-chat.js","names":["$slots"],"sources":["../../../src/components/ai-chat/ai-chat.vue"],"sourcesContent":["<template>\n <ULayout\n ref=\"layoutRef\"\n :class=\"[cls.b, bem.is('resizing', resizing)]\"\n :cols=\"layoutCols\"\n rows=\"1fr\"\n :resizable=\"panelOpen\"\n :col-min-sizes=\"panelOpen ? [MAIN_MIN_WIDTH, PANEL_MIN_WIDTH] : undefined\"\n @resize-start=\"resizing = true\"\n @resize-end=\"resizing = false\"\n >\n <div :class=\"[cls.e('main'), bem.is('empty', isEmpty)]\">\n <MessageList\n :messages=\"messages\"\n :welcome=\"welcome\"\n :running=\"running\"\n :readonly=\"readonly\"\n :renderer-props=\"rendererProps\"\n @respond=\"respondToolCall\"\n @regenerate=\"regenerate\"\n @welcome-click=\"(text) => handleSend(text, [])\"\n >\n <template v-if=\"$slots.welcome\" #welcome>\n <slot name=\"welcome\" />\n </template>\n </MessageList>\n\n <div v-if=\"pendingQuestion\" :class=\"cls.e('session-question')\">\n <AskQuestion :questions=\"pendingQuestion.questions\" @submit=\"handleQuestionSubmit\" />\n </div>\n\n <ApprovalBanner :approvals=\"pendingApprovals\" @respond=\"respondSession\" />\n\n <QueueList\n :queue=\"queue\"\n :readonly=\"readonly\"\n @start-now=\"startQueued\"\n @edit=\"handleQueueEdit\"\n @remove=\"removeQueued\"\n />\n\n <JobBar :jobs=\"jobs\" />\n\n <ChatInput\n v-if=\"!readonly\"\n ref=\"chatInputRef\"\n v-model:model=\"model\"\n v-model:reasoning-level=\"reasoningLevel\"\n :running=\"running\"\n :models=\"models\"\n :placeholder=\"placeholder\"\n :accept=\"accept\"\n :max-attachment-size=\"maxAttachmentSize\"\n :clearable=\"clearable\"\n :token-usage=\"tokenUsage\"\n :last-request-usage=\"lastRequestUsage\"\n :token-usage-detail=\"tokenUsageDetail\"\n @send=\"handleSend\"\n @abort=\"abort\"\n @clear=\"clear\"\n />\n </div>\n\n <!-- 侧边面板(renderTo: 'panel' 的工具渲染区),与会话区宽度经 ULayout 拖拽手柄调节 -->\n <SidePanel\n v-if=\"activePanelCall\"\n ref=\"panelRef\"\n :tool-call=\"activePanelCall\"\n @close=\"closePanel\"\n />\n </ULayout>\n</template>\n\n<script lang=\"ts\" setup>\nimport { useResizeObserver } from '@veltra/compositions'\nimport { ULayout } from '@veltra/desktop'\nimport { bem } from '@veltra/utils'\nimport { computed, provide, ref, useSlots, useTemplateRef, watch } from 'vue'\n\nimport { isServerTransport } from '../../chat/session'\nimport type { ChatAttachment, ChatTool, ChatToolCall, ChatToolMeta } from '../../chat/types'\nimport { useChat } from '../../chat/use-chat'\nimport { createBuiltinTools } from '../../tools'\nimport type { AskQuestionAnswer, AskQuestionResult } from '../../tools/ask-question/ask-question'\nimport type { _AiChatExposed, AiChatEmits, AiChatProps } from '../../types'\nimport ApprovalBanner from './approval-banner.vue'\nimport AskQuestion from './ask-question.vue'\nimport ChatInput from './chat-input.vue'\nimport { AiChatDIKey } from './di'\nimport JobBar from './job-bar.vue'\nimport MessageList from './message-list.vue'\nimport QueueList from './queue-list.vue'\nimport SidePanel from './side-panel.vue'\n\ndefineOptions({ name: 'UAiChat' })\n\nconst props = defineProps<AiChatProps>()\n\nconst emit = defineEmits<AiChatEmits>()\n\ndefineSlots<{\n /** 空闲欢迎区插槽(输入框上方;工作中跳到列表末尾) */\n welcome(): any\n /** 按工具名自定义工具结果展示,如 tool-getWeather */\n [name: `tool-${string}`]: (scope: { toolCall: ChatToolCall }) => any\n}>()\n\nconst cls = bem('ai-chat')\n\nconst slots = useSlots()\n\n/**\n * 按 name 查表:session 下 tools 仅为渲染元信息(不注入内置工具,忽略 execute 等)。\n * 函数 transport 仍注入 createBuiltinTools(同名内置优先)。\n */\nconst toolMap = computed((): Record<string, ChatTool | ChatToolMeta | undefined> => {\n if (isServerTransport(props.transport)) {\n return Object.fromEntries((props.tools ?? []).map((t) => [t.name, t]))\n }\n const builtins = createBuiltinTools()\n const names = new Set(builtins.map((t) => t.name))\n const tools = [...builtins, ...(props.tools ?? []).filter((t) => !names.has(t.name))]\n return Object.fromEntries(tools.map((t) => [t.name, t]))\n})\n\nconst toolIcons = computed(() => props.toolIcons)\n\nconst {\n messages,\n model,\n reasoningLevel,\n running,\n queue,\n jobs,\n tokenUsage,\n lastTurnUsage,\n lastRequestUsage,\n pendingApprovals,\n pendingQuestion,\n send,\n abort,\n regenerate,\n clear,\n respondToolCall,\n respondSession,\n enqueue,\n startQueued,\n removeQueued\n} = useChat({ props, emit })\n\n/** 空会话(无可见消息):输入区垂直居中;欢迎区钉在滚动容器外、贴于输入框上方 */\nconst isEmpty = computed(() => !messages.value.some((msg) => msg.role !== 'tool'))\n\n/** 有对话内容、队列或生成中才允许清除,避免误触空会话 */\nconst clearable = computed(\n () => messages.value.length > 0 || queue.value.length > 0 || running.value\n)\n\n/** 所有 renderTo: 'panel' 的工具调用(按消息顺序,面板内容的来源) */\nconst panelCalls = computed(() => {\n const calls: ChatToolCall[] = []\n for (const msg of messages.value) {\n if (msg.role !== 'assistant' || !msg.toolCalls) continue\n for (const call of msg.toolCalls) {\n if (toolMap.value[call.name]?.renderTo === 'panel') calls.push(call)\n }\n }\n return calls\n})\n\n/** 当前展示在右侧面板的工具调用 id;null 表示面板关闭 */\nconst activePanelCallId = ref<string | null>(null)\n\nconst activePanelCall = computed(() => {\n if (!activePanelCallId.value) return null\n return panelCalls.value.find((call) => call.id === activePanelCallId.value) ?? null\n})\n\nconst panelOpen = computed(() => activePanelCall.value != null)\n\n// 出现新的面板工具调用时自动打开并聚焦到它\nwatch(\n () => panelCalls.value[panelCalls.value.length - 1]?.id,\n (id) => {\n if (id) activePanelCallId.value = id\n }\n)\n\n// 聚焦的调用消失(清空消息 / 重新生成)时,回落到最近一次面板调用或关闭\nwatch(panelCalls, (calls) => {\n const id = activePanelCallId.value\n if (id && !calls.some((call) => call.id === id)) {\n activePanelCallId.value = calls[calls.length - 1]?.id ?? null\n }\n})\n\n/** 打开面板并聚焦到指定工具调用(工具卡片「查看面板」入口,经 DI 下发) */\nconst openPanel = (toolCallId: string) => {\n if (panelCalls.value.some((call) => call.id === toolCallId)) {\n activePanelCallId.value = toolCallId\n }\n}\n\nconst closePanel = () => {\n activePanelCallId.value = null\n}\n\nprovide(AiChatDIKey, { cls, slots, tools: toolMap, toolIcons, openPanel })\n\n/**\n * 面板宽度:经 ULayout 列轨施加(cols = 1fr + 面板宽度),\n * 拖拽手柄由 ULayout 提供(colMinSizes 约束会话区 / 面板最小宽度)。\n */\nconst PANEL_MIN_WIDTH = 320\nconst MAIN_MIN_WIDTH = 360\n/** 面板打开时默认给会话区留的宽度(即面板默认尽可能大) */\nconst MAIN_DEFAULT_WIDTH = 860\n\nconst layoutRef = useTemplateRef<InstanceType<typeof ULayout>>('layoutRef')\nconst panelRef = useTemplateRef<InstanceType<typeof SidePanel>>('panelRef')\nconst panelWidth = ref(PANEL_MIN_WIDTH)\nconst resizing = ref(false)\n\nconst layoutCols = computed(() => {\n return panelOpen.value ? ['1fr', `${panelWidth.value}px`] : ['1fr']\n})\n\nconst containerWidth = () => (layoutRef.value?.$el as HTMLElement | undefined)?.clientWidth ?? 0\n\n/** 面板默认宽度:容器宽减去会话区默认保留宽度(缺省给会话区留 MAIN_DEFAULT_WIDTH) */\nconst defaultPanelWidth = () => {\n const width = containerWidth()\n return width ? width - MAIN_DEFAULT_WIDTH : 420\n}\n\n/** 钳位面板宽度在 [PANEL_MIN_WIDTH, 容器宽 - MAIN_MIN_WIDTH] 区间 */\nconst clampPanelWidth = (width: number) => {\n const width0 = containerWidth()\n const maxWidth = width0\n ? Math.max(PANEL_MIN_WIDTH, width0 - MAIN_MIN_WIDTH)\n : Number.MAX_SAFE_INTEGER\n return Math.min(Math.max(width, PANEL_MIN_WIDTH), maxWidth)\n}\n\n// 聚焦到指定了 panelWidth 的工具调用时应用其宽度;面板从关闭到打开且工具未指定宽度时\n// 应用默认宽(会话区留 860px);面板已打开时切换聚焦保持当前宽度(含用户拖拽结果)\nwatch(activePanelCall, (call, prevCall) => {\n if (!call) return\n const toolWidth = toolMap.value[call.name]?.panelWidth\n const width = toolWidth ?? (prevCall ? undefined : defaultPanelWidth())\n if (typeof width !== 'number') return\n panelWidth.value = clampPanelWidth(width)\n})\n\n// 跟踪面板实际宽度(含用户拖拽结果):面板关闭重开、切换到未指定宽度的工具时保持\nuseResizeObserver({\n targets: computed(() => (panelRef.value?.$el as HTMLElement | undefined) ?? undefined),\n onResize(entries) {\n // borderBoxSize 随通知携带(含边框、零布局开销),不读 offsetWidth/clientWidth\n const inlineSize = entries[0]?.borderBoxSize?.[0]?.inlineSize\n if (!inlineSize) return\n const width = Math.round(inlineSize)\n // 滞回:忽略亚像素/边框级的微小偏差(< 2px),阻断任何「观测→回写→再观测」的微缩反馈回路\n if (Math.abs(width - panelWidth.value) < 2) return\n panelWidth.value = width\n }\n})\n\nconst chatInputRef = useTemplateRef<InstanceType<typeof ChatInput>>('chatInputRef')\n\n/**\n * 编辑中的队列项原后继 id(编辑态标记):\n * undefined 表示无编辑;null 表示原位置在队尾;否则重新提交时插回该后继之前,\n * 使前后项顺序不受编辑影响(前项已执行则该项自然成为最前待执行项)。\n */\nconst editingSuccessor = ref<string | null | undefined>(undefined)\n\n/** 编辑队列项:取回输入框重新编辑;已有未完成编辑时先按锚点还原,避免内容丢失 */\nconst handleQueueEdit = (id: string) => {\n restoreEditing()\n\n const index = queue.value.findIndex((item) => item.id === id)\n if (index === -1) return\n editingSuccessor.value = queue.value[index + 1]?.id ?? null\n\n const item = removeQueued(id)\n if (item) chatInputRef.value?.setContent(item.content)\n}\n\n/** 将输入框中未提交的编辑内容按原锚点插回队列 */\nconst restoreEditing = () => {\n if (editingSuccessor.value === undefined) return\n const content = chatInputRef.value?.getContent().trim()\n if (content) enqueue(content, undefined, editingSuccessor.value ?? undefined)\n editingSuccessor.value = undefined\n}\n\nconst handleSend = (content: string, attachments: ChatAttachment[]) => {\n if (props.readonly) return\n // 编辑态提交:插回原锚点位置(会话进行中则留在队列,空闲时由 enqueue 自动消耗队首)\n if (editingSuccessor.value !== undefined) {\n enqueue(content, attachments, editingSuccessor.value ?? undefined)\n editingSuccessor.value = undefined\n return\n }\n send(content, attachments)\n}\n\nconst handleQuestionSubmit = (value: AskQuestionAnswer[] | AskQuestionResult) => {\n const pending = pendingQuestion.value\n if (!pending) return\n respondSession(pending.rpcId, true, Array.isArray(value) ? value : value.answers)\n}\n\ndefineExpose<_AiChatExposed>({\n send,\n abort,\n regenerate,\n clear,\n tokenUsage,\n lastTurnUsage,\n queue,\n startQueued,\n removeQueued,\n enqueue\n})\n</script>\n"],"mappings":"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"}
|
|
@@ -2,12 +2,12 @@ import { AiChatDIKey } from "./di.js";
|
|
|
2
2
|
import _sfc_main$1 from "./model-picker.js";
|
|
3
3
|
import { Fragment, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createVNode, defineComponent, inject, mergeModels, nextTick, normalizeClass, openBlock, ref, renderList, shallowRef, toDisplayString, unref, useModel, vModelText, watch, withCtx, withDirectives, withKeys, withModifiers } from "vue";
|
|
4
4
|
import { Attach, Clear, Close, Up } from "@veltra/icons/normal";
|
|
5
|
-
import { UButton, UFilePicker, UIcon, UPopConfirm } from "@veltra/desktop";
|
|
5
|
+
import { UButton, UDropdown, UFilePicker, UIcon, UPopConfirm } from "@veltra/desktop";
|
|
6
6
|
import { bem } from "@veltra/utils";
|
|
7
7
|
//#region src/components/ai-chat/chat-input.vue
|
|
8
8
|
const _hoisted_1 = ["src", "alt"];
|
|
9
9
|
const _hoisted_2 = ["placeholder", "onKeydown"];
|
|
10
|
-
const _hoisted_3 = ["
|
|
10
|
+
const _hoisted_3 = ["stroke-dasharray", "stroke-linecap"];
|
|
11
11
|
const MAX_INPUT_HEIGHT = 160;
|
|
12
12
|
/** 多行自适应高度,上限 160px */
|
|
13
13
|
const _sfc_main = /*@__PURE__*/ defineComponent({
|
|
@@ -21,6 +21,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
|
|
|
21
21
|
maxAttachmentSize: {},
|
|
22
22
|
clearable: { type: Boolean },
|
|
23
23
|
tokenUsage: {},
|
|
24
|
+
lastRequestUsage: {},
|
|
24
25
|
tokenUsageDetail: { type: Boolean }
|
|
25
26
|
}, {
|
|
26
27
|
"model": {},
|
|
@@ -58,16 +59,59 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
|
|
|
58
59
|
if (n < 1e6) return Math.round(n / 1e3 * 10) / 10 >= 1e3 ? scaled(n / 1e6, "M") : scaled(n / 1e3, "K");
|
|
59
60
|
return scaled(n / 1e6, "M");
|
|
60
61
|
};
|
|
61
|
-
/**
|
|
62
|
-
const
|
|
62
|
+
/** 百分比:最多 1 位小数,整数不写 `.0` */
|
|
63
|
+
const formatPercent = (p) => {
|
|
64
|
+
const rounded = Math.round(p * 10) / 10;
|
|
65
|
+
return `${Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)}%`;
|
|
66
|
+
};
|
|
67
|
+
/** 当前模型声明的上下文窗口;未声明则用量环不填充、面板不显示占用 */
|
|
68
|
+
const contextWindow = computed(() => {
|
|
69
|
+
return props.models?.find((m) => m.id === model.value)?.contextWindow;
|
|
70
|
+
});
|
|
71
|
+
/** 上下文占用百分比:最近一次请求总 token / 窗口上限;缺数据时为 null */
|
|
72
|
+
const contextPercent = computed(() => {
|
|
73
|
+
const win = contextWindow.value;
|
|
74
|
+
const total = props.lastRequestUsage?.totalTokens;
|
|
75
|
+
if (!win || total == null) return null;
|
|
76
|
+
return Math.min(100, total / win * 100);
|
|
77
|
+
});
|
|
78
|
+
/** 面板占用行文案:1.5K / 128K · 1.2% */
|
|
79
|
+
const contextText = computed(() => {
|
|
80
|
+
const percent = contextPercent.value;
|
|
81
|
+
const win = contextWindow.value;
|
|
82
|
+
const total = props.lastRequestUsage?.totalTokens;
|
|
83
|
+
if (percent == null || !win || total == null) return "";
|
|
84
|
+
return `${formatTokenCount(total)} / ${formatTokenCount(win)} · ${formatPercent(percent)}`;
|
|
85
|
+
});
|
|
86
|
+
/** 明细面板行:总 / 输入 / 输出;明细开关下再拼有数据的缓存项,缺字段不写 0 */
|
|
87
|
+
const usageRows = computed(() => {
|
|
63
88
|
const usage = props.tokenUsage;
|
|
64
|
-
if (!usage) return
|
|
65
|
-
const
|
|
89
|
+
if (!usage) return [];
|
|
90
|
+
const rows = [
|
|
91
|
+
{
|
|
92
|
+
label: "总 token",
|
|
93
|
+
value: formatTokenCount(usage.totalTokens)
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
label: "输入",
|
|
97
|
+
value: formatTokenCount(usage.promptTokens)
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
label: "输出",
|
|
101
|
+
value: formatTokenCount(usage.completionTokens)
|
|
102
|
+
}
|
|
103
|
+
];
|
|
66
104
|
if (props.tokenUsageDetail) {
|
|
67
|
-
if (usage.cacheHitTokens != null)
|
|
68
|
-
|
|
105
|
+
if (usage.cacheHitTokens != null) rows.push({
|
|
106
|
+
label: "缓存命中",
|
|
107
|
+
value: formatTokenCount(usage.cacheHitTokens)
|
|
108
|
+
});
|
|
109
|
+
if (usage.cacheMissTokens != null) rows.push({
|
|
110
|
+
label: "缓存未命中",
|
|
111
|
+
value: formatTokenCount(usage.cacheMissTokens)
|
|
112
|
+
});
|
|
69
113
|
}
|
|
70
|
-
return
|
|
114
|
+
return rows;
|
|
71
115
|
});
|
|
72
116
|
/** 生成中提示用户消息将进入队列 */
|
|
73
117
|
const placeholderText = computed(() => {
|
|
@@ -157,79 +201,113 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
|
|
|
157
201
|
onInput: autoResize
|
|
158
202
|
}, null, 42, _hoisted_2), [[vModelText, text.value]]),
|
|
159
203
|
createElementVNode("div", { class: normalizeClass(unref(cls).e("input-toolbar")) }, [
|
|
160
|
-
createElementVNode("div", { class: normalizeClass(unref(cls).e("input-toolbar-left")) }, [
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
204
|
+
createElementVNode("div", { class: normalizeClass(unref(cls).e("input-toolbar-left")) }, [createVNode(unref(UFilePicker), {
|
|
205
|
+
multiple: "",
|
|
206
|
+
accept: __props.accept ?? "image/*",
|
|
207
|
+
onPick: handlePick
|
|
208
|
+
}, {
|
|
209
|
+
default: withCtx(() => [createVNode(unref(UIcon), {
|
|
210
|
+
class: normalizeClass(unref(cls).e("input-attach")),
|
|
211
|
+
title: "添加图片"
|
|
165
212
|
}, {
|
|
166
|
-
default: withCtx(() => [createVNode(unref(
|
|
167
|
-
class: normalizeClass(unref(cls).e("input-attach")),
|
|
168
|
-
title: "添加图片"
|
|
169
|
-
}, {
|
|
170
|
-
default: withCtx(() => [createVNode(unref(Attach))]),
|
|
171
|
-
_: 1
|
|
172
|
-
}, 8, ["class"])]),
|
|
213
|
+
default: withCtx(() => [createVNode(unref(Attach))]),
|
|
173
214
|
_: 1
|
|
174
|
-
}, 8, ["
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
215
|
+
}, 8, ["class"])]),
|
|
216
|
+
_: 1
|
|
217
|
+
}, 8, ["accept"]), createElementVNode("span", { class: normalizeClass([unref(cls).e("input-clear-wrap"), unref(bem).is("disabled", !__props.clearable)]) }, [createVNode(unref(UPopConfirm), {
|
|
218
|
+
title: "清空当前对话?进行中的生成将被中止。",
|
|
219
|
+
"confirm-text": "清空",
|
|
220
|
+
direction: "top",
|
|
221
|
+
alignment: "start",
|
|
222
|
+
onConfirm: _cache[1] || (_cache[1] = ($event) => emit("clear"))
|
|
223
|
+
}, {
|
|
224
|
+
reference: withCtx(() => [createVNode(unref(UIcon), {
|
|
225
|
+
class: normalizeClass(unref(cls).e("input-clear")),
|
|
226
|
+
title: "清除会话"
|
|
181
227
|
}, {
|
|
182
|
-
|
|
183
|
-
class: normalizeClass(unref(cls).e("input-clear")),
|
|
184
|
-
title: "清除会话"
|
|
185
|
-
}, {
|
|
186
|
-
default: withCtx(() => [createVNode(unref(Clear))]),
|
|
187
|
-
_: 1
|
|
188
|
-
}, 8, ["class"])]),
|
|
228
|
+
default: withCtx(() => [createVNode(unref(Clear))]),
|
|
189
229
|
_: 1
|
|
190
|
-
}
|
|
191
|
-
__props.tokenUsage ? (openBlock(), createElementBlock("span", {
|
|
192
|
-
key: 0,
|
|
193
|
-
class: normalizeClass(unref(cls).e("input-usage")),
|
|
194
|
-
title: usageText.value
|
|
195
|
-
}, toDisplayString(usageText.value), 11, _hoisted_3)) : createCommentVNode("v-if", true)
|
|
196
|
-
], 2),
|
|
197
|
-
createCommentVNode(" 右簇:模型/推理选择 → 发送或停止(互斥) "),
|
|
198
|
-
createElementVNode("div", { class: normalizeClass(unref(cls).e("input-toolbar-right")) }, [__props.models?.length ? (openBlock(), createBlock(_sfc_main$1, {
|
|
199
|
-
key: 0,
|
|
200
|
-
model: model.value,
|
|
201
|
-
"onUpdate:model": _cache[2] || (_cache[2] = ($event) => model.value = $event),
|
|
202
|
-
"reasoning-level": reasoningLevel.value,
|
|
203
|
-
"onUpdate:reasoningLevel": _cache[3] || (_cache[3] = ($event) => reasoningLevel.value = $event),
|
|
204
|
-
models: __props.models
|
|
205
|
-
}, null, 8, [
|
|
206
|
-
"model",
|
|
207
|
-
"reasoning-level",
|
|
208
|
-
"models"
|
|
209
|
-
])) : createCommentVNode("v-if", true), showStop.value ? (openBlock(), createBlock(unref(UButton), {
|
|
210
|
-
key: "stop",
|
|
211
|
-
size: "small",
|
|
212
|
-
type: "danger",
|
|
213
|
-
circle: "",
|
|
214
|
-
title: "停止生成",
|
|
215
|
-
onClick: _cache[4] || (_cache[4] = ($event) => emit("abort"))
|
|
216
|
-
}, {
|
|
217
|
-
default: withCtx(() => [createElementVNode("span", { class: normalizeClass(unref(cls).e("input-stop")) }, null, 2)]),
|
|
230
|
+
}, 8, ["class"])]),
|
|
218
231
|
_: 1
|
|
219
|
-
})
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
232
|
+
})], 2)], 2),
|
|
233
|
+
createCommentVNode(" 右簇:token 用量环 → 模型/推理选择 → 发送或停止(互斥) "),
|
|
234
|
+
createElementVNode("div", { class: normalizeClass(unref(cls).e("input-toolbar-right")) }, [
|
|
235
|
+
__props.tokenUsage ? (openBlock(), createBlock(unref(UDropdown), {
|
|
236
|
+
key: 0,
|
|
237
|
+
width: "auto",
|
|
238
|
+
"min-width": "200px",
|
|
239
|
+
"content-class": unref(cls).e("usage-panel")
|
|
240
|
+
}, {
|
|
241
|
+
trigger: withCtx(() => [createElementVNode("button", {
|
|
242
|
+
type: "button",
|
|
243
|
+
class: normalizeClass(unref(cls).e("usage-trigger")),
|
|
244
|
+
"aria-label": "Token 用量"
|
|
245
|
+
}, [(openBlock(), createElementBlock("svg", {
|
|
246
|
+
class: normalizeClass(unref(cls).e("usage-ring")),
|
|
247
|
+
viewBox: "0 0 36 36"
|
|
248
|
+
}, [createElementVNode("circle", {
|
|
249
|
+
class: normalizeClass(unref(cls).e("usage-ring-track")),
|
|
250
|
+
cx: "18",
|
|
251
|
+
cy: "18",
|
|
252
|
+
r: "15.5"
|
|
253
|
+
}, null, 2), createElementVNode("circle", {
|
|
254
|
+
class: normalizeClass(unref(cls).e("usage-ring-value")),
|
|
255
|
+
cx: "18",
|
|
256
|
+
cy: "18",
|
|
257
|
+
r: "15.5",
|
|
258
|
+
pathLength: "100",
|
|
259
|
+
transform: "rotate(-90 18 18)",
|
|
260
|
+
"stroke-dasharray": `${contextPercent.value ?? 0} 100`,
|
|
261
|
+
"stroke-linecap": contextPercent.value ? "round" : void 0
|
|
262
|
+
}, null, 10, _hoisted_3)], 2))], 2)]),
|
|
263
|
+
content: withCtx(() => [createElementVNode("div", { class: normalizeClass(unref(cls).e("usage-list")) }, [contextText.value ? (openBlock(), createElementBlock("div", {
|
|
264
|
+
key: 0,
|
|
265
|
+
class: normalizeClass(unref(cls).e("usage-row"))
|
|
266
|
+
}, [createElementVNode("span", { class: normalizeClass(unref(cls).e("usage-label")) }, "上下文占用", 2), createElementVNode("span", { class: normalizeClass(unref(cls).e("usage-value")) }, toDisplayString(contextText.value), 3)], 2)) : createCommentVNode("v-if", true), (openBlock(true), createElementBlock(Fragment, null, renderList(usageRows.value, (row) => {
|
|
267
|
+
return openBlock(), createElementBlock("div", {
|
|
268
|
+
key: row.label,
|
|
269
|
+
class: normalizeClass(unref(cls).e("usage-row"))
|
|
270
|
+
}, [createElementVNode("span", { class: normalizeClass(unref(cls).e("usage-label")) }, toDisplayString(row.label), 3), createElementVNode("span", { class: normalizeClass(unref(cls).e("usage-value")) }, toDisplayString(row.value), 3)], 2);
|
|
271
|
+
}), 128))], 2)]),
|
|
272
|
+
_: 1
|
|
273
|
+
}, 8, ["content-class"])) : createCommentVNode("v-if", true),
|
|
274
|
+
__props.models?.length ? (openBlock(), createBlock(_sfc_main$1, {
|
|
275
|
+
key: 1,
|
|
276
|
+
model: model.value,
|
|
277
|
+
"onUpdate:model": _cache[2] || (_cache[2] = ($event) => model.value = $event),
|
|
278
|
+
"reasoning-level": reasoningLevel.value,
|
|
279
|
+
"onUpdate:reasoningLevel": _cache[3] || (_cache[3] = ($event) => reasoningLevel.value = $event),
|
|
280
|
+
models: __props.models
|
|
281
|
+
}, null, 8, [
|
|
282
|
+
"model",
|
|
283
|
+
"reasoning-level",
|
|
284
|
+
"models"
|
|
285
|
+
])) : createCommentVNode("v-if", true),
|
|
286
|
+
showStop.value ? (openBlock(), createBlock(unref(UButton), {
|
|
287
|
+
key: "stop",
|
|
288
|
+
size: "small",
|
|
289
|
+
type: "danger",
|
|
290
|
+
circle: "",
|
|
291
|
+
title: "停止生成",
|
|
292
|
+
onClick: _cache[4] || (_cache[4] = ($event) => emit("abort"))
|
|
293
|
+
}, {
|
|
294
|
+
default: withCtx(() => [createElementVNode("span", { class: normalizeClass(unref(cls).e("input-stop")) }, null, 2)]),
|
|
295
|
+
_: 1
|
|
296
|
+
})) : (openBlock(), createBlock(unref(UButton), {
|
|
297
|
+
key: "send",
|
|
298
|
+
size: "small",
|
|
299
|
+
type: "primary",
|
|
300
|
+
circle: "",
|
|
301
|
+
disabled: !hasContent.value,
|
|
302
|
+
icon: unref(Up),
|
|
303
|
+
title: __props.running ? "加入待发送队列" : void 0,
|
|
304
|
+
onClick: handleSend
|
|
305
|
+
}, null, 8, [
|
|
306
|
+
"disabled",
|
|
307
|
+
"icon",
|
|
308
|
+
"title"
|
|
309
|
+
]))
|
|
310
|
+
], 2)
|
|
233
311
|
], 2)
|
|
234
312
|
], 2);
|
|
235
313
|
};
|