支持富文本编辑器,支持在客户端对提词和资源进行选择

This commit is contained in:
锦恢 2025-05-07 21:56:07 +08:00
parent cefa6b2af5
commit 96d36c0706
42 changed files with 1106 additions and 538 deletions

View File

@ -1,9 +1,10 @@
# Change Log # Change Log
## [main] 0.0.7 ## [main] 0.0.7
- 优化页面布局,使得调试内容更加紧凑 - 优化页面布局,使得调试窗口可以显示更多内容
- 扩大默认的上下文长度 - 扩大默认的上下文长度 10 -> 20
- 增加「通用选项」用于设置mcp服务器的最大的等待时间 - 增加「通用选项」 -> 「MCP工具最长调用时间 (sec)」
- 支持富文本输入框,现在可以将 prompt 和 resource 嵌入到输入框中 进行 大规模 prompt engineering 调试工作了
## [main] 0.0.6 ## [main] 0.0.6
- 修复部分因为服务器名称特殊字符而导致的保存实效的错误 - 修复部分因为服务器名称特殊字符而导致的保存实效的错误

View File

@ -1,126 +0,0 @@
<template>
<div class="k-rich-textarea">
<div
ref="editor"
contenteditable="true"
class="rich-editor"
:placeholder="placeholder"
@input="handleInput"
@keydown.enter="handleKeydown"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
></div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, defineProps, defineEmits, watch } from 'vue';
import type { RichTextItem } from './textarea.dto';
const props = defineProps({
modelValue: {
type: Array as () => RichTextItem[],
required: true
},
placeholder: {
type: String,
default: '输入消息...'
},
customClass: {
type: String,
default: ''
}
});
const emit = defineEmits(['update:modelValue', 'pressEnter']);
const editor = ref<HTMLElement | null>(null);
const renderRichText = (items: RichTextItem[]) => {
return items.map(item => {
if (item.type === 'prompt' || item.type === 'resource') {
return `<span class="rich-item rich-item-${item.type}">${item.text}</span>`;
}
return item.text;
}).join('');
};
const handleInput = (event: Event) => {
if (editor.value) {
const items: RichTextItem[] = [];
const nodes = editor.value.childNodes;
nodes.forEach(node => {
if (node.nodeType === Node.TEXT_NODE) {
items.push({ type: 'text', text: node.textContent || '' });
} else if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as HTMLElement;
if (element.classList.contains('rich-item-prompt')) {
items.push({ type: 'prompt', text: element.textContent || '' });
} else if (element.classList.contains('rich-item-resource')) {
items.push({ type: 'resource', text: element.textContent || '' });
} else {
items.push({ type: 'text', text: element.textContent || '' });
}
}
});
emit('update:modelValue', items);
}
};
watch(() => props.modelValue, (newValue) => {
if (editor.value) {
editor.value.innerHTML = renderRichText(newValue);
}
}, { immediate: true });
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>
<style>
.k-rich-textarea {
border-radius: .9em;
border: 1px solid #DCDFE6;
padding: 5px;
}
.rich-editor {
min-height: 100px;
outline: none;
}
.rich-editor:empty::before {
content: attr(placeholder);
color: #C0C4CC;
}
.rich-item {
padding: 2px 4px;
border-radius: 4px;
margin: 0 2px;
}
.rich-item-prompt {
background-color: #e8f0fe;
color: #1a73e8;
}
.rich-item-resource {
background-color: #f1f3f4;
color: #202124;
}
</style>

View File

@ -1,17 +0,0 @@
interface PromptTextItem {
type: 'prompt'
text: string
}
interface ResourceTextItem {
type: 'resource'
text: string
}
interface TextItem {
type: 'text'
text: string
}
export type RichTextItem = PromptTextItem | ResourceTextItem | TextItem;

View File

@ -76,6 +76,23 @@ export interface ToolCall {
} }
} }
interface PromptTextItem {
type: 'prompt'
text: string
}
interface ResourceTextItem {
type: 'resource'
text: string
}
interface TextItem {
type: 'text'
text: string
}
export type RichTextItem = PromptTextItem | ResourceTextItem | TextItem;
export const allTools = ref<ToolItem[]>([]); export const allTools = ref<ToolItem[]>([]);
export interface IRenderMessage { export interface IRenderMessage {
@ -105,3 +122,8 @@ export function getToolSchema(enableTools: EnableToolItem[]) {
} }
return toolsSchema; return toolsSchema;
} }
export interface EditorContext {
editor: Ref<HTMLDivElement>;
[key: string]: any;
}

View File

