实现中间对话的修改

This commit is contained in:
锦恢 2025-04-26 17:03:56 +08:00
parent cf3a3a57ce
commit beaf4f5ba1
11 changed files with 252 additions and 42 deletions

View File

@ -1,8 +1,8 @@
@font-face {
font-family: "iconfont"; /* Project id 4870215 */
src: url('iconfont.woff2?t=1745313248329') format('woff2'),
url('iconfont.woff?t=1745313248329') format('woff'),
url('iconfont.ttf?t=1745313248329') format('truetype');
src: url('iconfont.woff2?t=1745654620708') format('woff2'),
url('iconfont.woff?t=1745654620708') format('woff'),
url('iconfont.ttf?t=1745654620708') format('truetype');
}
.iconfont {
@ -13,6 +13,18 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-copy:before {
content: "\e77c";
}
.icon-restart:before {
content: "\e86b";
}
.icon-edit2:before {
content: "\e848";
}
.icon-star:before {
content: "\e80f";
}

Binary file not shown.

View File

@ -0,0 +1,70 @@
<template>
<el-input
type="textarea"
v-model="model"
:rows="inputHeightLines"
:maxlength="2000"
:placeholder="placeholder"
:resize="resize"
:class="customClass"
@keydown.enter="handleKeydown"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
/>
</template>
<script setup lang="ts">
import { ref, computed, defineProps, defineEmits } from 'vue';
const props = defineProps({
modelValue: {
type: String,
required: true
},
placeholder: {
type: String,
default: '输入消息...'
},
resize: {
type: String,
default: 'none'
},
customClass: {
type: String,
default: ''
}
});
const emit = defineEmits(['update:modelValue', 'pressEnter']);
const model = computed({
get() {
return props.modelValue;
},
set(value) {
emit('update:modelValue', value);
}
});
const inputHeightLines = computed(() => {
const currentLines = props.modelValue.split('\n').length;
return Math.min(12, Math.max(5, currentLines));
});
const isComposing = ref(false);
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter' && !event.shiftKey && !isComposing.value) {
event.preventDefault();
emit('pressEnter', event);
}
};
const handleCompositionStart = () => {
isComposing.value = true;
};
const handleCompositionEnd = () => {
isComposing.value = false;
};
</script>

View File

@ -103,4 +103,5 @@ export function getToolSchema(enableTools: EnableToolItem[]) {
}
}
return toolsSchema;
}
}

View File