@ -0,0 +1,204 @@
<template>
<footer class="chat-footer">
<div class="input-area">
<div class="input-wrapper">
<KRichTextarea
:tabId="tabId"
v-model="userInput"
:placeholder="t('enter-message-dot')"
: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>
<span v-else class="iconfont icon-stop"></span>
</el-button>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
import { provide, onMounted, onUnmounted, ref, defineEmits, defineProps, PropType, inject, Ref } from 'vue';
import { useI18n } from 'vue-i18n';
import KRichTextarea from './rich-textarea.vue';
import { tabs } from '../../panel';
import { ChatMessage, ChatStorage, MessageState, ToolCall, RichTextItem } from './chat';
import { TaskLoop } from '../core/task-loop';
import { llmManager, llms } from '@/views/setting/llm';
import { ElMessage } from 'element-plus';
const { t } = useI18n();
const props = defineProps({
tabId: {
type: Number,
required: true
}
});
const emits = defineEmits(['update:scrollToBottom']);
const tab = tabs.content[props.tabId];
const tabStorage = tab.storage as ChatStorage;
// messages
if (!tabStorage.messages) {
tabStorage.messages = [] as ChatMessage[];
}
const userInput = ref<string>('');
let loop: TaskLoop | undefined = undefined;
const isLoading = inject('isLoading') as Ref<boolean>;
const autoScroll = inject('autoScroll') as Ref<boolean>;
const streamingContent = inject('streamingContent') as Ref<string>;
const streamingToolCalls = inject('streamingToolCalls') as Ref<ToolCall[]>;
const scrollToBottom = inject('scrollToBottom') as () => Promise<void>;
const updateScrollHeight = inject('updateScrollHeight') as () => void;
function handleSend(newMessage?: string) {
//
const userMessage = newMessage || userInput.value;
if (!userMessage || isLoading.value) {
return;
}
isLoading.value = true;
autoScroll.value = true;
loop = new TaskLoop(streamingContent, streamingToolCalls);
loop.registerOnError((error) => {
ElMessage({
message: error.msg,
type: 'error',
duration: 3000
});
if (error.state === MessageState.ReceiveChunkError) {
tabStorage.messages.push({
role: 'assistant',
content: error.msg,
extraInfo: {
created: Date.now(),
state: error.state,
serverName: llms[llmManager.currentModelIndex].id || 'unknown'
}
});
}
isLoading.value = false;
});
loop.registerOnChunk(() => {
scrollToBottom();
});
loop.registerOnDone(() => {
isLoading.value = false;
scrollToBottom();
});
loop.registerOnEpoch(() => {
isLoading.value = true;
scrollToBottom();
});
loop.start(tabStorage, userMessage);
userInput.value = '';
}
function handleAbort() {
if (loop) {
loop.abort();
isLoading.value = false;
ElMessage.info('请求已中止');
}
}
provide('handleSend', handleSend);
onMounted(() => {
updateScrollHeight();
window.addEventListener('resize', updateScrollHeight);
scrollToBottom();
});
onUnmounted(() => {
window.removeEventListener('resize', updateScrollHeight);
});
</script>
<style>
.chat-footer {
padding: 16px;
border-top: 1px solid var(--el-border-color);
flex-shrink: 0;
position: absolute;
height: fit-content !important;
bottom: 0;
width: 100%;
}
.input-area {
max-width: 800px;
margin: 0 auto;
position: relative;
}
.input-wrapper {
position: relative;
}
.chat-input {
padding-right: 80px;
}
.chat-input textarea {
border-radius: .5em;
}
.send-button {
position: absolute !important;
right: 8px !important;
bottom: 8px !important;
height: auto;
padding: 8px 12px;
font-size: 20px;
border-radius: 1.2em !important;
}
:deep(.chat-settings) {
position: absolute;
left: 0;
bottom: 0px;
z-index: 1;
}
.typing-cursor {
animation: blink 1s infinite;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
</style>

View File

@ -0,0 +1,114 @@
<template>
<el-tooltip :content="t('prompts')" placement="top">
<div class="setting-button" @click="showChoosePrompt = true; saveCursorPosition();">
<span class="iconfont icon-chat"></span>
</div>
</el-tooltip>
<!-- 上下文长度设置 - 改为滑块形式 -->
<el-dialog v-model="showChoosePrompt" :title="t('prompts')" width="400px">
<div class="prompt-template-container-scrollbar" v-if="!selectPrompt">
<PromptTemplates :tab-id="-1" @prompt-selected="prompt => selectPrompt = prompt" />
</div>
<div v-else>
<PromptReader :tab-id="-1" :current-prompt-name="selectPrompt!.name"
@prompt-get-response="msg => whenGetPromptResponse(msg)" />
</div>
<template #footer>
<el-button v-if="selectPrompt" @click="selectPrompt = undefined;">{{ t('return') }}</el-button>
<el-button @click="showChoosePrompt = false; selectPrompt = undefined;">{{ t("cancel") }}</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { createApp, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { ChatStorage, EditorContext } from '../chat';
import { PromptsGetResponse, PromptTemplate } from '@/hook/type';
import PromptTemplates from '@/components/main-panel/prompt/prompt-templates.vue';
import PromptReader from '@/components/main-panel/prompt/prompt-reader.vue';
import { ElMessage, ElTooltip } from 'element-plus';
import PromptChatItem from '../prompt-chat-item.vue';
const { t } = useI18n();
const tabStorage = inject('tabStorage') as ChatStorage;
let selectPrompt = ref<PromptTemplate | undefined>(undefined);
const showChoosePrompt = ref(false);
const editorContext = inject('editorContext') as EditorContext;
let savedSelection: Range | null = null;
function saveCursorPosition() {
const editor = editorContext.editor.value;
if (editor) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
// selection editor
if (editor.contains(range.startContainer) && editor.contains(range.endContainer)) {
savedSelection = range;
} else {
savedSelection = null;
}
}
}
}
async function whenGetPromptResponse(msg: PromptsGetResponse) {
try {
const content = msg.messages[0].content;
selectPrompt.value = undefined;
const editor = editorContext.editor.value;
if (!content || !editor) {
return;
}
const container = document.createElement('div');
const promptChatItem = createApp(PromptChatItem, {
messages: msg.messages
});
promptChatItem.use(ElTooltip);
promptChatItem.mount(container);
const firstElement = container.firstElementChild!;
if (savedSelection) {
savedSelection.deleteContents();
savedSelection.insertNode(firstElement);
} else {
editor.appendChild(firstElement);
}
//
const newRange = document.createRange();
newRange.setStartAfter(firstElement);
newRange.collapse(true);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(newRange);
editor.dispatchEvent(new Event('input'));
editor.focus();
showChoosePrompt.value = false;
} catch (error) {
ElMessage.error((error as Error).message);
}
}
</script>
<style>
.icon-length {
font-size: 16px;
}
</style>

View File

@ -0,0 +1,116 @@
<template>
<el-tooltip :content="t('resources')" placement="top">
<div class="setting-button" @click="showChooseResource = true; saveCursorPosition();">
<span class="iconfont icon-file"></span>
</div>
</el-tooltip>
<el-dialog v-model="showChooseResource" :title="t('resources')" width="400px">
<div class="resource-template-container-scrollbar" v-if="!selectResource">
<ResourceList :tab-id="-1" @resource-selected="resource => selectResource = resource" />
</div>
<div v-else>
<ResourceReader :tab-id="-1" :current-resource-name="selectResource!.name"
@resource-get-response="msg => whenGetResourceResponse(msg)" />
</div>
<template #footer>
<el-button v-if="selectResource" @click="selectResource = undefined;">{{ t('return') }}</el-button>
<el-button @click="showChooseResource = false; selectResource = undefined;">{{ t("cancel") }}</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { createApp, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { ChatStorage, EditorContext } from '../chat';
import { ResourcesReadResponse, ResourceTemplate } from '@/hook/type';
import ResourceList from '@/components/main-panel/resource/resource-list.vue';
import ResourceReader from '@/components/main-panel/resource/resouce-reader.vue';
import { ElMessage, ElTooltip, ElProgress, ElPopover } from 'element-plus';
import ResourceChatItem from '../resource-chat-item.vue';
const { t } = useI18n();
const tabStorage = inject('tabStorage') as ChatStorage;
let selectResource = ref<ResourceTemplate | undefined>(undefined);
const showChooseResource = ref(false);
const editorContext = inject('editorContext') as EditorContext;
let savedSelection: Range | null = null;
function saveCursorPosition() {
const editor = editorContext.editor.value;
if (editor) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
if (editor.contains(range.startContainer) && editor.contains(range.endContainer)) {
savedSelection = range;
} else {
savedSelection = null;
}
}
}
}
async function whenGetResourceResponse(msg: ResourcesReadResponse) {
if (!msg) {
return;
}
try {
console.log(msg);
selectResource.value = undefined;
const editor = editorContext.editor.value;
if (msg.contents.length === 0 || !editor) {
return;
}
const container = document.createElement('div');
const resourceChatItem = createApp(ResourceChatItem, {
contents: msg.contents
});
resourceChatItem
.use(ElTooltip)
.use(ElProgress)
.use(ElPopover)
resourceChatItem.mount(container);
const firstElement = container.firstElementChild!;
if (savedSelection) {
savedSelection.deleteContents();
savedSelection.insertNode(firstElement);
} else {
editor.appendChild(firstElement);
}
const newRange = document.createRange();
newRange.setStartAfter(firstElement);
newRange.collapse(true);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(newRange);
editor.dispatchEvent(new Event('input'));
editor.focus();
showChooseResource.value = false;
} catch (error) {
ElMessage.error((error as Error).message);
}
}
</script>
<style>
.icon-length {
font-size: 16px;
}
</style>

View File

@ -3,7 +3,8 @@
<Model /> <Model />
<SystemPrompt /> <SystemPrompt />
<ToolUse /> <ToolUse />
<Prompt v-model="modelValue" /> <Prompt />
<Resource />
<Websearch /> <Websearch />
<Temperature /> <Temperature />
<ContextLength /> <ContextLength />
@ -11,15 +12,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defineProps, defineEmits, provide, ref, computed } from 'vue'; import { defineProps, defineEmits, provide, PropType, computed } from 'vue';
import { llmManager } from '@/views/setting/llm'; import { llmManager } from '@/views/setting/llm';
import { tabs } from '../../panel'; import { tabs } from '@/components/main-panel/panel';
import type { ChatSetting, ChatStorage } from '../chat'; import type { ChatSetting, ChatStorage } from '../chat';
import Model from './model.vue'; import Model from './model.vue';
import SystemPrompt from './system-prompt.vue'; import SystemPrompt from './system-prompt.vue';
import ToolUse from './tool-use.vue'; import ToolUse from './tool-use.vue';
import Prompt from './prompt.vue'; import Prompt from './prompt.vue';
import Resource from './resource.vue';
import Websearch from './websearch.vue'; import Websearch from './websearch.vue';
import Temperature from './temperature.vue'; import Temperature from './temperature.vue';
import ContextLength from './context-length.vue'; import ContextLength from './context-length.vue';
@ -70,9 +72,9 @@ provide('tabStorage', tabStorage);
display: flex; display: flex;
gap: 2px; gap: 2px;
padding: 8px 0; padding: 8px 0;
background-color: var(--sidebar);
width: fit-content; width: fit-content;
border-radius: 99%; border-radius: 99%;
left: 5px;
bottom: 0px; bottom: 0px;
z-index: 10; z-index: 10;
position: absolute; position: absolute;
@ -212,6 +214,7 @@ provide('tabStorage', tabStorage);
border-radius: 50%; border-radius: 50%;
padding: 2px 6px; padding: 2px 6px;
font-size: 10px; font-size: 10px;
z-index: 10;
top: -16px; top: -16px;
right: -18px; right: -18px;
box-shadow: 0 0 6px rgba(0, 0, 0, 0.2); box-shadow: 0 0 6px rgba(0, 0, 0, 0.2);

View File