@ -14,23 +14,23 @@
<!-- 用户输入的部分 -->
<div class="message-content" v-if="message.role === 'user'">
<Message.User :message="message" />
<Message.User :message="message" :tab-id="props.tabId" />
</div>
<!-- 助手返回的内容部分 -->
<div class="message-content" v-else-if="message.role === 'assistant/content'">
<Message.Assistant :message="message" />
<Message.Assistant :message="message" :tab-id="props.tabId" />
</div>
<!-- 助手调用的工具部分 -->
<div class="message-content" v-else-if="message.role === 'assistant/tool_calls'">
<Message.Toolcall :message="message" />
<Message.Toolcall :message="message" :tab-id="props.tabId" />
</div>
</div>
<!-- 正在加载的部分实时解析 markdown -->
<div v-if="isLoading" class="message-item assistant">
<Message.StreamingBox :streaming-content="streamingContent" />
<Message.StreamingBox :streaming-content="streamingContent" :tab-id="props.tabId" />
</div>
</div>
</el-scrollbar>
@ -52,8 +52,12 @@
<div class="input-wrapper">
<Setting :tabId="tabId" />
<el-input v-model="userInput" type="textarea" :rows="inputHeightLines" :maxlength="2000"
placeholder="输入消息..." @keydown.enter="handleKeydown" resize="none" class="chat-input" />
<KCuteTextarea
v-model="userInput"
placeholder="输入消息..."
:customClass="'chat-input'"
@press-enter="handleSend()"
/>
<el-button type="primary" @click="isLoading ? handleAbort() : handleSend()" class="send-button">
<span v-if="!isLoading" class="iconfont icon-send"></span>
@ -77,6 +81,9 @@ import { llmManager, llms } from '@/views/setting/llm';
import * as Message from './message';
import Setting from './setting.vue';
import KCuteTextarea from '@/components/k-cute-textarea/index.vue';
import { provide } from 'vue';
defineComponent({ name: 'chat' });
@ -93,10 +100,6 @@ const tab = tabs.content[props.tabId];
const tabStorage = tab.storage as ChatStorage;
const userInput = ref('');
const inputHeightLines = computed(() => {
const currentLines = userInput.value.split('\n').length;
return Math.min(12, Math.max(5, currentLines));
});
// messages
if (!tabStorage.messages) {
@ -165,14 +168,6 @@ const updateScrollHeight = () => {
}
};
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSend();
}
// Shift+Enter
};
const autoScroll = ref(true);
const scrollbarRef = ref<ScrollbarInstance>();
@ -218,14 +213,13 @@ watch(streamingToolCalls, () => {
let loop: TaskLoop | undefined = undefined;
const handleSend = () => {
if (!userInput.value.trim() || isLoading.value) return;
const handleSend = (newMessage?: string) => {
const userMessage = newMessage || userInput.value.trim();
if (!userMessage || isLoading.value) return;
autoScroll.value = true;
isLoading.value = true;
const userMessage = userInput.value.trim();
loop = new TaskLoop(streamingContent, streamingToolCalls);
loop.registerOnError((error) => {
@ -278,6 +272,8 @@ const handleAbort = () => {
}
};
provide('handleSend', handleSend);
onMounted(() => {
updateScrollHeight();
window.addEventListener('resize', updateScrollHeight);
@ -357,9 +353,10 @@ onUnmounted(() => {
.user .message-text {
margin-top: 10px;
margin-bottom: 10px;
width: 100%;
}
.user .message-text>span {
.user .message-text > span {
border-radius: .9em;
background-color: var(--main-light-color);
padding: 10px 15px;
@ -419,7 +416,7 @@ onUnmounted(() => {
height: auto;
padding: 8px 12px;
font-size: 20px;
border-radius: .5em;
border-radius: 1.2em !important;
}
:deep(.chat-settings) {

View File

@ -16,6 +16,10 @@ const props = defineProps({
message: {
type: Object,
required: true
},
tabId: {
type: Number,
required: true
}
});

View File

@ -25,6 +25,10 @@ const props = defineProps({
streamingContent: {
type: String,
required: true
},
tabId: {
type: Number,
required: true
}
});

View File

@ -104,6 +104,10 @@ const props = defineProps({
message: {
type: Object as PropType<IRenderMessage>,
required: true
},
tabId: {
type: Number,
required: true
}
});

View File

@ -1,22 +1,140 @@
<template>
<div class="message-role"></div>
<div class="message-text">
<span>{{ props.message.content }}</span>
<div v-if="!isEditing" class="message-content">
<span>
{{ props.message.content.trim() }}
</span>
</div>
<KCuteTextarea v-else
v-model="userInput"
placeholder="输入消息..."
@press-enter="handleKeydown"
/>
<div class="message-actions" v-if="!isEditing">
<el-button @click="copy">
<span class="iconfont icon-copy"></span>
</el-button>
<el-button @click="toggleEdit">
<span class="iconfont icon-edit2"></span>
</el-button>
</div>
<div class="message-actions" v-else>
<el-button @click="toggleEdit">
{{ '取消' }}
</el-button>
<el-button @click="handleKeydown" type="primary">
{{ '发送' }}
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { defineProps, ref, PropType, inject } from 'vue';
import { tabs } from '../../panel';
import { ChatStorage, IRenderMessage } from '../chat';
import { defineProps } from 'vue';
import KCuteTextarea from '@/components/k-cute-textarea/index.vue';
import { ElMessage } from 'element-plus';
import { markdownToHtml } from '../markdown';
const props = defineProps({
message: {
type: Object,
type: Object as PropType<IRenderMessage>,
required: true
},
tabId: {
type: Number,
required: true
}
});
const tab = tabs.content[props.tabId];
const tabStorage = tab.storage as ChatStorage;
const isEditing = ref(false);
const userInput = ref('');
const handleSend = inject<(newMessage: string | undefined) => void>('handleSend');
const toggleEdit = () => {
isEditing.value = !isEditing.value;
if (isEditing.value) {
userInput.value = props.message.content;
}
};
const handleKeydown = (event: KeyboardEvent) => {
const index = tabStorage.messages.findIndex(msg => msg.extraInfo === props.message.extraInfo);
if (index !== -1 && handleSend) {
// index index
tabStorage.messages.splice(index);
handleSend(userInput.value);
isEditing.value = false;
}
};
const copy = async () => {
try {
await navigator.clipboard.writeText(userInput.value);
ElMessage.success('内容已复制到剪贴板');
} catch (err) {
console.error('无法复制内容: ', err);
}
};
</script>
<style>
.message-text {
position: relative;
}
.message-text:hover .message-actions {
opacity: 1;
}
.message-actions {
opacity: 0;
transition: var(--animation-3s);
position: absolute;
bottom: -34px;
right: 0;
}
.message-actions .el-button {
border-radius: .5em;
padding: 5px 8px;
}
.message-actions .el-button:hover {
background-color: var(--main-light-color);
}
.message-actions .el-button+.el-button {
margin-left: 10px;
}
.user .message-content {
display: flex;
align-items: center;
justify-content: flex-end;
width: 100%;
}
.user .message-content > span {
max-width: calc(100% - 48px);
border-radius: .9em;
background-color: var(--main-light-color);
padding: 10px 15px;
box-sizing: border-box;
white-space: pre-wrap;
word-break: break-word;
text-align: left;
}
</style>

View File

@ -83,7 +83,7 @@ export function messageController(command: string, data: any, webview: PostMessa
chatCompletionService(client, data, webview);
break;
case 'llm/chat/completions/cancel':
case 'llm/chat/completions/abort':
abortMessageService(client, data, webview);
break;

View File

@ -104,15 +104,15 @@ export function abortMessageService(client: MCPClient | undefined, data: any, we
if (currentStream) {
// 标记流已中止
currentStream = null;
// 发送中止消息给前端
webview.postMessage({
command: 'llm/chat/completions/abort',
data: {
code: 200,
msg: {
success: true
}
}
});
}
webview.postMessage({
command: 'llm/chat/completions/abort',
data: {
code: 200,
msg: {
success: true
}
}
});
}