@ -38,7 +38,7 @@
import { ref, computed, inject, onMounted } from 'vue'; import { ref, computed, inject, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { allTools, ChatStorage, getToolSchema } from '../chat'; import { allTools, ChatStorage, getToolSchema } from '../chat';
import { markdownToHtml } from '../markdown/markdown'; import { markdownToHtml } from '@/components/main-panel/chat/markdown/markdown';
import { useMessageBridge } from '@/api/message-bridge'; import { useMessageBridge } from '@/api/message-bridge';
const { t } = useI18n(); const { t } = useI18n();

View File

@ -0,0 +1,42 @@
<template>
<el-tooltip :content="props.messages[0].content.text" placement="top">
<span class="chat-prompt-item" contenteditable="false">
<span class="iconfont icon-chat"></span>
<span class="real-text">{{ props.messages[0].content.text }}</span>
</span>
</el-tooltip>
</template>
<script setup lang="ts">
import { PromptsGetResponse } from '@/hook/type';
import { defineProps, PropType } from 'vue';
const props = defineProps({
messages: {
type: Array as PropType<PromptsGetResponse['messages']>,
required: true
}
});
</script>
<style>
.chat-prompt-item {
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-flex;
border-radius: .3em;
align-items: center;
padding: 0 4px;
background-color: #373839;
border: 1px solid var(--foreground);
font-size: 12px;
margin-left: 3px;
margin-right: 3px;
}
.chat-prompt-item .iconfont {
margin-right: 4px;
}
</style>

View File

@ -0,0 +1,168 @@
<template>
<el-tooltip placement="top">
<template #content>
<div class="resource-chat-item-tooltip">
<div v-for="(item, index) of toolRenderItems" :key="index">
<div v-if="item.mimeType === 'text/plain'">
{{ item.text }}
</div>
<div v-else-if="item.mimeType === 'image/jpeg' || item.mimeType === 'image/png'">
<img :src="item.imageUrl" alt="screenshot" />
<span>{{ item.text }}</span>
</div>
<div v-else>
</div>
</div>
</div>
</template>
<span class="chat-prompt-item" contenteditable="false">
<span class="iconfont icon-file"></span>
<span class="real-text">{{ resourceText }}</span>
<el-progress v-if="!finishProcess" class="progress" style="width: 100px;" :percentage="progress"
color="var(--main-color)"></el-progress>
</span>
</el-tooltip>
</template>
<script setup lang="ts">
import { useMessageBridge } from '@/api/message-bridge';
import { ResourcesReadResponse } from '@/hook/type';
import { getImageBlobUrlByBase64 } from '@/hook/util';
import { computed, defineProps, PropType, reactive, ref, watch } from 'vue';
const props = defineProps({
contents: {
type: Array as PropType<ResourcesReadResponse['contents']>,
required: true
}
});
// mcp
// 1.
// 2. { image_url: "https://tos.com/xxx.jpeg" }
const toolRenderItems = ref<{
mimeType: string;
text: string;
[key: string]: any;
}[]>([]);
const resourceText = computed(() => {
const texts = [];
for (const item of toolRenderItems.value) {
if (item.mimeType === 'text/plain') {
texts.push(item.text);
} else if (item.mimeType === 'image/jpeg' || item.mimeType === 'image/png') {
texts.push(item.text || '');
}
}
return texts.join('');
});
const bridge = useMessageBridge();
const progress = ref(0);
const progressText = ref('OCR');
const finishProcess = ref(true);
props.contents.forEach((content) => {
console.log(content);
if (content.mimeType === 'text/plain') {
toolRenderItems.value.push({
mimeType: content.mimeType,
text: content.text
});
}
if (content.mimeType === 'image/jpeg' || content.mimeType === 'image/png') {
finishProcess.value = false;
const blobUrl = getImageBlobUrlByBase64(content.blob!, content.mimeType);
toolRenderItems.value.push({
mimeType: content.mimeType,
imageUrl: blobUrl,
text: ''
});
const renderItem = toolRenderItems.value.at(-1)!;
const makeRequest = async () => {
const res = await bridge.commandRequest('ocr/start-ocr', {
base64String: content.blob,
mimeType: content.mimeType
});
if (res.code === 200) {
const filename = res.msg.filename;
const workerId = res.msg.workerId;
const cancel = bridge.addCommandListener('ocr/worker/log', data => {
finishProcess.value = false;
const { id, progress: p = 1.0, status = 'finish' } = data;
if (id === workerId) {
progressText.value = status;
progress.value = Math.min(Math.ceil(Math.max(p * 100, 0)), 100);
}
}, { once: false });
bridge.addCommandListener('ocr/worker/done', data => {
if (data.id !== workerId) {
return;
}
progress.value = 1;
finishProcess.value = true;
toolRenderItems.value[0].text = data.text;
cancel();
}, { once: true });
}
}
makeRequest();
}
});
</script>
<style>
.resource-chat-item-tooltip {
min-height: 100px;
max-width: 420px;
display: flex;
flex-direction: column;
}
.resource-chat-item-tooltip img {
max-width: 420px;
}
.chat-resource-item {
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-flex;
border-radius: .3em;
align-items: center;
padding: 0 4px;
background-color: #373839;
border: 1px solid var(--foreground);
font-size: 12px;
margin-left: 3px;
margin-right: 3px;
}
.chat-resource-item .iconfont {
margin-right: 4px;
}
</style>

View File

@ -0,0 +1,243 @@
<template>
<!-- 下侧的设置按钮 -->
<Setting :tabId="tabId" v-model="modelValue" />
<!-- 编辑区 -->
<div class="k-rich-textarea">
<div
:ref="el => editor = el"
contenteditable="true"
class="rich-editor"
:placeholder="placeholder"
@input="handleInput"
@keydown.backspace="handleBackspace"
@keydown.enter="handleKeydown"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
></div>
</div>
</template>
<script setup lang="ts">
import { ref, PropType, defineProps, defineEmits, computed, provide } from 'vue';
import type { RichTextItem } from './chat';
import Setting from './options/setting.vue';
const props = defineProps({
tabId: {
type: Number,
required: true
},
modelValue: {
type: String,
required: true
},
placeholder: {
type: String,
default: '输入消息...'
},
customClass: {
type: String,
default: ''
}
});
const modelValue = computed({
get() {
return props.modelValue;
},
set(value: string) {
emit('update:modelValue', value);
}
})
const emit = defineEmits(['update:modelValue', 'pressEnter']);
const editor = ref<any>(null);
provide('editorContext', {
editor,
});
function handleBackspace(event: KeyboardEvent) {
// Backspace
const editorElement = editor.value;
if (!(editorElement instanceof HTMLDivElement)) {
return;
}
const selection = window.getSelection();
if (!selection || !selection.rangeCount) {
return;
}
const range = selection.getRangeAt(0);
const startContainer = range.startContainer;
// rich-item
if (startContainer.parentElement?.classList.contains('rich-item')) {
event.preventDefault();
startContainer.parentElement.remove();
}
}
function handleInput(event: Event) {
const editorElement = editor.value;
if (!(editorElement instanceof HTMLDivElement)) {
return;
}
const fragments: string[] = [];
editorElement.childNodes.forEach(node => {
if (node.nodeType === Node.TEXT_NODE) {
fragments.push(node.textContent || '');
} else {
const element = node as HTMLElement;
const collection = element.getElementsByClassName('real-text');
const fragmentText = extractTextFromCollection(collection);
fragments.push(fragmentText || '');
}
});
console.log(fragments);
emit('update:modelValue', fragments.join(' '));
}
function extractTextFromCollection(collection: HTMLCollection) {
const texts = [];
for (let i = 0; i < collection.length; i++) {
texts.push(collection[i].textContent); // .innerText
}
return texts.join('');
}
const isComposing = ref(false);
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey && !isComposing.value) {
event.preventDefault();
const editorElement = editor.value;
if (!(editorElement instanceof HTMLDivElement)) {
return;
}
//
editorElement.innerHTML = '';
emit('pressEnter', event);
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
const editorElement = editor.value;
if (!(editorElement instanceof HTMLDivElement)) {
return;
}
const selection = window.getSelection();
if (!selection || !selection.rangeCount) {
return;
}
const range = selection.getRangeAt(0);
const startContainer = range.startContainer;
if (event.key === 'ArrowLeft') {
//
const previousSibling = startContainer.previousSibling;
if (previousSibling && previousSibling.nodeType !== Node.TEXT_NODE) {
event.preventDefault();
range.setStartBefore(previousSibling);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
} else if (event.key === 'ArrowRight') {
//
const nextSibling = startContainer.nextSibling;
if (nextSibling && nextSibling.nodeType !== Node.TEXT_NODE) {
event.preventDefault();
range.setStartAfter(nextSibling);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
}
}
}
function handleCompositionStart() {
isComposing.value = true;
}
function handleCompositionEnd() {
isComposing.value = false;
}
</script>
<style>
.k-rich-textarea {
border: 1px solid var(--main-color);
background-color: var(--el-input-bg-color, var(--el-fill-color-blank));
background-image: none;
border-radius: .5em;
box-shadow: 0 0 0 1px var(--el-input-border-color, var(--el-border-color)) inset;
box-sizing: border-box;
color: var(--el-input-text-color, var(--el-text-color-regular));
padding: 10px 10px;
display: inline-block;
font-size: var(--el-font-size-base);
position: relative;
vertical-align: bottom;
width: 100%;
min-height: 50px;
transition: var(--el-transition-box-shadow);
}
.rich-editor {
min-height: 100px;
outline: none;
}
.rich-editor:empty::before {
content: attr(placeholder);
color: #C0C4CC;
}
.rich-item {
padding: 2px 4px;
border-radius: 4px;
margin: 0 2px;
}
.rich-item-prompt {
background-color: #e8f0fe;
color: #1a73e8;
}
.rich-item-resource {
background-color: #f1f3f4;
color: #202124;
}
.chat-resource-item {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background-color: #373839;
font-size: 12px;
}
.chat-resource-item .iconfont {
margin-right: 4px;
}
</style>

View File

@ -1,9 +1,9 @@
/* eslint-disable */ /* eslint-disable */
import { Ref } from "vue"; import { Ref } from "vue";
import { ToolCall, ChatStorage, getToolSchema, MessageState } from "./chat"; import { ToolCall, ChatStorage, getToolSchema, MessageState } from "../chat-box/chat";
import { useMessageBridge } from "@/api/message-bridge"; import { useMessageBridge } from "@/api/message-bridge";
import type { OpenAI } from 'openai'; import type { OpenAI } from 'openai';
import { callTool } from "../tool/tools"; import { callTool } from "../../tool/tools";
import { llmManager, llms } from "@/views/setting/llm"; import { llmManager, llms } from "@/views/setting/llm";
import { pinkLog, redLog } from "@/views/setting/util"; import { pinkLog, redLog } from "@/views/setting/util";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";

View File

@ -1,4 +1,4 @@
import { IExtraInfo } from "./chat"; import { IExtraInfo } from "../chat-box/chat";
export interface UsageStatistic { export interface UsageStatistic {
input: number; input: number;

View File

@ -40,52 +40,29 @@
<div> <div>
<!-- <span class="iconfont icon-openmcp"></span> --> <!-- <span class="iconfont icon-openmcp"></span> -->
<span>{{ t('press-and-run') }} <span>{{ t('press-and-run') }}
<span style="padding: 5px 15px; border-radius: .5em; background-color: var(--background);"> <span style="padding: 5px 15px; border-radius: .5em; background-color: var(--background);">
<span class="iconfont icon-send"></span> <span class="iconfont icon-send"></span>
</span> </span>
</span> </span>
</div> </div>
</div> </div>
<footer class="chat-footer" ref="footerRef"> <ChatBox
<div class="input-area"> :ref="el => footerRef = el"
<div class="input-wrapper"> :tab-id="props.tabId"
<Setting :tabId="tabId" v-model="userInput" /> />
<KCuteTextarea
v-model="userInput"
:placeholder="t('enter-message-dot')"
: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>
<span v-else class="iconfont icon-stop"></span>
</el-button>
</div>
</div>
</footer>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, defineComponent, defineProps, onUnmounted, computed, nextTick, watch } from 'vue'; import { ref, onMounted, defineComponent, defineProps, onUnmounted, computed, nextTick, watch, provide } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { ElMessage, ScrollbarInstance } from 'element-plus'; import { ElMessage, ScrollbarInstance } from 'element-plus';
import { tabs } from '../panel'; import { tabs } from '../panel';
import { ChatMessage, ChatStorage, IRenderMessage, MessageState, ToolCall } from './chat'; import { ChatMessage, ChatStorage, IRenderMessage, MessageState, ToolCall } from './chat-box/chat';
import { TaskLoop } from './task-loop';
import { llmManager, llms } from '@/views/setting/llm';
import * as Message from './message'; import * as Message from './message';
import Setting from './options/setting.vue'; import ChatBox from './chat-box/index.vue';
import KCuteTextarea from '@/components/k-cute-textarea/index.vue';
import { provide } from 'vue';
defineComponent({ name: 'chat' }); defineComponent({ name: 'chat' });
@ -101,8 +78,6 @@ const props = defineProps({
const tab = tabs.content[props.tabId]; const tab = tabs.content[props.tabId];
const tabStorage = tab.storage as ChatStorage; const tabStorage = tab.storage as ChatStorage;
const userInput = ref('');
// messages // messages
if (!tabStorage.messages) { if (!tabStorage.messages) {
tabStorage.messages = [] as ChatMessage[]; tabStorage.messages = [] as ChatMessage[];
@ -158,18 +133,19 @@ const streamingToolCalls = ref<ToolCall[]>([]);
const chatContainerRef = ref<any>(null); const chatContainerRef = ref<any>(null);
const messageListRef = ref<any>(null); const messageListRef = ref<any>(null);
const footerRef = ref<any>(null);
const footerRef = ref<HTMLElement>();
const scrollHeight = ref('500px'); const scrollHeight = ref('500px');
const updateScrollHeight = () => { function updateScrollHeight() {
if (chatContainerRef.value && footerRef.value) { if (chatContainerRef.value && footerRef.value) {
const containerHeight = chatContainerRef.value.clientHeight; const containerHeight = chatContainerRef.value.clientHeight;
const footerHeight = footerRef.value.clientHeight; const footerHeight = footerRef.value.clientHeight;
scrollHeight.value = `${containerHeight - footerHeight}px`; scrollHeight.value = `${containerHeight - footerHeight}px`;
} }
}; }
provide('updateScrollHeight', updateScrollHeight);
const autoScroll = ref(true); const autoScroll = ref(true);
const scrollbarRef = ref<ScrollbarInstance>(); const scrollbarRef = ref<ScrollbarInstance>();
@ -184,8 +160,13 @@ const handleScroll = ({ scrollTop, scrollHeight, clientHeight }: {
autoScroll.value = scrollTop + clientHeight >= scrollHeight - 10; autoScroll.value = scrollTop + clientHeight >= scrollHeight - 10;
}; };
provide('streamingContent', streamingContent);
provide('streamingToolCalls', streamingToolCalls);
provide('isLoading', isLoading);
provide('autoScroll', autoScroll);
// scrollToBottom // scrollToBottom
const scrollToBottom = async () => { async function scrollToBottom() {
if (!scrollbarRef.value || !messageListRef.value) return; if (!scrollbarRef.value || !messageListRef.value) return;
await nextTick(); // DOM await nextTick(); // DOM
@ -198,7 +179,9 @@ const scrollToBottom = async () => {
} catch (error) { } catch (error) {
console.error('Scroll to bottom failed:', error); console.error('Scroll to bottom failed:', error);
} }
}; }
provide('scrollToBottom', scrollToBottom);
// streamingContent // streamingContent
watch(streamingContent, () => { watch(streamingContent, () => {
@ -213,78 +196,6 @@ watch(streamingToolCalls, () => {
} }
}, { deep: true }); }, { deep: true });
let loop: TaskLoop | undefined = undefined;
const handleSend = (newMessage?: string) => {
const userMessage = newMessage || userInput.value.trim();
if (!userMessage || isLoading.value) return;
autoScroll.value = true;
isLoading.value = true;
loop = new TaskLoop(streamingContent, streamingToolCalls);
loop.registerOnError((error) => {
ElMessage({
message: error.msg,
type: 'error',
duration: 3000
});
if (error.state === MessageState.ReceiveChunkError) {
tabStorage.messages.push({
role: 'assistant',
content: error.msg,
extraInfo: {
created: Date.now(),
state: error.state,
serverName: llms[llmManager.currentModelIndex].id || 'unknown'
}
});
}
isLoading.value = false;
});
loop.registerOnChunk(() => {
scrollToBottom();
});
loop.registerOnDone(() => {
isLoading.value = false;
scrollToBottom();
});
loop.registerOnEpoch(() => {
isLoading.value = true;
scrollToBottom();
});
loop.start(tabStorage, userMessage);
userInput.value = '';
};
const handleAbort = () => {
if (loop) {
loop.abort();
isLoading.value = false;
ElMessage.info('请求已中止');
}
};
provide('handleSend', handleSend);
onMounted(() => {
updateScrollHeight();
window.addEventListener('resize', updateScrollHeight);
scrollToBottom();
});
onUnmounted(() => {
window.removeEventListener('resize', updateScrollHeight);
});
</script> </script>
@ -387,67 +298,6 @@ onUnmounted(() => {
margin-top: 5px; margin-top: 5px;
} }
.chat-footer {
padding: 16px;
border-top: 1px solid var(--el-border-color);
flex-shrink: 0;
position: absolute;
height: fit-content !important;
bottom: 0;
width: 100%;
}
.input-area {
max-width: 800px;
margin: 0 auto;
position: relative;
}
.input-wrapper {
position: relative;
}
.chat-input {
padding-right: 80px;
}
.chat-input textarea {
border-radius: .5em;
}
.send-button {
position: absolute !important;
right: 8px !important;
bottom: 8px !important;
height: auto;
padding: 8px 12px;
font-size: 20px;
border-radius: 1.2em !important;
}
:deep(.chat-settings) {
position: absolute;
left: 0;
bottom: 0px;
z-index: 1;
}
.typing-cursor {
animation: blink 1s infinite;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.message-text p, .message-text p,
.message-text h3, .message-text h3,
.message-text ol, .message-text ol,

View File

@ -8,7 +8,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineProps } from 'vue'; import { defineProps } from 'vue';
import { markdownToHtml } from '../markdown/markdown'; import { markdownToHtml } from '@/components/main-panel/chat/markdown/markdown';
import MessageMeta from './message-meta.vue'; import MessageMeta from './message-meta.vue';

View File

@ -32,7 +32,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineComponent, defineProps, ref, computed } from 'vue'; import { defineComponent, defineProps, ref, computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { makeUsageStatistic } from '../usage'; import { makeUsageStatistic } from '@/components/main-panel/chat/core/usage';
defineComponent({ name: 'message-meta' }); defineComponent({ name: 'message-meta' });

View File

@ -20,7 +20,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineProps } from 'vue'; import { defineProps } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { markdownToHtml } from '../markdown/markdown'; import { markdownToHtml } from '@/components/main-panel/chat/markdown/markdown';
const { t } = useI18n(); const { t } = useI18n();

View File

@ -44,7 +44,7 @@ import { getBlobUrlByFilename } from '@/hook/util';
import { defineComponent, PropType, defineProps, ref, defineEmits } from 'vue'; import { defineComponent, PropType, defineProps, ref, defineEmits } from 'vue';
defineComponent({ name: 'toolcall-result-item' }); defineComponent({ name: 'toolcall-result-item' });
const emit = defineEmits(['update:item', 'update:ocr-done']); const emits = defineEmits(['update:item', 'update:ocr-done']);
const props = defineProps({ const props = defineProps({
item: { item: {
@ -73,16 +73,20 @@ if (ocr) {
} }
}, { once: false }); }, { once: false });
bridge.addCommandListener('ocr/worker/done', () => { bridge.addCommandListener('ocr/worker/done', data => {
if (data.id !== workerId) {
return;
}
progress.value = 1; progress.value = 1;
finishProcess.value = true; finishProcess.value = true;
if (props.item._meta) { if (props.item._meta) {
const { _meta, ...rest } = props.item; const { _meta, ...rest } = props.item;
emit('update:item', { ...rest }); emits('update:item', { ...rest });
} }
emit('update:ocr-done'); emits('update:ocr-done');
cancel(); cancel();
}, { once: true }); }, { once: true });

View File

@ -96,9 +96,9 @@
import { defineProps, ref, watch, PropType, computed, defineEmits } from 'vue'; import { defineProps, ref, watch, PropType, computed, defineEmits } from 'vue';
import MessageMeta from './message-meta.vue'; import MessageMeta from './message-meta.vue';
import { markdownToHtml } from '../markdown/markdown'; import { markdownToHtml } from '@/components/main-panel/chat/markdown/markdown';
import { createTest } from '@/views/setting/llm'; import { createTest } from '@/views/setting/llm';
import { IRenderMessage, MessageState } from '../chat'; import { IRenderMessage, MessageState } from '../chat-box/chat';
import { ToolCallContent } from '@/hook/type'; import { ToolCallContent } from '@/hook/type';
import ToolcallResultItem from './toolcall-result-item.vue'; import ToolcallResultItem from './toolcall-result-item.vue';

View File

@ -1,81 +0,0 @@
<template>
<el-tooltip :content="t('prompts')" placement="top">
<div class="setting-button" @click="showChoosePrompt = true">
<span class="iconfont icon-chat"></span>
</div>
</el-tooltip>
<!-- 上下文长度设置 - 改为滑块形式 -->
<el-dialog v-model="showChoosePrompt" :title="t('prompts')" width="400px">
<div class="prompt-template-container-scrollbar" v-if="!selectPrompt">
<PromptTemplates
:tab-id="-1"
@prompt-selected="prompt => selectPrompt = prompt"
/>
</div>
<div v-else>
<PromptReader
:tab-id="-1"
:current-prompt-name="selectPrompt!.name"
@prompt-get-response="msg => whenGetPromptResponse(msg)"
/>
</div>
<template #footer>
<el-button v-if="selectPrompt" @click="selectPrompt = undefined;">{{ t('return') }}</el-button>
<el-button @click="showChoosePrompt = false; selectPrompt = undefined;">{{ t("cancel") }}</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { inject, ref, defineProps, PropType, defineEmits } from 'vue';
import { useI18n } from 'vue-i18n';
import { ChatStorage } from '../chat';
import { PromptsGetResponse, PromptTemplate } from '@/hook/type';
import PromptTemplates from '../../prompt/prompt-templates.vue';
import PromptReader from '../../prompt/prompt-reader.vue';
import { ElMessage } from 'element-plus';
const { t } = useI18n();
const tabStorage = inject('tabStorage') as ChatStorage;
const props = defineProps({
modelValue: {
type: String,
required: true
}
});
const emits = defineEmits([ 'update:modelValue' ]);
let selectPrompt = ref<PromptTemplate | undefined>(undefined);
const showChoosePrompt = ref(false);
function whenGetPromptResponse(msg: PromptsGetResponse) {
try {
const content = msg.messages[0].content;
selectPrompt.value = undefined;
if (content) {
emits('update:modelValue', props.modelValue + content);
}
showChoosePrompt.value = false;
} catch (error) {
ElMessage.error((error as Error).message);
}
}
</script>
<style>
.icon-length {
font-size: 16px;
}
</style>

View File

@ -1,42 +0,0 @@
<template>
<el-tooltip :content="t('context-length')" placement="top">
<div class="setting-button" @click="showContextLengthDialog = true">
<span class="iconfont icon-length"></span>
<span class="value-badge">{{ tabStorage.settings.contextLength }}</span>
</div>
</el-tooltip>
<!-- 上下文长度设置 - 改为滑块形式 -->
<el-dialog v-model="showContextLengthDialog" :title="t('context-length') + ' ' + tabStorage.settings.contextLength"
width="400px">
<div class="slider-container">
<el-slider v-model="tabStorage.settings.contextLength" :min="1" :max="99" :step="1" />
<div class="slider-tips">
<span> 1: {{ t('single-dialog') }}</span>
<span> >1: {{ t('multi-dialog') }}</span>
</div>
</div>
<template #footer>
<el-button @click="showContextLengthDialog = false">{{ t("cancel") }}</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { defineComponent, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { ChatStorage } from '../chat';
const { t } = useI18n();
const tabStorage = inject('tabStorage') as ChatStorage;
const showContextLengthDialog = ref(false);
</script>
<style>
.icon-length {
font-size: 16px;
}
</style>

View File

@ -13,7 +13,7 @@
/> />
</span> </span>
</span> </span>
<el-scrollbar height="350px"> <el-scrollbar>
<div <div
class="output-content" class="output-content"
contenteditable="false" contenteditable="false"

View File

@ -4,16 +4,14 @@
</div> </div>
<div class="resource-reader-container"> <div class="resource-reader-container">
<el-form :model="tabStorage.formData" :rules="formRules" ref="formRef" label-position="top"> <el-form :model="tabStorage.formData" :rules="formRules" ref="formRef" label-position="top">
<el-form-item v-for="param in currentResource?.params" :key="param.name" <el-form-item v-for="param in currentResource?.params" :key="param.name" :label="param.name"
:label="param.name" :prop="param.name"> :prop="param.name">
<!-- 根据不同类型渲染不同输入组件 --> <!-- 根据不同类型渲染不同输入组件 -->
<el-input v-if="param.type === 'string'" v-model="tabStorage.formData[param.name]" <el-input v-if="param.type === 'string'" v-model="tabStorage.formData[param.name]"
:placeholder="param.placeholder || `请输入${param.name}`" :placeholder="param.placeholder || `请输入${param.name}`" @keydown.enter.prevent="handleSubmit" />
@keydown.enter.prevent="handleSubmit" />
<el-input-number v-else-if="param.type === 'number'" v-model="tabStorage.formData[param.name]" <el-input-number v-else-if="param.type === 'number'" v-model="tabStorage.formData[param.name]"
:placeholder="param.placeholder || `请输入${param.name}`" :placeholder="param.placeholder || `请输入${param.name}`" @keydown.enter.prevent="handleSubmit" />
@keydown.enter.prevent="handleSubmit" />
<el-switch v-else-if="param.type === 'boolean'" v-model="tabStorage.formData[param.name]" /> <el-switch v-else-if="param.type === 'boolean'" v-model="tabStorage.formData[param.name]" />
</el-form-item> </el-form-item>
@ -31,7 +29,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defineComponent, defineProps, watch, ref, computed } from 'vue'; import { defineComponent, defineProps, watch, ref, computed, reactive, defineEmits } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import type { FormInstance, FormRules } from 'element-plus'; import type { FormInstance, FormRules } from 'element-plus';
import { tabs } from '../panel'; import { tabs } from '../panel';
@ -48,11 +46,28 @@ const props = defineProps({
tabId: { tabId: {
type: Number, type: Number,
required: true required: true
},
currentResourceName: {
type: String,
required: false
} }
}); });
const tab = tabs.content[props.tabId]; const emits = defineEmits(['resource-get-response']);
const tabStorage = tab.storage as ResourceStorage;
let tabStorage: ResourceStorage;
if (props.tabId >= 0) {
const tab = tabs.content[props.tabId];
tabStorage = tab.storage as ResourceStorage;
} else {
tabStorage = reactive({
currentType: 'resource',
currentResourceName: props.currentResourceName || '',
formData: {},
lastResourceReadResponse: undefined
});
}
if (!tabStorage.formData) { if (!tabStorage.formData) {
tabStorage.formData = {}; tabStorage.formData = {};
@ -71,7 +86,7 @@ const currentResource = computed(() => {
const viewParams = params.map(param => ({ const viewParams = params.map(param => ({
name: param, name: param,
type: 'string', type: 'string',
placeholder: t('enter') +' ' + param, placeholder: t('enter') + ' ' + param,
required: true required: true
})); }));
@ -97,9 +112,6 @@ const formRules = computed<FormRules>(() => {
return rules; return rules;
}); });
// //
const initFormData = () => { const initFormData = () => {
if (!currentResource.value?.params) return; if (!currentResource.value?.params) return;
@ -110,7 +122,7 @@ const initFormData = () => {
newSchemaDataForm[param.name] = getDefaultValue(param); newSchemaDataForm[param.name] = getDefaultValue(param);
const originType = normaliseJavascriptType(typeof tabStorage.formData[param.name]); const originType = normaliseJavascriptType(typeof tabStorage.formData[param.name]);
if (tabStorage.formData[param.name]!== undefined && originType === param.type) { if (tabStorage.formData[param.name] !== undefined && originType === param.type) {
newSchemaDataForm[param.name] = tabStorage.formData[param.name]; newSchemaDataForm[param.name] = tabStorage.formData[param.name];
} }
}) })
@ -129,28 +141,31 @@ function getUri() {
return uri; return uri;
} }
const targetResource = resourcesManager.resources.find(resources => resources.name === tabStorage.currentResourceName); const currentResourceName = props.tabId >= 0 ? tabStorage.currentResourceName : props.currentResourceName;
const targetResource = resourcesManager.resources.find(resources => resources.name === currentResourceName);
return targetResource?.uri; return targetResource?.uri;
} }
// //
async function handleSubmit() { async function handleSubmit() {
const uri = getUri(); const uri = getUri();
console.log(uri);
const bridge = useMessageBridge(); const bridge = useMessageBridge();
const { code, msg } = await bridge.commandRequest('resources/read', { resourceUri: uri }); const { code, msg } = await bridge.commandRequest('resources/read', { resourceUri: uri });
if (code === 200) { tabStorage.lastResourceReadResponse = msg;
tabStorage.lastResourceReadResponse = msg;
} emits('resource-get-response', msg);
} }
// if (props.tabId >= 0) {
watch(() => tabStorage.currentResourceName, () => { watch(() => tabStorage.currentResourceName, () => {
initFormData(); initFormData();
resetForm(); resetForm();
}, { immediate: true }); }, { immediate: true });
}
</script> </script>

View File

@ -12,7 +12,7 @@
<div class="resource-template-container"> <div class="resource-template-container">
<div <div
class="item" class="item"
:class="{ 'active': tabStorage.currentType === 'template' && tabStorage.currentResourceName === template.name }" :class="{ 'active': props.tabId >= 0 && tabStorage.currentType === 'template' && tabStorage.currentResourceName === template.name }"
v-for="template of resourcesManager.templates" v-for="template of resourcesManager.templates"
:key="template.name" :key="template.name"
@click="handleClick(template)" @click="handleClick(template)"
@ -31,7 +31,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useMessageBridge } from '@/api/message-bridge'; import { useMessageBridge } from '@/api/message-bridge';
import { CasualRestAPI, ResourceTemplate, ResourceTemplatesListResponse } from '@/hook/type'; import { CasualRestAPI, ResourceTemplate, ResourceTemplatesListResponse } from '@/hook/type';
import { onMounted, onUnmounted, defineProps, ref } from 'vue'; import { onMounted, onUnmounted, defineProps, ref, reactive } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { resourcesManager, ResourceStorage } from './resources'; import { resourcesManager, ResourceStorage } from './resources';
import { tabs } from '../panel'; import { tabs } from '../panel';
@ -47,8 +47,19 @@ const props = defineProps({
} }
}); });
const tab = tabs.content[props.tabId]; let tabStorage: ResourceStorage;
const tabStorage = tab.storage as ResourceStorage;
if (props.tabId >= 0) {
const tab = tabs.content[props.tabId];
tabStorage = tab.storage as ResourceStorage;
} else {
tabStorage = reactive({
currentType:'template',
currentResourceName: '',
formData: {},
lastResourceReadResponse: undefined
});
}
function reloadResources(option: { first: boolean }) { function reloadResources(option: { first: boolean }) {
bridge.postMessage({ bridge.postMessage({

View File

@ -12,7 +12,7 @@
<div class="resource-template-container"> <div class="resource-template-container">
<div <div
class="item" class="item"
:class="{ 'active': tabStorage.currentType === 'resource' && tabStorage.currentResourceName === resource.name }" :class="{ 'active': props.tabId >= 0 && tabStorage.currentType === 'resource' && tabStorage.currentResourceName === resource.name }"
v-for="resource of resourcesManager.resources" v-for="resource of resourcesManager.resources"
:key="resource.uri" :key="resource.uri"
@click="handleClick(resource)" @click="handleClick(resource)"
@ -28,7 +28,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useMessageBridge } from '@/api/message-bridge'; import { useMessageBridge } from '@/api/message-bridge';
import { CasualRestAPI, Resources, ResourcesListResponse } from '@/hook/type'; import { CasualRestAPI, Resources, ResourcesListResponse } from '@/hook/type';
import { onMounted, onUnmounted, defineProps, ref } from 'vue'; import { onMounted, onUnmounted, defineProps, defineEmits, reactive } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { resourcesManager, ResourceStorage } from './resources'; import { resourcesManager, ResourceStorage } from './resources';
import { tabs } from '../panel'; import { tabs } from '../panel';
@ -44,8 +44,21 @@ const props = defineProps({
} }
}); });
const tab = tabs.content[props.tabId]; const emits = defineEmits([ 'resource-selected' ]);
const tabStorage = tab.storage as ResourceStorage;
let tabStorage: ResourceStorage;
if (props.tabId >= 0) {
const tab = tabs.content[props.tabId];
tabStorage = tab.storage as ResourceStorage;
} else {
tabStorage = reactive({
currentType:'resource',
currentResourceName: '',
formData: {},
lastResourceReadResponse: undefined
});
}
function reloadResources(option: { first: boolean }) { function reloadResources(option: { first: boolean }) {
bridge.postMessage({ bridge.postMessage({
@ -66,6 +79,8 @@ function handleClick(resource: Resources) {
tabStorage.currentType = 'resource'; tabStorage.currentType = 'resource';
tabStorage.currentResourceName = resource.name; tabStorage.currentResourceName = resource.name;
tabStorage.lastResourceReadResponse = undefined; tabStorage.lastResourceReadResponse = undefined;
emits('resource-selected', resource);
} }
let commandCancel: (() => void); let commandCancel: (() => void);

View File

@ -13,7 +13,7 @@
/> />
</span> </span>
</span> </span>
<el-scrollbar height="350px"> <el-scrollbar>
<div <div
class="output-content" class="output-content"
contenteditable="false" contenteditable="false"
@ -104,7 +104,7 @@ const formattedJson = computed(() => {
.resource-logger .output-content { .resource-logger .output-content {
border-radius: .5em; border-radius: .5em;
padding: 15px; padding: 15px;
min-height: 300px; min-height: 600px;
height: fit-content; height: fit-content;
font-family: var(--code-font-family); font-family: var(--code-font-family);
white-space: pre-wrap; white-space: pre-wrap;

View File

@ -167,9 +167,6 @@ watch(() => tabStorage.currentToolName, () => {
margin-bottom: 15px; margin-bottom: 15px;
} }
.tool-executor-container {
}
.tool-executor-container .el-switch .el-switch__action { .tool-executor-container .el-switch .el-switch__action {
background-color: var(--main-color); background-color: var(--main-color);

View File

@ -149,7 +149,9 @@ function toggleConnectionPanel() {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
white-space: wrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background-color: #f39a6d; background-color: #f39a6d;
padding: 5px 12px; padding: 5px 12px;
border-radius: .5em; border-radius: .5em;

View File

@ -65,6 +65,9 @@ export interface Resources {
uri: string; uri: string;
name: string; name: string;
mimeType: string; mimeType: string;
text?: string;
blob?: string;
[key: string]: any;
} }
export interface ResourceTemplatesListResponse { export interface ResourceTemplatesListResponse {

View File

@ -70,6 +70,12 @@ export async function getBlobUrlByFilename(filename: string) {
} }
export function getImageBlobUrlByBase64(base64String: string, mimeType: string, cacheKey?: string) { export function getImageBlobUrlByBase64(base64String: string, mimeType: string, cacheKey?: string) {
// 检查缓存中是否存在该文件
if (cacheKey && blobUrlCache.has(cacheKey)) {
return blobUrlCache.get(cacheKey);
}
const byteCharacters = atob(base64String); const byteCharacters = atob(base64String);
const byteNumbers = new Array(byteCharacters.length); const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) { for (let i = 0; i < byteCharacters.length; i++) {

View File

@ -1,5 +1,5 @@
import { ChatStorage } from '@/components/main-panel/chat/chat'; import { ChatStorage } from '@/components/main-panel/chat/chat-box/chat';
import { TaskLoop } from '@/components/main-panel/chat/task-loop'; import { TaskLoop } from '@/components/main-panel/chat/core/task-loop';
import { llmManager } from './llm'; import { llmManager } from './llm';
import { reactive, ref } from 'vue'; import { reactive, ref } from 'vue';

View File

@ -1,7 +1,7 @@
import { markRaw, reactive } from 'vue'; import { markRaw, reactive } from 'vue';
import { createTab, debugModes, tabs } from '@/components/main-panel/panel'; import { createTab, debugModes, tabs } from '@/components/main-panel/panel';
import { ToolStorage } from '@/components/main-panel/tool/tools'; import { ToolStorage } from '@/components/main-panel/tool/tools';
import { ToolCall } from '@/components/main-panel/chat/chat'; import { ToolCall } from '@/components/main-panel/chat/chat-box/chat';
import I18n from '@/i18n'; import I18n from '@/i18n';
const { t } = I18n.global; const { t } = I18n.global;

View File

@ -9,6 +9,16 @@ mcp = FastMCP('锦恢的 MCP Server', version="11.45.14")
def add(a: int, b: int) -> int: def add(a: int, b: int) -> int:
return a + b return a + b
@mcp.resource(
uri="network://log",
name='network_log',
description='用于演示的一个无参数资源协议'
)
def network_log() -> str:
# 访问处理 greeting://{name} 资源访问协议,然后返回
# 此处方便起见,直接返回一个 Hellobalabala 了
return f"Response from ..."
@mcp.resource( @mcp.resource(
uri="greeting://{name}", uri="greeting://{name}",
name='greeting', name='greeting',

View File

@ -1,6 +1,7 @@
import { Controller, RequestClientType } from "../common"; import { Controller, RequestClientType } from "../common";
import { PostMessageble } from "../hook/adapter"; import { PostMessageble } from "../hook/adapter";
import { diskStorage } from "../hook/db"; import { diskStorage } from "../hook/db";
import { createOcrWorker, saveBase64ImageData } from "./ocr.service";
export class OcrController { export class OcrController {
@Controller('ocr/get-ocr-image') @Controller('ocr/get-ocr-image')
@ -15,4 +16,21 @@ export class OcrController {
} }
} }
} }
@Controller('ocr/start-ocr')
async startOcr(client: RequestClientType, data: any, webview: PostMessageble) {
const { base64String, mimeType } = data;
const filename = saveBase64ImageData(base64String, mimeType);
const worker = createOcrWorker(filename, webview);
return {
code: 200,
msg: {
filename,
workerId: worker.id
}
}
}
} }

View File

@ -2,47 +2,37 @@
"currentIndex": 0, "currentIndex": 0,
"tabs": [ "tabs": [
{ {
"name": "交互测试", "name": "资源",
"icon": "icon-robot", "icon": "icon-file",
"type": "blank", "type": "blank",
"componentIndex": 3, "componentIndex": 0,
"storage": { "storage": {
"messages": [], "formData": {},
"settings": { "currentType": "resource",
"modelIndex": 0, "currentResourceName": "network_log",
"enableTools": [ "lastResourceReadResponse": {
"contents": [
{ {
"name": "add", "uri": "network://log",
"description": "对两个数字进行实数域的加法", "mimeType": "text/plain",
"enabled": true "text": "Response from ..."
},
{
"name": "multiply",
"description": "对两个数字进行实数域的乘法运算",
"enabled": true
},
{
"name": "is_even",
"description": "判断一个整数是否为偶数",
"enabled": true
},
{
"name": "capitalize",
"description": "将字符串首字母大写",
"enabled": true
},
{
"name": "get_weather_by_city_code",
"description": "根据城市天气预报的城市编码 (int),获取指定城市的天气信息",
"enabled": true
} }
], ]
"enableWebSearch": false,
"temperature": 0.7,
"contextLength": 20,
"systemPrompt": ""
} }
} }
},
{
"name": "工具",
"icon": "icon-tool",
"type": "blank",
"componentIndex": 2,
"storage": {
"formData": {
"a": 0,
"b": 0
},
"currentToolName": "add"
}
} }
] ]
} }