refactor: split frontend app modules
This commit is contained in:
parent
5e768a9e41
commit
66b846ee37
3981
static/src/app.ts
3981
static/src/app.ts
File diff suppressed because it is too large
Load Diff
139
static/src/app/bootstrap.ts
Normal file
139
static/src/app/bootstrap.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import katex from 'katex';
|
||||||
|
|
||||||
|
function normalizeShowImageSrc(src: string) {
|
||||||
|
if (!src) return '';
|
||||||
|
const trimmed = src.trim();
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||||
|
if (trimmed.startsWith('/user_upload/')) return trimmed;
|
||||||
|
// 兼容容器内部路径:/workspace/.../user_upload/xxx.png 或 /workspace/user_upload/xxx
|
||||||
|
const idx = trimmed.toLowerCase().indexOf('/user_upload/');
|
||||||
|
if (idx >= 0) {
|
||||||
|
return '/user_upload/' + trimmed.slice(idx + '/user_upload/'.length);
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../')) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeImageSrc(src: string) {
|
||||||
|
return !!normalizeShowImageSrc(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(input: string) {
|
||||||
|
return input
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShowImages(root: ParentNode | null = document) {
|
||||||
|
if (!root) return;
|
||||||
|
// 处理因自闭合解析导致的嵌套:把子 show_image 平铺到父后面
|
||||||
|
const nested = Array.from(root.querySelectorAll('show_image show_image')).reverse();
|
||||||
|
nested.forEach(child => {
|
||||||
|
const parent = child.parentElement;
|
||||||
|
if (parent && parent !== root) {
|
||||||
|
parent.after(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const nodes = Array.from(root.querySelectorAll('show_image:not([data-rendered])')).reverse();
|
||||||
|
nodes.forEach(node => {
|
||||||
|
// 将 show_image 内误被包裹的内容移动到当前节点之后,保持原有顺序
|
||||||
|
if (node.parentNode && node.firstChild) {
|
||||||
|
const parent = node.parentNode;
|
||||||
|
const ref = node.nextSibling; // 可能为 null,insertBefore 会当 append
|
||||||
|
const children = Array.from(node.childNodes);
|
||||||
|
children.forEach(child => parent.insertBefore(child, ref));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawSrc = node.getAttribute('src') || '';
|
||||||
|
const mappedSrc = normalizeShowImageSrc(rawSrc);
|
||||||
|
if (!mappedSrc) {
|
||||||
|
node.setAttribute('data-rendered', '1');
|
||||||
|
node.setAttribute('data-rendered-error', 'invalid-src');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const alt = node.getAttribute('alt') || '';
|
||||||
|
const safeAlt = escapeHtml(alt.trim());
|
||||||
|
const figure = document.createElement('figure');
|
||||||
|
figure.className = 'chat-inline-image';
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.loading = 'lazy';
|
||||||
|
img.src = mappedSrc;
|
||||||
|
img.alt = safeAlt;
|
||||||
|
img.onerror = () => {
|
||||||
|
figure.classList.add('chat-inline-image--error');
|
||||||
|
const tip = document.createElement('div');
|
||||||
|
tip.className = 'chat-inline-image__error';
|
||||||
|
tip.textContent = '图片加载失败';
|
||||||
|
figure.appendChild(tip);
|
||||||
|
};
|
||||||
|
figure.appendChild(img);
|
||||||
|
|
||||||
|
if (safeAlt) {
|
||||||
|
const caption = document.createElement('figcaption');
|
||||||
|
caption.innerHTML = safeAlt;
|
||||||
|
figure.appendChild(caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
node.replaceChildren(figure);
|
||||||
|
node.setAttribute('data-rendered', '1');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let showImageObserver: MutationObserver | null = null;
|
||||||
|
|
||||||
|
export function setupShowImageObserver() {
|
||||||
|
if (showImageObserver) return;
|
||||||
|
const container = document.querySelector('.messages-area') || document.body;
|
||||||
|
if (!container) return;
|
||||||
|
renderShowImages(container);
|
||||||
|
showImageObserver = new MutationObserver(() => renderShowImages(container));
|
||||||
|
showImageObserver.observe(container, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function teardownShowImageObserver() {
|
||||||
|
if (showImageObserver) {
|
||||||
|
showImageObserver.disconnect();
|
||||||
|
showImageObserver = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateViewportHeightVar() {
|
||||||
|
const docEl = document.documentElement;
|
||||||
|
const visualViewport = window.visualViewport;
|
||||||
|
|
||||||
|
if (visualViewport) {
|
||||||
|
const vh = visualViewport.height;
|
||||||
|
const bottomInset = Math.max(
|
||||||
|
0,
|
||||||
|
(window.innerHeight || docEl.clientHeight || vh) - visualViewport.height - visualViewport.offsetTop
|
||||||
|
);
|
||||||
|
docEl.style.setProperty('--app-viewport', `${vh}px`);
|
||||||
|
docEl.style.setProperty('--app-bottom-inset', `${bottomInset}px`);
|
||||||
|
} else {
|
||||||
|
const height = window.innerHeight || docEl.clientHeight;
|
||||||
|
if (height) {
|
||||||
|
docEl.style.setProperty('--app-viewport', `${height}px`);
|
||||||
|
}
|
||||||
|
docEl.style.setProperty('--app-bottom-inset', 'env(safe-area-inset-bottom, 0px)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.katex = katex;
|
||||||
|
|
||||||
|
updateViewportHeightVar();
|
||||||
|
window.addEventListener('resize', updateViewportHeightVar);
|
||||||
|
window.addEventListener('orientationchange', updateViewportHeightVar);
|
||||||
|
window.addEventListener('pageshow', updateViewportHeightVar);
|
||||||
|
if (window.visualViewport) {
|
||||||
|
window.visualViewport.addEventListener('resize', updateViewportHeightVar);
|
||||||
|
window.visualViewport.addEventListener('scroll', updateViewportHeightVar);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
static/src/app/components.ts
Normal file
27
static/src/app/components.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import ChatArea from '../components/chat/ChatArea.vue';
|
||||||
|
import ConversationSidebar from '../components/sidebar/ConversationSidebar.vue';
|
||||||
|
import LeftPanel from '../components/panels/LeftPanel.vue';
|
||||||
|
import FocusPanel from '../components/panels/FocusPanel.vue';
|
||||||
|
import TokenDrawer from '../components/token/TokenDrawer.vue';
|
||||||
|
import PersonalizationDrawer from '../components/personalization/PersonalizationDrawer.vue';
|
||||||
|
import LiquidGlassWidget from '../components/experiments/LiquidGlassWidget.vue';
|
||||||
|
import QuickMenu from '../components/input/QuickMenu.vue';
|
||||||
|
import InputComposer from '../components/input/InputComposer.vue';
|
||||||
|
import AppShell from '../components/shell/AppShell.vue';
|
||||||
|
import ImagePicker from '../components/overlay/ImagePicker.vue';
|
||||||
|
import ConversationReviewDialog from '../components/overlay/ConversationReviewDialog.vue';
|
||||||
|
|
||||||
|
export const appComponents = {
|
||||||
|
ChatArea,
|
||||||
|
ConversationSidebar,
|
||||||
|
LeftPanel,
|
||||||
|
FocusPanel,
|
||||||
|
TokenDrawer,
|
||||||
|
PersonalizationDrawer,
|
||||||
|
LiquidGlassWidget,
|
||||||
|
QuickMenu,
|
||||||
|
InputComposer,
|
||||||
|
AppShell,
|
||||||
|
ImagePicker,
|
||||||
|
ConversationReviewDialog
|
||||||
|
};
|
||||||
175
static/src/app/computed.ts
Normal file
175
static/src/app/computed.ts
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { mapState, mapWritableState } from 'pinia';
|
||||||
|
import { useConnectionStore } from '../stores/connection';
|
||||||
|
import { useFileStore } from '../stores/file';
|
||||||
|
import { useUiStore } from '../stores/ui';
|
||||||
|
import { useConversationStore } from '../stores/conversation';
|
||||||
|
import { useModelStore } from '../stores/model';
|
||||||
|
import { useChatStore } from '../stores/chat';
|
||||||
|
import { useInputStore } from '../stores/input';
|
||||||
|
import { useToolStore } from '../stores/tool';
|
||||||
|
import { useResourceStore } from '../stores/resource';
|
||||||
|
import { useFocusStore } from '../stores/focus';
|
||||||
|
import { useUploadStore } from '../stores/upload';
|
||||||
|
import { useMonitorStore } from '../stores/monitor';
|
||||||
|
import { usePolicyStore } from '../stores/policy';
|
||||||
|
|
||||||
|
export const computed = {
|
||||||
|
...mapWritableState(useConnectionStore, [
|
||||||
|
'isConnected',
|
||||||
|
'socket',
|
||||||
|
'stopRequested',
|
||||||
|
'projectPath',
|
||||||
|
'agentVersion',
|
||||||
|
'thinkingMode',
|
||||||
|
'runMode'
|
||||||
|
]),
|
||||||
|
...mapState(useFileStore, ['contextMenu', 'fileTree', 'expandedFolders', 'todoList']),
|
||||||
|
...mapWritableState(useUiStore, [
|
||||||
|
'sidebarCollapsed',
|
||||||
|
'workspaceCollapsed',
|
||||||
|
'chatDisplayMode',
|
||||||
|
'panelMode',
|
||||||
|
'panelMenuOpen',
|
||||||
|
'leftWidth',
|
||||||
|
'rightWidth',
|
||||||
|
'rightCollapsed',
|
||||||
|
'isResizing',
|
||||||
|
'resizingPanel',
|
||||||
|
'minPanelWidth',
|
||||||
|
'maxPanelWidth',
|
||||||
|
'quotaToast',
|
||||||
|
'toastQueue',
|
||||||
|
'confirmDialog',
|
||||||
|
'easterEgg',
|
||||||
|
'isMobileViewport',
|
||||||
|
'mobileOverlayMenuOpen',
|
||||||
|
'activeMobileOverlay'
|
||||||
|
]),
|
||||||
|
...mapWritableState(useConversationStore, [
|
||||||
|
'conversations',
|
||||||
|
'conversationsLoading',
|
||||||
|
'hasMoreConversations',
|
||||||
|
'loadingMoreConversations',
|
||||||
|
'currentConversationId',
|
||||||
|
'currentConversationTitle',
|
||||||
|
'searchQuery',
|
||||||
|
'searchTimer',
|
||||||
|
'searchResults',
|
||||||
|
'searchActive',
|
||||||
|
'searchInProgress',
|
||||||
|
'searchMoreAvailable',
|
||||||
|
'searchOffset',
|
||||||
|
'searchTotal',
|
||||||
|
'conversationsOffset',
|
||||||
|
'conversationsLimit'
|
||||||
|
]),
|
||||||
|
...mapWritableState(useModelStore, ['currentModelKey']),
|
||||||
|
...mapState(useModelStore, ['models']),
|
||||||
|
...mapWritableState(useChatStore, [
|
||||||
|
'messages',
|
||||||
|
'currentMessageIndex',
|
||||||
|
'streamingMessage',
|
||||||
|
'expandedBlocks',
|
||||||
|
'autoScrollEnabled',
|
||||||
|
'userScrolling',
|
||||||
|
'thinkingScrollLocks'
|
||||||
|
]),
|
||||||
|
...mapWritableState(useInputStore, [
|
||||||
|
'inputMessage',
|
||||||
|
'inputLineCount',
|
||||||
|
'inputIsMultiline',
|
||||||
|
'inputIsFocused',
|
||||||
|
'quickMenuOpen',
|
||||||
|
'toolMenuOpen',
|
||||||
|
'settingsOpen',
|
||||||
|
'imagePickerOpen',
|
||||||
|
'videoPickerOpen',
|
||||||
|
'selectedImages',
|
||||||
|
'selectedVideos'
|
||||||
|
]),
|
||||||
|
resolvedRunMode() {
|
||||||
|
const allowed = ['fast', 'thinking', 'deep'];
|
||||||
|
if (allowed.includes(this.runMode)) {
|
||||||
|
return this.runMode;
|
||||||
|
}
|
||||||
|
return this.thinkingMode ? 'thinking' : 'fast';
|
||||||
|
},
|
||||||
|
headerRunModeOptions() {
|
||||||
|
return [
|
||||||
|
{ value: 'fast', label: '快速模式', desc: '低思考,响应更快' },
|
||||||
|
{ value: 'thinking', label: '思考模式', desc: '更长思考,综合回答' },
|
||||||
|
{ value: 'deep', label: '深度思考', desc: '持续推理,适合复杂任务' }
|
||||||
|
];
|
||||||
|
},
|
||||||
|
headerRunModeLabel() {
|
||||||
|
const current = this.headerRunModeOptions.find((o) => o.value === this.resolvedRunMode);
|
||||||
|
return current ? current.label : '快速模式';
|
||||||
|
},
|
||||||
|
currentModelLabel() {
|
||||||
|
const modelStore = useModelStore();
|
||||||
|
return modelStore.currentModel?.label || 'Kimi-k2.5';
|
||||||
|
},
|
||||||
|
policyUiBlocks() {
|
||||||
|
const store = usePolicyStore();
|
||||||
|
return store.uiBlocks || {};
|
||||||
|
},
|
||||||
|
adminDisabledModels() {
|
||||||
|
const store = usePolicyStore();
|
||||||
|
return store.disabledModelSet;
|
||||||
|
},
|
||||||
|
modelOptions() {
|
||||||
|
const disabledSet = this.adminDisabledModels || new Set();
|
||||||
|
const options = this.models || [];
|
||||||
|
return options.map((opt) => ({
|
||||||
|
...opt,
|
||||||
|
disabled: disabledSet.has(opt.key)
|
||||||
|
})).filter((opt) => true);
|
||||||
|
},
|
||||||
|
titleRibbonVisible() {
|
||||||
|
return !this.isMobileViewport && this.chatDisplayMode === 'chat';
|
||||||
|
},
|
||||||
|
...mapWritableState(useToolStore, [
|
||||||
|
'preparingTools',
|
||||||
|
'activeTools',
|
||||||
|
'toolActionIndex',
|
||||||
|
'toolStacks',
|
||||||
|
'toolSettings',
|
||||||
|
'toolSettingsLoading'
|
||||||
|
]),
|
||||||
|
...mapWritableState(useResourceStore, [
|
||||||
|
'tokenPanelCollapsed',
|
||||||
|
'currentContextTokens',
|
||||||
|
'currentConversationTokens',
|
||||||
|
'projectStorage',
|
||||||
|
'containerStatus',
|
||||||
|
'containerNetRate',
|
||||||
|
'usageQuota'
|
||||||
|
]),
|
||||||
|
...mapWritableState(useFocusStore, ['focusedFiles']),
|
||||||
|
...mapWritableState(useUploadStore, ['uploading', 'mediaUploading']),
|
||||||
|
...mapState(useMonitorStore, {
|
||||||
|
monitorIsLocked: (store) => store.isLocked
|
||||||
|
}),
|
||||||
|
displayModeSwitchDisabled() {
|
||||||
|
return !!this.policyUiBlocks.block_virtual_monitor;
|
||||||
|
},
|
||||||
|
displayLockEngaged() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
streamingUi() {
|
||||||
|
return this.streamingMessage || this.hasPendingToolActions();
|
||||||
|
},
|
||||||
|
composerBusy() {
|
||||||
|
const monitorLock = this.monitorIsLocked && this.chatDisplayMode === 'monitor';
|
||||||
|
return this.streamingUi || this.taskInProgress || monitorLock || this.stopRequested;
|
||||||
|
},
|
||||||
|
composerHeroActive() {
|
||||||
|
return (this.blankHeroActive || this.blankHeroExiting) && !this.composerInteractionActive;
|
||||||
|
},
|
||||||
|
composerInteractionActive() {
|
||||||
|
const hasText = !!(this.inputMessage && this.inputMessage.trim().length > 0);
|
||||||
|
const hasImages = Array.isArray(this.selectedImages) && this.selectedImages.length > 0;
|
||||||
|
return this.quickMenuOpen || hasText || hasImages;
|
||||||
|
}
|
||||||
|
};
|
||||||
87
static/src/app/lifecycle.ts
Normal file
87
static/src/app/lifecycle.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { useChatActionStore } from '../stores/chatActions';
|
||||||
|
import { normalizeScrollLock } from '../composables/useScrollControl';
|
||||||
|
import { setupShowImageObserver, teardownShowImageObserver } from './bootstrap';
|
||||||
|
import { debugLog } from './methods/common';
|
||||||
|
|
||||||
|
export function created() {
|
||||||
|
const actionStore = useChatActionStore();
|
||||||
|
actionStore.registerDependencies({
|
||||||
|
pushToast: (payload) => this.uiPushToast(payload),
|
||||||
|
autoResizeInput: () => this.autoResizeInput(),
|
||||||
|
focusComposer: () => {
|
||||||
|
const inputEl = this.getComposerElement('stadiumInput');
|
||||||
|
if (inputEl && typeof inputEl.focus === 'function') {
|
||||||
|
inputEl.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isConnected: () => this.isConnected,
|
||||||
|
getSocket: () => this.socket,
|
||||||
|
downloadResource: (url, filename) => this.downloadResource(url, filename)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mounted() {
|
||||||
|
debugLog('Vue应用已挂载');
|
||||||
|
if (window.ensureCsrfToken) {
|
||||||
|
window.ensureCsrfToken().catch((err) => {
|
||||||
|
console.warn('CSRF token 初始化失败:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 并行启动路由解析与实时连接,避免等待路由加载阻塞思考模式同步
|
||||||
|
const routePromise = this.bootstrapRoute();
|
||||||
|
const socketPromise = this.initSocket();
|
||||||
|
await routePromise;
|
||||||
|
await socketPromise;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.ensureScrollListener();
|
||||||
|
// 刷新后若无输出,自动解锁滚动锁定
|
||||||
|
normalizeScrollLock(this);
|
||||||
|
});
|
||||||
|
setupShowImageObserver();
|
||||||
|
|
||||||
|
// 立即加载初始数据(并行获取状态,优先同步运行模式)
|
||||||
|
this.loadInitialData();
|
||||||
|
|
||||||
|
document.addEventListener('click', this.handleClickOutsideQuickMenu);
|
||||||
|
document.addEventListener('click', this.handleClickOutsidePanelMenu);
|
||||||
|
document.addEventListener('click', this.handleClickOutsideHeaderMenu);
|
||||||
|
document.addEventListener('click', this.handleClickOutsideMobileMenu);
|
||||||
|
window.addEventListener('popstate', this.handlePopState);
|
||||||
|
window.addEventListener('keydown', this.handleMobileOverlayEscape);
|
||||||
|
this.setupMobileViewportWatcher();
|
||||||
|
|
||||||
|
this.subAgentFetch();
|
||||||
|
this.subAgentStartPolling();
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.autoResizeInput();
|
||||||
|
});
|
||||||
|
this.resourceBindContainerVisibilityWatcher();
|
||||||
|
this.resourceStartContainerStatsPolling();
|
||||||
|
this.resourceStartProjectStoragePolling();
|
||||||
|
this.resourceStartUsageQuotaPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function beforeUnmount() {
|
||||||
|
document.removeEventListener('click', this.handleClickOutsideQuickMenu);
|
||||||
|
document.removeEventListener('click', this.handleClickOutsidePanelMenu);
|
||||||
|
document.removeEventListener('click', this.handleClickOutsideHeaderMenu);
|
||||||
|
document.removeEventListener('click', this.handleClickOutsideMobileMenu);
|
||||||
|
window.removeEventListener('popstate', this.handlePopState);
|
||||||
|
window.removeEventListener('keydown', this.handleMobileOverlayEscape);
|
||||||
|
this.teardownMobileViewportWatcher();
|
||||||
|
this.subAgentStopPolling();
|
||||||
|
this.resourceStopContainerStatsPolling();
|
||||||
|
this.resourceStopProjectStoragePolling();
|
||||||
|
this.resourceStopUsageQuotaPolling();
|
||||||
|
teardownShowImageObserver();
|
||||||
|
if (this.titleTypingTimer) {
|
||||||
|
clearInterval(this.titleTypingTimer);
|
||||||
|
this.titleTypingTimer = null;
|
||||||
|
}
|
||||||
|
const cleanup = this.destroyEasterEggEffect(true);
|
||||||
|
if (cleanup && typeof cleanup.catch === 'function') {
|
||||||
|
cleanup.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
21
static/src/app/methods/common.ts
Normal file
21
static/src/app/methods/common.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
const ENABLE_APP_DEBUG_LOGS = true;
|
||||||
|
const TRACE_CONV = true;
|
||||||
|
|
||||||
|
export function debugLog(...args) {
|
||||||
|
if (!ENABLE_APP_DEBUG_LOGS) return;
|
||||||
|
try {
|
||||||
|
console.log('[app]', ...args);
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore logging errors */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const traceLog = (...args) => {
|
||||||
|
if (!TRACE_CONV) return;
|
||||||
|
try {
|
||||||
|
console.log('[conv-trace]', ...args);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
407
static/src/app/methods/conversation.ts
Normal file
407
static/src/app/methods/conversation.ts
Normal file
@ -0,0 +1,407 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { debugLog, traceLog } from './common';
|
||||||
|
|
||||||
|
export const conversationMethods = {
|
||||||
|
// 完整重置所有状态
|
||||||
|
resetAllStates(reason = 'unspecified', options: { preserveMonitorWindows?: boolean } = {}) {
|
||||||
|
debugLog('重置所有前端状态', { reason, conversationId: this.currentConversationId });
|
||||||
|
this.logMessageState('resetAllStates:before-cleanup', { reason });
|
||||||
|
this.fileHideContextMenu();
|
||||||
|
this.monitorResetVisual({
|
||||||
|
preserveBubble: true,
|
||||||
|
preservePointer: true,
|
||||||
|
preserveWindows: !!options?.preserveMonitorWindows,
|
||||||
|
preserveQueue: !!options?.preserveMonitorWindows
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重置消息和流状态
|
||||||
|
this.streamingMessage = false;
|
||||||
|
this.currentMessageIndex = -1;
|
||||||
|
this.stopRequested = false;
|
||||||
|
this.taskInProgress = false;
|
||||||
|
this.dropToolEvents = false;
|
||||||
|
|
||||||
|
// 清理工具状态
|
||||||
|
this.toolResetTracking();
|
||||||
|
|
||||||
|
// 新增:将所有未完成的工具标记为已完成
|
||||||
|
this.messages.forEach(msg => {
|
||||||
|
if (msg.role === 'assistant' && msg.actions) {
|
||||||
|
msg.actions.forEach(action => {
|
||||||
|
if (action.type === 'tool' &&
|
||||||
|
(action.tool.status === 'preparing' || action.tool.status === 'running')) {
|
||||||
|
action.tool.status = 'completed';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清理Markdown缓存
|
||||||
|
if (this.markdownCache) {
|
||||||
|
this.markdownCache.clear();
|
||||||
|
}
|
||||||
|
this.chatClearThinkingLocks();
|
||||||
|
|
||||||
|
// 强制更新视图
|
||||||
|
this.$forceUpdate();
|
||||||
|
|
||||||
|
this.inputSetSettingsOpen(false);
|
||||||
|
this.inputSetToolMenuOpen(false);
|
||||||
|
this.inputSetQuickMenuOpen(false);
|
||||||
|
this.modeMenuOpen = false;
|
||||||
|
this.inputSetLineCount(1);
|
||||||
|
this.inputSetMultiline(false);
|
||||||
|
this.inputClearMessage();
|
||||||
|
this.inputClearSelectedImages();
|
||||||
|
this.inputSetImagePickerOpen(false);
|
||||||
|
this.imageEntries = [];
|
||||||
|
this.imageLoading = false;
|
||||||
|
this.conversationHasImages = false;
|
||||||
|
this.toolSetSettingsLoading(false);
|
||||||
|
this.toolSetSettings([]);
|
||||||
|
|
||||||
|
debugLog('前端状态重置完成');
|
||||||
|
this._scrollListenerReady = false;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.ensureScrollListener();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重置已加载对话标记,便于后续重新加载新对话历史
|
||||||
|
this.lastHistoryLoadedConversationId = null;
|
||||||
|
|
||||||
|
this.logMessageState('resetAllStates:after-cleanup', { reason });
|
||||||
|
},
|
||||||
|
|
||||||
|
scheduleResetAfterTask(reason = 'unspecified', options: { preserveMonitorWindows?: boolean } = {}) {
|
||||||
|
const start = Date.now();
|
||||||
|
const maxWait = 4000;
|
||||||
|
const interval = 200;
|
||||||
|
const tryReset = () => {
|
||||||
|
if (!this.monitorIsLocked || Date.now() - start >= maxWait) {
|
||||||
|
this.resetAllStates(reason, options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(tryReset, interval);
|
||||||
|
};
|
||||||
|
tryReset();
|
||||||
|
},
|
||||||
|
|
||||||
|
resetTokenStatistics() {
|
||||||
|
this.resourceResetTokenStatistics();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 对话管理核心功能
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
async loadConversationsList() {
|
||||||
|
const queryOffset = this.conversationsOffset;
|
||||||
|
const queryLimit = this.conversationsLimit;
|
||||||
|
const refreshToken = queryOffset === 0 ? ++this.conversationListRefreshToken : this.conversationListRefreshToken;
|
||||||
|
const requestSeq = ++this.conversationListRequestSeq;
|
||||||
|
this.conversationsLoading = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/conversations?limit=${queryLimit}&offset=${queryOffset}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
if (refreshToken !== this.conversationListRefreshToken) {
|
||||||
|
debugLog('忽略已过期的对话列表响应', {
|
||||||
|
requestSeq,
|
||||||
|
responseOffset: queryOffset
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryOffset === 0) {
|
||||||
|
this.conversations = data.data.conversations;
|
||||||
|
} else {
|
||||||
|
this.conversations.push(...data.data.conversations);
|
||||||
|
}
|
||||||
|
if (this.currentConversationId) {
|
||||||
|
this.promoteConversationToTop(this.currentConversationId);
|
||||||
|
}
|
||||||
|
this.hasMoreConversations = data.data.has_more;
|
||||||
|
debugLog(`已加载 ${this.conversations.length} 个对话`);
|
||||||
|
|
||||||
|
if (this.conversationsOffset === 0 && !this.currentConversationId && this.conversations.length > 0) {
|
||||||
|
const latestConversation = this.conversations[0];
|
||||||
|
if (latestConversation && latestConversation.id) {
|
||||||
|
await this.loadConversation(latestConversation.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('加载对话列表失败:', data.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载对话列表异常:', error);
|
||||||
|
} finally {
|
||||||
|
if (refreshToken === this.conversationListRefreshToken) {
|
||||||
|
this.conversationsLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadMoreConversations() {
|
||||||
|
if (this.loadingMoreConversations || !this.hasMoreConversations) return;
|
||||||
|
|
||||||
|
this.loadingMoreConversations = true;
|
||||||
|
this.conversationsOffset += this.conversationsLimit;
|
||||||
|
await this.loadConversationsList();
|
||||||
|
this.loadingMoreConversations = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadConversation(conversationId, options = {}) {
|
||||||
|
const force = Boolean(options.force);
|
||||||
|
debugLog('加载对话:', conversationId);
|
||||||
|
traceLog('loadConversation:start', { conversationId, currentConversationId: this.currentConversationId, force });
|
||||||
|
this.logMessageState('loadConversation:start', { conversationId, force });
|
||||||
|
this.suppressTitleTyping = true;
|
||||||
|
this.titleReady = false;
|
||||||
|
this.currentConversationTitle = '';
|
||||||
|
this.titleTypingText = '';
|
||||||
|
|
||||||
|
if (!force && conversationId === this.currentConversationId) {
|
||||||
|
debugLog('已是当前对话,跳过加载');
|
||||||
|
traceLog('loadConversation:skip-same', { conversationId });
|
||||||
|
this.suppressTitleTyping = false;
|
||||||
|
this.titleReady = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 调用加载API
|
||||||
|
const response = await fetch(`/api/conversations/${conversationId}/load`, {
|
||||||
|
method: 'PUT'
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
debugLog('对话加载API成功:', result);
|
||||||
|
traceLog('loadConversation:api-success', { conversationId, title: result.title });
|
||||||
|
|
||||||
|
// 2. 更新当前对话信息
|
||||||
|
this.skipConversationHistoryReload = true;
|
||||||
|
this.currentConversationId = conversationId;
|
||||||
|
this.currentConversationTitle = result.title;
|
||||||
|
this.titleReady = true;
|
||||||
|
this.suppressTitleTyping = false;
|
||||||
|
this.startTitleTyping(this.currentConversationTitle, { animate: false });
|
||||||
|
this.promoteConversationToTop(conversationId);
|
||||||
|
history.pushState({ conversationId }, '', `/${this.stripConversationPrefix(conversationId)}`);
|
||||||
|
this.skipConversationLoadedEvent = true;
|
||||||
|
|
||||||
|
// 3. 重置UI状态
|
||||||
|
this.resetAllStates(`loadConversation:${conversationId}`);
|
||||||
|
this.subAgentFetch();
|
||||||
|
this.fetchTodoList();
|
||||||
|
|
||||||
|
// 4. 立即加载历史和统计,确保列表切换后界面同步更新
|
||||||
|
await this.fetchAndDisplayHistory();
|
||||||
|
this.fetchConversationTokenStatistics();
|
||||||
|
this.updateCurrentContextTokens();
|
||||||
|
traceLog('loadConversation:after-history', {
|
||||||
|
conversationId,
|
||||||
|
messagesLen: Array.isArray(this.messages) ? this.messages.length : 'n/a'
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.error('对话加载失败:', result.message);
|
||||||
|
this.suppressTitleTyping = false;
|
||||||
|
this.titleReady = true;
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '加载对话失败',
|
||||||
|
message: result.message || '服务器未返回成功状态',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载对话异常:', error);
|
||||||
|
traceLog('loadConversation:error', { conversationId, error: error?.message || String(error) });
|
||||||
|
this.suppressTitleTyping = false;
|
||||||
|
this.titleReady = true;
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '加载对话异常',
|
||||||
|
message: error.message || String(error),
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
promoteConversationToTop(conversationId) {
|
||||||
|
if (!Array.isArray(this.conversations) || !conversationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const index = this.conversations.findIndex(conv => conv && conv.id === conversationId);
|
||||||
|
if (index > 0) {
|
||||||
|
const [selected] = this.conversations.splice(index, 1);
|
||||||
|
this.conversations.unshift(selected);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async createNewConversation() {
|
||||||
|
debugLog('创建新对话...');
|
||||||
|
traceLog('createNewConversation:start', {
|
||||||
|
currentConversationId: this.currentConversationId,
|
||||||
|
convCount: Array.isArray(this.conversations) ? this.conversations.length : 'n/a'
|
||||||
|
});
|
||||||
|
this.logMessageState('createNewConversation:start');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/conversations', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
thinking_mode: this.thinkingMode,
|
||||||
|
mode: this.runMode
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const newConversationId = result.conversation_id;
|
||||||
|
debugLog('新对话创建成功:', newConversationId);
|
||||||
|
traceLog('createNewConversation:created', { newConversationId });
|
||||||
|
|
||||||
|
// 在本地列表插入占位,避免等待刷新
|
||||||
|
const placeholder = {
|
||||||
|
id: newConversationId,
|
||||||
|
title: '新对话',
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
total_messages: 0,
|
||||||
|
total_tools: 0
|
||||||
|
};
|
||||||
|
this.conversations = [
|
||||||
|
placeholder,
|
||||||
|
...this.conversations.filter(conv => conv && conv.id !== newConversationId)
|
||||||
|
];
|
||||||
|
|
||||||
|
// 直接加载新对话,确保状态一致
|
||||||
|
// 如果 socket 事件已把 currentConversationId 设置为新ID,则强制加载一次以同步状态
|
||||||
|
await this.loadConversation(newConversationId, { force: true });
|
||||||
|
traceLog('createNewConversation:after-load', {
|
||||||
|
newConversationId,
|
||||||
|
currentConversationId: this.currentConversationId
|
||||||
|
});
|
||||||
|
|
||||||
|
// 刷新对话列表获取最新统计
|
||||||
|
this.conversationsOffset = 0;
|
||||||
|
await this.loadConversationsList();
|
||||||
|
traceLog('createNewConversation:after-refresh', {
|
||||||
|
newConversationId,
|
||||||
|
conversationsLen: Array.isArray(this.conversations) ? this.conversations.length : 'n/a'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error('创建对话失败:', result.message);
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '创建对话失败',
|
||||||
|
message: result.message || '服务器未返回成功状态',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建对话异常:', error);
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '创建对话异常',
|
||||||
|
message: error.message || String(error),
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteConversation(conversationId) {
|
||||||
|
const confirmed = await this.confirmAction({
|
||||||
|
title: '删除对话',
|
||||||
|
message: '确定要删除这个对话吗?删除后无法恢复。',
|
||||||
|
confirmText: '删除',
|
||||||
|
cancelText: '取消'
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLog('删除对话:', conversationId);
|
||||||
|
this.logMessageState('deleteConversation:start', { conversationId });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/conversations/${conversationId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
debugLog('对话删除成功');
|
||||||
|
|
||||||
|
// 如果删除的是当前对话,清空界面
|
||||||
|
if (conversationId === this.currentConversationId) {
|
||||||
|
this.logMessageState('deleteConversation:before-clear', { conversationId });
|
||||||
|
this.messages = [];
|
||||||
|
this.logMessageState('deleteConversation:after-clear', { conversationId });
|
||||||
|
this.currentConversationId = null;
|
||||||
|
this.currentConversationTitle = '';
|
||||||
|
this.resetAllStates(`deleteConversation:${conversationId}`);
|
||||||
|
this.resetTokenStatistics();
|
||||||
|
history.replaceState({}, '', '/new');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新对话列表
|
||||||
|
this.conversationsOffset = 0;
|
||||||
|
await this.loadConversationsList();
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.error('删除对话失败:', result.message);
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '删除对话失败',
|
||||||
|
message: result.message || '服务器未返回成功状态',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除对话异常:', error);
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '删除对话异常',
|
||||||
|
message: error.message || String(error),
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async duplicateConversation(conversationId) {
|
||||||
|
debugLog('复制对话:', conversationId);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/conversations/${conversationId}/duplicate`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result.success) {
|
||||||
|
const newId = result.duplicate_conversation_id;
|
||||||
|
if (newId) {
|
||||||
|
this.currentConversationId = newId;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.conversationsOffset = 0;
|
||||||
|
await this.loadConversationsList();
|
||||||
|
} else {
|
||||||
|
const message = result.message || result.error || '复制失败';
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '复制对话失败',
|
||||||
|
message,
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('复制对话异常:', error);
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '复制对话异常',
|
||||||
|
message: error.message || String(error),
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
396
static/src/app/methods/history.ts
Normal file
396
static/src/app/methods/history.ts
Normal file
@ -0,0 +1,396 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { debugLog } from './common';
|
||||||
|
|
||||||
|
export const historyMethods = {
|
||||||
|
// ==========================================
|
||||||
|
// 关键功能:获取并显示历史对话内容
|
||||||
|
// ==========================================
|
||||||
|
async fetchAndDisplayHistory(options = {}) {
|
||||||
|
const { force = false } = options as { force?: boolean };
|
||||||
|
const targetConversationId = this.currentConversationId;
|
||||||
|
if (!targetConversationId || targetConversationId.startsWith('temp_')) {
|
||||||
|
debugLog('没有当前对话ID,跳过历史加载');
|
||||||
|
this.refreshBlankHeroState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若同一对话正在加载,直接复用;若是切换对话则允许并发但后来的请求会赢
|
||||||
|
if (this.historyLoading && this.historyLoadingFor === targetConversationId) {
|
||||||
|
debugLog('同一对话历史正在加载,跳过重复请求');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已经有完整历史且非强制刷新时,避免重复加载导致动画播放两次
|
||||||
|
const alreadyHydrated =
|
||||||
|
!force &&
|
||||||
|
this.lastHistoryLoadedConversationId === targetConversationId &&
|
||||||
|
Array.isArray(this.messages) &&
|
||||||
|
this.messages.length > 0;
|
||||||
|
if (alreadyHydrated) {
|
||||||
|
debugLog('历史已加载,跳过重复请求');
|
||||||
|
this.logMessageState('fetchAndDisplayHistory:skip-duplicate', {
|
||||||
|
conversationId: targetConversationId
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSeq = ++this.historyLoadSeq;
|
||||||
|
this.historyLoading = true;
|
||||||
|
this.historyLoadingFor = targetConversationId;
|
||||||
|
try {
|
||||||
|
debugLog('开始获取历史对话内容...');
|
||||||
|
this.logMessageState('fetchAndDisplayHistory:start', { conversationId: this.currentConversationId });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用专门的API获取对话消息历史
|
||||||
|
const messagesResponse = await fetch(`/api/conversations/${targetConversationId}/messages`);
|
||||||
|
|
||||||
|
if (!messagesResponse.ok) {
|
||||||
|
console.warn('无法获取消息历史,尝试备用方法');
|
||||||
|
// 备用方案:通过状态API获取
|
||||||
|
const statusResponse = await fetch('/api/status');
|
||||||
|
const status = await statusResponse.json();
|
||||||
|
debugLog('系统状态:', status);
|
||||||
|
this.applyStatusSnapshot(status);
|
||||||
|
|
||||||
|
// 如果状态中有对话历史字段
|
||||||
|
if (status.conversation_history && Array.isArray(status.conversation_history)) {
|
||||||
|
this.renderHistoryMessages(status.conversation_history);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLog('备用方案也无法获取历史消息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果在等待期间用户已切换到其他对话,则丢弃结果
|
||||||
|
if (loadSeq !== this.historyLoadSeq || this.currentConversationId !== targetConversationId) {
|
||||||
|
debugLog('检测到对话已切换,丢弃过期的历史加载结果');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messagesData = await messagesResponse.json();
|
||||||
|
debugLog('获取到消息数据:', messagesData);
|
||||||
|
|
||||||
|
if (messagesData.success && messagesData.data && messagesData.data.messages) {
|
||||||
|
const messages = messagesData.data.messages;
|
||||||
|
debugLog(`发现 ${messages.length} 条历史消息`);
|
||||||
|
|
||||||
|
if (messages.length > 0) {
|
||||||
|
// 清空当前显示的消息
|
||||||
|
this.logMessageState('fetchAndDisplayHistory:before-clear-existing');
|
||||||
|
this.messages = [];
|
||||||
|
this.logMessageState('fetchAndDisplayHistory:after-clear-existing');
|
||||||
|
|
||||||
|
// 渲染历史消息 - 这是关键功能
|
||||||
|
this.renderHistoryMessages(messages);
|
||||||
|
|
||||||
|
// 滚动到底部
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.scrollToBottom();
|
||||||
|
});
|
||||||
|
|
||||||
|
debugLog('历史对话内容显示完成');
|
||||||
|
} else {
|
||||||
|
debugLog('对话存在但没有历史消息');
|
||||||
|
this.logMessageState('fetchAndDisplayHistory:no-history-clear');
|
||||||
|
this.messages = [];
|
||||||
|
this.logMessageState('fetchAndDisplayHistory:no-history-cleared');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debugLog('消息数据格式不正确:', messagesData);
|
||||||
|
this.logMessageState('fetchAndDisplayHistory:invalid-data-clear');
|
||||||
|
this.messages = [];
|
||||||
|
this.logMessageState('fetchAndDisplayHistory:invalid-data-cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取历史对话失败:', error);
|
||||||
|
debugLog('尝试不显示错误弹窗,仅在控制台记录');
|
||||||
|
// 不显示alert,避免打断用户体验
|
||||||
|
this.logMessageState('fetchAndDisplayHistory:error-clear', { error: error?.message || String(error) });
|
||||||
|
this.messages = [];
|
||||||
|
this.logMessageState('fetchAndDisplayHistory:error-cleared');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// 仅在本次加载仍是最新请求时清除 loading 状态
|
||||||
|
if (loadSeq === this.historyLoadSeq) {
|
||||||
|
this.historyLoading = false;
|
||||||
|
this.historyLoadingFor = null;
|
||||||
|
}
|
||||||
|
this.refreshBlankHeroState();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 关键功能:渲染历史消息
|
||||||
|
// ==========================================
|
||||||
|
renderHistoryMessages(historyMessages) {
|
||||||
|
debugLog('开始渲染历史消息...', historyMessages);
|
||||||
|
debugLog('历史消息数量:', historyMessages.length);
|
||||||
|
this.logMessageState('renderHistoryMessages:start', { historyCount: historyMessages.length });
|
||||||
|
|
||||||
|
if (!Array.isArray(historyMessages)) {
|
||||||
|
console.error('历史消息不是数组格式');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentAssistantMessage = null;
|
||||||
|
let historyHasImages = false;
|
||||||
|
let historyHasVideos = false;
|
||||||
|
|
||||||
|
historyMessages.forEach((message, index) => {
|
||||||
|
debugLog(`处理消息 ${index + 1}/${historyMessages.length}:`, message.role, message);
|
||||||
|
const meta = message.metadata || {};
|
||||||
|
if (message.role === 'user' && (meta.system_injected_image || meta.system_injected_video)) {
|
||||||
|
debugLog('跳过系统代发的图片/视频消息(仅用于模型查看,不在前端展示)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.role === 'user') {
|
||||||
|
// 用户消息 - 先结束之前的assistant消息
|
||||||
|
if (currentAssistantMessage && currentAssistantMessage.actions.length > 0) {
|
||||||
|
this.messages.push(currentAssistantMessage);
|
||||||
|
currentAssistantMessage = null;
|
||||||
|
}
|
||||||
|
const images = message.images || (message.metadata && message.metadata.images) || [];
|
||||||
|
const videos = message.videos || (message.metadata && message.metadata.videos) || [];
|
||||||
|
if (Array.isArray(images) && images.length) {
|
||||||
|
historyHasImages = true;
|
||||||
|
}
|
||||||
|
if (Array.isArray(videos) && videos.length) {
|
||||||
|
historyHasVideos = true;
|
||||||
|
}
|
||||||
|
this.messages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: message.content || '',
|
||||||
|
images,
|
||||||
|
videos
|
||||||
|
});
|
||||||
|
debugLog('添加用户消息:', message.content?.substring(0, 50) + '...');
|
||||||
|
|
||||||
|
} else if (message.role === 'assistant') {
|
||||||
|
// AI消息 - 如果没有当前assistant消息,创建一个
|
||||||
|
if (!currentAssistantMessage) {
|
||||||
|
currentAssistantMessage = {
|
||||||
|
role: 'assistant',
|
||||||
|
actions: [],
|
||||||
|
streamingThinking: '',
|
||||||
|
streamingText: '',
|
||||||
|
currentStreamingType: null,
|
||||||
|
activeThinkingId: null,
|
||||||
|
awaitingFirstContent: false,
|
||||||
|
generatingLabel: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = message.content || '';
|
||||||
|
const reasoningText = (message.reasoning_content || '').trim();
|
||||||
|
|
||||||
|
if (reasoningText) {
|
||||||
|
const blockId = `history-thinking-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
currentAssistantMessage.actions.push({
|
||||||
|
id: `history-think-${Date.now()}-${Math.random()}`,
|
||||||
|
type: 'thinking',
|
||||||
|
content: reasoningText,
|
||||||
|
streaming: false,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
blockId
|
||||||
|
});
|
||||||
|
debugLog('添加思考内容:', reasoningText.substring(0, 50) + '...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理普通文本内容(移除思考标签后的内容)
|
||||||
|
const metadata = message.metadata || {};
|
||||||
|
const appendPayloadMeta = metadata.append_payload;
|
||||||
|
const modifyPayloadMeta = metadata.modify_payload;
|
||||||
|
const isAppendMessage = message.name === 'append_to_file';
|
||||||
|
const isModifyMessage = message.name === 'modify_file';
|
||||||
|
const containsAppendMarkers = /<<<\s*(APPEND|MODIFY)/i.test(content || '') || /<<<END_\s*(APPEND|MODIFY)>>>/i.test(content || '');
|
||||||
|
|
||||||
|
const textContent = content.trim();
|
||||||
|
|
||||||
|
if (appendPayloadMeta) {
|
||||||
|
currentAssistantMessage.actions.push({
|
||||||
|
id: `history-append-payload-${Date.now()}-${Math.random()}`,
|
||||||
|
type: 'append_payload',
|
||||||
|
append: {
|
||||||
|
path: appendPayloadMeta.path || '未知文件',
|
||||||
|
forced: !!appendPayloadMeta.forced,
|
||||||
|
success: appendPayloadMeta.success === undefined ? true : !!appendPayloadMeta.success,
|
||||||
|
lines: appendPayloadMeta.lines ?? null,
|
||||||
|
bytes: appendPayloadMeta.bytes ?? null
|
||||||
|
},
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
debugLog('添加append占位信息:', appendPayloadMeta.path);
|
||||||
|
} else if (modifyPayloadMeta) {
|
||||||
|
currentAssistantMessage.actions.push({
|
||||||
|
id: `history-modify-payload-${Date.now()}-${Math.random()}`,
|
||||||
|
type: 'modify_payload',
|
||||||
|
modify: {
|
||||||
|
path: modifyPayloadMeta.path || '未知文件',
|
||||||
|
total: modifyPayloadMeta.total_blocks ?? null,
|
||||||
|
completed: modifyPayloadMeta.completed || [],
|
||||||
|
failed: modifyPayloadMeta.failed || [],
|
||||||
|
forced: !!modifyPayloadMeta.forced,
|
||||||
|
details: modifyPayloadMeta.details || []
|
||||||
|
},
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
debugLog('添加modify占位信息:', modifyPayloadMeta.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textContent && !appendPayloadMeta && !modifyPayloadMeta && !isAppendMessage && !isModifyMessage && !containsAppendMarkers) {
|
||||||
|
currentAssistantMessage.actions.push({
|
||||||
|
id: `history-text-${Date.now()}-${Math.random()}`,
|
||||||
|
type: 'text',
|
||||||
|
content: textContent,
|
||||||
|
streaming: false,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
debugLog('添加文本内容:', textContent.substring(0, 50) + '...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理工具调用
|
||||||
|
if (message.tool_calls && Array.isArray(message.tool_calls)) {
|
||||||
|
message.tool_calls.forEach((toolCall, tcIndex) => {
|
||||||
|
let arguments_obj = {};
|
||||||
|
try {
|
||||||
|
arguments_obj = typeof toolCall.function.arguments === 'string'
|
||||||
|
? JSON.parse(toolCall.function.arguments || '{}')
|
||||||
|
: (toolCall.function.arguments || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('解析工具参数失败:', e);
|
||||||
|
arguments_obj = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = {
|
||||||
|
id: `history-tool-${toolCall.id || Date.now()}-${tcIndex}`,
|
||||||
|
type: 'tool',
|
||||||
|
tool: {
|
||||||
|
id: toolCall.id,
|
||||||
|
name: toolCall.function.name,
|
||||||
|
arguments: arguments_obj,
|
||||||
|
intent_full: arguments_obj.intent || '',
|
||||||
|
intent_rendered: arguments_obj.intent || '',
|
||||||
|
status: 'preparing',
|
||||||
|
result: null
|
||||||
|
},
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
// 如果是历史加载的动作且状态仍为进行中,标记为 stale,避免刷新后按钮卡死
|
||||||
|
if (['preparing', 'running', 'awaiting_content'].includes(action.tool.status)) {
|
||||||
|
action.tool.status = 'stale';
|
||||||
|
action.tool.awaiting_content = false;
|
||||||
|
action.streaming = false;
|
||||||
|
}
|
||||||
|
currentAssistantMessage.actions.push(action);
|
||||||
|
debugLog('添加工具调用:', toolCall.function.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (message.role === 'tool') {
|
||||||
|
// 工具结果 - 更新当前assistant消息中对应的工具
|
||||||
|
if (currentAssistantMessage) {
|
||||||
|
// 查找对应的工具action - 使用更灵活的匹配
|
||||||
|
let toolAction = null;
|
||||||
|
|
||||||
|
// 优先按tool_call_id匹配
|
||||||
|
if (message.tool_call_id) {
|
||||||
|
toolAction = currentAssistantMessage.actions.find(action =>
|
||||||
|
action.type === 'tool' &&
|
||||||
|
action.tool.id === message.tool_call_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果找不到,按name匹配最后一个同名工具
|
||||||
|
if (!toolAction && message.name) {
|
||||||
|
const sameNameTools = currentAssistantMessage.actions.filter(action =>
|
||||||
|
action.type === 'tool' &&
|
||||||
|
action.tool.name === message.name
|
||||||
|
);
|
||||||
|
toolAction = sameNameTools[sameNameTools.length - 1]; // 取最后一个
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolAction) {
|
||||||
|
// 解析工具结果(优先使用JSON,其次使用元数据的 tool_payload,以保证搜索结果在刷新后仍可展示)
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = JSON.parse(message.content);
|
||||||
|
} catch (e) {
|
||||||
|
if (message.metadata && message.metadata.tool_payload) {
|
||||||
|
result = message.metadata.tool_payload;
|
||||||
|
} else {
|
||||||
|
result = {
|
||||||
|
output: message.content,
|
||||||
|
success: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toolAction.tool.status = 'completed';
|
||||||
|
toolAction.tool.result = result;
|
||||||
|
if (result && typeof result === 'object') {
|
||||||
|
if (result.error) {
|
||||||
|
toolAction.tool.message = result.error;
|
||||||
|
} else if (result.message && !toolAction.tool.message) {
|
||||||
|
toolAction.tool.message = result.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (message.name === 'append_to_file' && result && result.message) {
|
||||||
|
toolAction.tool.message = result.message;
|
||||||
|
}
|
||||||
|
debugLog(`更新工具结果: ${message.name} -> ${message.content?.substring(0, 50)}...`);
|
||||||
|
|
||||||
|
// append_to_file 的摘要在 append_payload 占位中呈现,此处无需重复
|
||||||
|
} else {
|
||||||
|
console.warn('找不到对应的工具调用:', message.name, message.tool_call_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// 其他类型消息(如system)- 先结束当前assistant消息
|
||||||
|
if (currentAssistantMessage && currentAssistantMessage.actions.length > 0) {
|
||||||
|
this.messages.push(currentAssistantMessage);
|
||||||
|
currentAssistantMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLog('处理其他类型消息:', message.role);
|
||||||
|
this.messages.push({
|
||||||
|
role: message.role,
|
||||||
|
content: message.content || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理最后一个assistant消息
|
||||||
|
if (currentAssistantMessage && currentAssistantMessage.actions.length > 0) {
|
||||||
|
this.messages.push(currentAssistantMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.conversationHasImages = historyHasImages;
|
||||||
|
this.conversationHasVideos = historyHasVideos;
|
||||||
|
|
||||||
|
debugLog(`历史消息渲染完成,共 ${this.messages.length} 条消息`);
|
||||||
|
this.logMessageState('renderHistoryMessages:after-render');
|
||||||
|
this.lastHistoryLoadedConversationId = this.currentConversationId || null;
|
||||||
|
|
||||||
|
// 强制更新视图
|
||||||
|
this.$forceUpdate();
|
||||||
|
|
||||||
|
// 确保滚动到底部
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.scrollToBottom();
|
||||||
|
setTimeout(() => {
|
||||||
|
const blockCount = this.$el && this.$el.querySelectorAll
|
||||||
|
? this.$el.querySelectorAll('.message-block').length
|
||||||
|
: 'N/A';
|
||||||
|
debugLog('[Messages] DOM 渲染统计', {
|
||||||
|
blocks: blockCount,
|
||||||
|
conversationId: this.currentConversationId
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
287
static/src/app/methods/message.ts
Normal file
287
static/src/app/methods/message.ts
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { debugLog } from './common';
|
||||||
|
|
||||||
|
export const messageMethods = {
|
||||||
|
handleSendOrStop() {
|
||||||
|
if (this.composerBusy) {
|
||||||
|
this.stopTask();
|
||||||
|
} else {
|
||||||
|
this.sendMessage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sendMessage() {
|
||||||
|
if (this.streamingUi || !this.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.mediaUploading) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '上传中',
|
||||||
|
message: '请等待图片/视频上传完成后再发送',
|
||||||
|
type: 'info'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = (this.inputMessage || '').trim();
|
||||||
|
const images = Array.isArray(this.selectedImages) ? this.selectedImages.slice(0, 9) : [];
|
||||||
|
const videos = Array.isArray(this.selectedVideos) ? this.selectedVideos.slice(0, 1) : [];
|
||||||
|
const hasText = text.length > 0;
|
||||||
|
const hasImages = images.length > 0;
|
||||||
|
const hasVideos = videos.length > 0;
|
||||||
|
|
||||||
|
if (!hasText && !hasImages && !hasVideos) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quotaType = this.thinkingMode ? 'thinking' : 'fast';
|
||||||
|
if (this.isQuotaExceeded(quotaType)) {
|
||||||
|
this.showQuotaToast({ type: quotaType });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasImages && !['qwen3-vl-plus', 'kimi-k2.5'].includes(this.currentModelKey)) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '当前模型不支持图片',
|
||||||
|
message: '请切换到 Qwen3.5 或 Kimi-k2.5 再发送图片',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasVideos && !['qwen3-vl-plus', 'kimi-k2.5'].includes(this.currentModelKey)) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '当前模型不支持视频',
|
||||||
|
message: '请切换到 Qwen3.5 或 Kimi-k2.5 后再发送视频',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasVideos && hasImages) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '请勿同时发送',
|
||||||
|
message: '视频与图片需分开发送,每条仅包含一种媒体',
|
||||||
|
type: 'warning'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasVideos) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '视频处理中',
|
||||||
|
message: '读取视频需要较长时间,请耐心等待',
|
||||||
|
type: 'info',
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = text;
|
||||||
|
const isCommand = hasText && !hasImages && !hasVideos && message.startsWith('/');
|
||||||
|
if (isCommand) {
|
||||||
|
this.socket.emit('send_command', { command: message });
|
||||||
|
this.inputClearMessage();
|
||||||
|
this.inputClearSelectedImages();
|
||||||
|
this.inputClearSelectedVideos();
|
||||||
|
this.autoResizeInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasBlank = this.isConversationBlank();
|
||||||
|
if (wasBlank) {
|
||||||
|
this.blankHeroExiting = true;
|
||||||
|
this.blankHeroActive = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.blankHeroExiting = false;
|
||||||
|
this.blankHeroActive = false;
|
||||||
|
}, 320);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记任务进行中,直到任务完成或用户手动停止
|
||||||
|
this.taskInProgress = true;
|
||||||
|
this.chatAddUserMessage(message, images, videos);
|
||||||
|
this.socket.emit('send_message', { message: message, images, videos, conversation_id: this.currentConversationId });
|
||||||
|
if (typeof this.monitorShowPendingReply === 'function') {
|
||||||
|
this.monitorShowPendingReply();
|
||||||
|
}
|
||||||
|
this.inputClearMessage();
|
||||||
|
this.inputClearSelectedImages();
|
||||||
|
this.inputClearSelectedVideos();
|
||||||
|
this.inputSetImagePickerOpen(false);
|
||||||
|
this.inputSetVideoPickerOpen(false);
|
||||||
|
this.inputSetLineCount(1);
|
||||||
|
this.inputSetMultiline(false);
|
||||||
|
if (hasImages) {
|
||||||
|
this.conversationHasImages = true;
|
||||||
|
this.conversationHasVideos = false;
|
||||||
|
}
|
||||||
|
if (hasVideos) {
|
||||||
|
this.conversationHasVideos = true;
|
||||||
|
this.conversationHasImages = false;
|
||||||
|
}
|
||||||
|
if (this.autoScrollEnabled) {
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
this.autoResizeInput();
|
||||||
|
|
||||||
|
// 发送消息后延迟更新当前上下文Token(关键修复:恢复原逻辑)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.currentConversationId) {
|
||||||
|
this.updateCurrentContextTokens();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 新增:停止任务方法
|
||||||
|
stopTask() {
|
||||||
|
const canStop = this.composerBusy && !this.stopRequested;
|
||||||
|
if (!canStop) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldDropToolEvents = this.streamingUi;
|
||||||
|
this.stopRequested = true;
|
||||||
|
this.dropToolEvents = shouldDropToolEvents;
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.emit('stop_task');
|
||||||
|
debugLog('发送停止请求');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即清理前端状态,避免出现“不可输入也不可停止”的卡死状态
|
||||||
|
this.clearPendingTools('user_stop');
|
||||||
|
this.streamingMessage = false;
|
||||||
|
this.taskInProgress = false;
|
||||||
|
this.forceUnlockMonitor('user_stop');
|
||||||
|
},
|
||||||
|
|
||||||
|
async clearChat() {
|
||||||
|
const confirmed = await this.confirmAction({
|
||||||
|
title: '清除对话',
|
||||||
|
message: '确定要清除所有对话记录吗?该操作不可撤销。',
|
||||||
|
confirmText: '清除',
|
||||||
|
cancelText: '取消'
|
||||||
|
});
|
||||||
|
if (confirmed) {
|
||||||
|
this.socket.emit('send_command', { command: '/clear' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async compressConversation() {
|
||||||
|
if (!this.currentConversationId) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '无法压缩',
|
||||||
|
message: '当前没有可压缩的对话。',
|
||||||
|
type: 'info'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.compressing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = await this.confirmAction({
|
||||||
|
title: '压缩对话',
|
||||||
|
message: '确定要压缩当前对话记录吗?压缩后会生成新的对话副本。',
|
||||||
|
confirmText: '压缩',
|
||||||
|
cancelText: '取消'
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.compressing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/conversations/${this.currentConversationId}/compress`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result.success) {
|
||||||
|
const newId = result.compressed_conversation_id;
|
||||||
|
if (newId) {
|
||||||
|
this.currentConversationId = newId;
|
||||||
|
}
|
||||||
|
debugLog('对话压缩完成:', result);
|
||||||
|
} else {
|
||||||
|
const message = result.message || result.error || '压缩失败';
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '压缩失败',
|
||||||
|
message,
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('压缩对话异常:', error);
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '压缩对话异常',
|
||||||
|
message: error.message || '请稍后重试',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.compressing = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sendAutoUserMessage(text) {
|
||||||
|
const message = (text || '').trim();
|
||||||
|
if (!message || !this.isConnected) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const quotaType = this.thinkingMode ? 'thinking' : 'fast';
|
||||||
|
if (this.isQuotaExceeded(quotaType)) {
|
||||||
|
this.showQuotaToast({ type: quotaType });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.taskInProgress = true;
|
||||||
|
this.chatAddUserMessage(message, []);
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.emit('send_message', {
|
||||||
|
message,
|
||||||
|
images: [],
|
||||||
|
conversation_id: this.currentConversationId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof this.monitorShowPendingReply === 'function') {
|
||||||
|
this.monitorShowPendingReply();
|
||||||
|
}
|
||||||
|
if (this.autoScrollEnabled) {
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
this.autoResizeInput();
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.currentConversationId) {
|
||||||
|
this.updateCurrentContextTokens();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
autoResizeInput() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const textarea = this.getComposerElement('stadiumInput');
|
||||||
|
if (!textarea || !(textarea instanceof HTMLTextAreaElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previousHeight = textarea.offsetHeight;
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
const computedStyle = window.getComputedStyle(textarea);
|
||||||
|
const lineHeight = parseFloat(computedStyle.lineHeight || '20') || 20;
|
||||||
|
const maxHeight = lineHeight * 6;
|
||||||
|
const targetHeight = Math.min(textarea.scrollHeight, maxHeight);
|
||||||
|
this.inputSetLineCount(Math.max(1, Math.round(targetHeight / lineHeight)));
|
||||||
|
this.inputSetMultiline(targetHeight > lineHeight * 1.4);
|
||||||
|
if (Math.abs(targetHeight - previousHeight) <= 0.5) {
|
||||||
|
textarea.style.height = `${targetHeight}px`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
textarea.style.height = `${previousHeight}px`;
|
||||||
|
void textarea.offsetHeight;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
textarea.style.height = `${targetHeight}px`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
24
static/src/app/methods/monitor.ts
Normal file
24
static/src/app/methods/monitor.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { useEasterEgg } from '../../composables/useEasterEgg';
|
||||||
|
|
||||||
|
export const monitorMethods = {
|
||||||
|
async handleEasterEggPayload(payload) {
|
||||||
|
const controller = useEasterEgg();
|
||||||
|
await controller.handlePayload(payload, this);
|
||||||
|
},
|
||||||
|
|
||||||
|
async startEasterEggEffect(effectName, payload = {}) {
|
||||||
|
const controller = useEasterEgg();
|
||||||
|
await controller.startEffect(effectName, payload, this);
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyEasterEggEffect(forceImmediate = false) {
|
||||||
|
const controller = useEasterEgg();
|
||||||
|
return controller.destroyEffect(forceImmediate);
|
||||||
|
},
|
||||||
|
|
||||||
|
finishEasterEggCleanup() {
|
||||||
|
const controller = useEasterEgg();
|
||||||
|
controller.finishCleanup();
|
||||||
|
}
|
||||||
|
};
|
||||||
276
static/src/app/methods/resources.ts
Normal file
276
static/src/app/methods/resources.ts
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import {
|
||||||
|
formatTokenCount,
|
||||||
|
formatBytes,
|
||||||
|
formatPercentage,
|
||||||
|
formatRate,
|
||||||
|
formatResetTime,
|
||||||
|
formatQuotaValue,
|
||||||
|
quotaTypeLabel,
|
||||||
|
buildQuotaResetSummary,
|
||||||
|
isQuotaExceeded as isQuotaExceededUtil,
|
||||||
|
buildQuotaToastMessage
|
||||||
|
} from '../../utils/formatters';
|
||||||
|
|
||||||
|
export const resourceMethods = {
|
||||||
|
hasContainerStats() {
|
||||||
|
return !!(
|
||||||
|
this.containerStatus &&
|
||||||
|
this.containerStatus.mode === 'docker' &&
|
||||||
|
this.containerStatus.stats
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
containerStatusClass() {
|
||||||
|
if (!this.containerStatus) {
|
||||||
|
return 'status-pill--host';
|
||||||
|
}
|
||||||
|
if (this.containerStatus.mode !== 'docker') {
|
||||||
|
return 'status-pill--host';
|
||||||
|
}
|
||||||
|
const rawStatus = (
|
||||||
|
this.containerStatus.state &&
|
||||||
|
(this.containerStatus.state.status || this.containerStatus.state.Status)
|
||||||
|
) || '';
|
||||||
|
const status = String(rawStatus).toLowerCase();
|
||||||
|
if (status.includes('running')) {
|
||||||
|
return 'status-pill--running';
|
||||||
|
}
|
||||||
|
if (status.includes('paused')) {
|
||||||
|
return 'status-pill--stopped';
|
||||||
|
}
|
||||||
|
if (status.includes('exited') || status.includes('dead')) {
|
||||||
|
return 'status-pill--stopped';
|
||||||
|
}
|
||||||
|
return 'status-pill--running';
|
||||||
|
},
|
||||||
|
|
||||||
|
containerStatusText() {
|
||||||
|
if (!this.containerStatus) {
|
||||||
|
return '未知';
|
||||||
|
}
|
||||||
|
if (this.containerStatus.mode !== 'docker') {
|
||||||
|
return '宿主机模式';
|
||||||
|
}
|
||||||
|
const rawStatus = (
|
||||||
|
this.containerStatus.state &&
|
||||||
|
(this.containerStatus.state.status || this.containerStatus.state.Status)
|
||||||
|
) || '';
|
||||||
|
const status = String(rawStatus).toLowerCase();
|
||||||
|
if (status.includes('running')) {
|
||||||
|
return '运行中';
|
||||||
|
}
|
||||||
|
if (status.includes('paused')) {
|
||||||
|
return '已暂停';
|
||||||
|
}
|
||||||
|
if (status.includes('exited') || status.includes('dead')) {
|
||||||
|
return '已停止';
|
||||||
|
}
|
||||||
|
return rawStatus || '容器模式';
|
||||||
|
},
|
||||||
|
|
||||||
|
formatTime(value) {
|
||||||
|
if (!value) {
|
||||||
|
return '未知时间';
|
||||||
|
}
|
||||||
|
let date;
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
date = new Date(value);
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
const parsed = Date.parse(value);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
date = new Date(parsed);
|
||||||
|
} else {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isNaN(numeric)) {
|
||||||
|
date = new Date(numeric);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (value instanceof Date) {
|
||||||
|
date = value;
|
||||||
|
}
|
||||||
|
if (!date || Number.isNaN(date.getTime())) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - date.getTime();
|
||||||
|
if (diff < 60000) {
|
||||||
|
return '刚刚';
|
||||||
|
}
|
||||||
|
if (diff < 3600000) {
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
return `${mins} 分钟前`;
|
||||||
|
}
|
||||||
|
if (diff < 86400000) {
|
||||||
|
const hours = Math.floor(diff / 3600000);
|
||||||
|
return `${hours} 小时前`;
|
||||||
|
}
|
||||||
|
const formatter = new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
return formatter.format(date);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateCurrentContextTokens() {
|
||||||
|
await this.resourceUpdateCurrentContextTokens(this.currentConversationId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchConversationTokenStatistics() {
|
||||||
|
await this.resourceFetchConversationTokenStatistics(this.currentConversationId);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleTokenPanel() {
|
||||||
|
this.resourceToggleTokenPanel();
|
||||||
|
},
|
||||||
|
|
||||||
|
applyStatusSnapshot(status) {
|
||||||
|
this.resourceApplyStatusSnapshot(status);
|
||||||
|
if (status && typeof status.thinking_mode !== 'undefined') {
|
||||||
|
this.thinkingMode = !!status.thinking_mode;
|
||||||
|
}
|
||||||
|
if (status && typeof status.run_mode === 'string') {
|
||||||
|
this.runMode = status.run_mode;
|
||||||
|
} else if (status && typeof status.thinking_mode !== 'undefined') {
|
||||||
|
this.runMode = status.thinking_mode ? 'thinking' : 'fast';
|
||||||
|
}
|
||||||
|
if (status && typeof status.model_key === 'string') {
|
||||||
|
this.modelSet(status.model_key);
|
||||||
|
}
|
||||||
|
if (status && typeof status.has_images !== 'undefined') {
|
||||||
|
this.conversationHasImages = !!status.has_images;
|
||||||
|
}
|
||||||
|
if (status && typeof status.has_videos !== 'undefined') {
|
||||||
|
this.conversationHasVideos = !!status.has_videos;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateContainerStatus(status) {
|
||||||
|
this.resourceUpdateContainerStatus(status);
|
||||||
|
},
|
||||||
|
|
||||||
|
pollContainerStats() {
|
||||||
|
return this.resourcePollContainerStats();
|
||||||
|
},
|
||||||
|
|
||||||
|
startContainerStatsPolling() {
|
||||||
|
this.resourceStartContainerStatsPolling();
|
||||||
|
},
|
||||||
|
|
||||||
|
stopContainerStatsPolling() {
|
||||||
|
this.resourceStopContainerStatsPolling();
|
||||||
|
},
|
||||||
|
|
||||||
|
pollProjectStorage() {
|
||||||
|
return this.resourcePollProjectStorage();
|
||||||
|
},
|
||||||
|
|
||||||
|
startProjectStoragePolling() {
|
||||||
|
this.resourceStartProjectStoragePolling();
|
||||||
|
},
|
||||||
|
|
||||||
|
stopProjectStoragePolling() {
|
||||||
|
this.resourceStopProjectStoragePolling();
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchUsageQuota() {
|
||||||
|
return this.resourceFetchUsageQuota();
|
||||||
|
},
|
||||||
|
|
||||||
|
startUsageQuotaPolling() {
|
||||||
|
this.resourceStartUsageQuotaPolling();
|
||||||
|
},
|
||||||
|
|
||||||
|
stopUsageQuotaPolling() {
|
||||||
|
this.resourceStopUsageQuotaPolling();
|
||||||
|
},
|
||||||
|
|
||||||
|
async downloadFile(path) {
|
||||||
|
if (!path) {
|
||||||
|
this.fileHideContextMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = `/api/download/file?path=${encodeURIComponent(path)}`;
|
||||||
|
const name = path.split('/').pop() || 'file';
|
||||||
|
await this.downloadResource(url, name);
|
||||||
|
},
|
||||||
|
|
||||||
|
async downloadFolder(path) {
|
||||||
|
if (!path) {
|
||||||
|
this.fileHideContextMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = `/api/download/folder?path=${encodeURIComponent(path)}`;
|
||||||
|
const segments = path.split('/').filter(Boolean);
|
||||||
|
const folderName = segments.length ? segments.pop() : 'folder';
|
||||||
|
await this.downloadResource(url, `${folderName}.zip`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async downloadResource(url, filename) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = response.statusText;
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
message = errorData.error || errorData.message || message;
|
||||||
|
} catch (err) {
|
||||||
|
message = await response.text();
|
||||||
|
}
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '下载失败',
|
||||||
|
message: message || '无法完成下载',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const downloadName = filename || 'download';
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const href = URL.createObjectURL(blob);
|
||||||
|
link.href = href;
|
||||||
|
link.download = downloadName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(href);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('下载失败:', error);
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '下载失败',
|
||||||
|
message: error.message || String(error),
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.fileHideContextMenu();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatTokenCount,
|
||||||
|
formatBytes,
|
||||||
|
formatPercentage,
|
||||||
|
formatRate,
|
||||||
|
formatResetTime,
|
||||||
|
formatQuotaValue,
|
||||||
|
quotaTypeLabel,
|
||||||
|
|
||||||
|
quotaResetSummary() {
|
||||||
|
return buildQuotaResetSummary(this.usageQuota);
|
||||||
|
},
|
||||||
|
|
||||||
|
isQuotaExceeded(type) {
|
||||||
|
return isQuotaExceededUtil(this.usageQuota, type);
|
||||||
|
},
|
||||||
|
|
||||||
|
showQuotaToast(payload) {
|
||||||
|
if (!payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const type = payload.type || 'fast';
|
||||||
|
const message = buildQuotaToastMessage(type, this.usageQuota, payload.reset_at);
|
||||||
|
this.uiShowQuotaToastMessage(message, type);
|
||||||
|
}
|
||||||
|
};
|
||||||
0
static/src/app/methods/route.ts
Normal file
0
static/src/app/methods/route.ts
Normal file
170
static/src/app/methods/search.ts
Normal file
170
static/src/app/methods/search.ts
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
const SEARCH_INITIAL_BATCH = 100;
|
||||||
|
const SEARCH_MORE_BATCH = 50;
|
||||||
|
const SEARCH_PREVIEW_LIMIT = 20;
|
||||||
|
|
||||||
|
export const searchMethods = {
|
||||||
|
handleSidebarSearchInput(value) {
|
||||||
|
this.searchQuery = value;
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSidebarSearchSubmit(value) {
|
||||||
|
this.searchQuery = value;
|
||||||
|
const trimmed = String(value || '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
this.exitConversationSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.startConversationSearch(trimmed);
|
||||||
|
},
|
||||||
|
|
||||||
|
exitConversationSearch() {
|
||||||
|
this.searchActive = false;
|
||||||
|
this.searchInProgress = false;
|
||||||
|
this.searchMoreAvailable = false;
|
||||||
|
this.searchOffset = 0;
|
||||||
|
this.searchTotal = 0;
|
||||||
|
this.searchResults = [];
|
||||||
|
this.searchActiveQuery = '';
|
||||||
|
this.searchResultIdSet = new Set();
|
||||||
|
this.conversationsOffset = 0;
|
||||||
|
this.loadConversationsList();
|
||||||
|
},
|
||||||
|
|
||||||
|
async startConversationSearch(query) {
|
||||||
|
const trimmed = String(query || '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestSeq = ++this.searchRequestSeq;
|
||||||
|
this.searchActiveQuery = trimmed;
|
||||||
|
this.searchActive = true;
|
||||||
|
this.searchInProgress = true;
|
||||||
|
this.searchMoreAvailable = false;
|
||||||
|
this.searchOffset = 0;
|
||||||
|
this.searchTotal = 0;
|
||||||
|
this.searchResults = [];
|
||||||
|
this.searchResultIdSet = new Set();
|
||||||
|
await this.searchNextConversationBatch(SEARCH_INITIAL_BATCH, requestSeq);
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadMoreSearchResults() {
|
||||||
|
if (!this.searchActive || this.searchInProgress || !this.searchMoreAvailable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestSeq = this.searchRequestSeq;
|
||||||
|
this.searchInProgress = true;
|
||||||
|
await this.searchNextConversationBatch(SEARCH_MORE_BATCH, requestSeq);
|
||||||
|
},
|
||||||
|
|
||||||
|
async searchNextConversationBatch(batchSize, requestSeq) {
|
||||||
|
const query = this.searchActiveQuery;
|
||||||
|
if (!query) {
|
||||||
|
if (requestSeq === this.searchRequestSeq) {
|
||||||
|
this.searchInProgress = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/conversations?limit=${batchSize}&offset=${this.searchOffset}`);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
if (requestSeq !== this.searchRequestSeq) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.success) {
|
||||||
|
console.error('搜索对话失败:', payload.error || payload.message);
|
||||||
|
this.searchInProgress = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = payload.data || {};
|
||||||
|
const conversations = data.conversations || [];
|
||||||
|
if (!this.searchTotal) {
|
||||||
|
this.searchTotal = data.total || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const conv of conversations) {
|
||||||
|
if (requestSeq !== this.searchRequestSeq) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.matchConversation(conv, query, requestSeq);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.searchOffset += conversations.length;
|
||||||
|
this.searchMoreAvailable = this.searchOffset < (this.searchTotal || 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索对话异常:', error);
|
||||||
|
} finally {
|
||||||
|
if (requestSeq === this.searchRequestSeq) {
|
||||||
|
this.searchInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async matchConversation(conversation, query, requestSeq) {
|
||||||
|
if (!conversation || !conversation.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.searchResultIdSet && this.searchResultIdSet.has(conversation.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const firstSentence = await this.getConversationFirstUserSentence(conversation.id, requestSeq);
|
||||||
|
if (requestSeq !== this.searchRequestSeq) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const queryLower = String(query || '').toLowerCase();
|
||||||
|
const combined = `${conversation.title || ''} ${firstSentence || ''}`.toLowerCase();
|
||||||
|
if (queryLower && combined.includes(queryLower)) {
|
||||||
|
this.searchResults.push(conversation);
|
||||||
|
this.searchResultIdSet.add(conversation.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getConversationFirstUserSentence(conversationId, requestSeq) {
|
||||||
|
if (!conversationId) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (this.searchPreviewCache && Object.prototype.hasOwnProperty.call(this.searchPreviewCache, conversationId)) {
|
||||||
|
return this.searchPreviewCache[conversationId];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/conversations/${conversationId}/review_preview?limit=${SEARCH_PREVIEW_LIMIT}`);
|
||||||
|
const payload = await resp.json();
|
||||||
|
if (requestSeq !== this.searchRequestSeq) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const lines = payload?.data?.preview || [];
|
||||||
|
let firstUserLine = '';
|
||||||
|
for (const line of lines) {
|
||||||
|
if (typeof line === 'string' && line.startsWith('user:')) {
|
||||||
|
firstUserLine = line.slice('user:'.length).trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const firstSentence = this.extractFirstSentence(firstUserLine);
|
||||||
|
const cached = firstSentence || firstUserLine || '';
|
||||||
|
if (!this.searchPreviewCache) {
|
||||||
|
this.searchPreviewCache = {};
|
||||||
|
}
|
||||||
|
this.searchPreviewCache[conversationId] = cached;
|
||||||
|
return cached;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取对话预览失败:', error);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
extractFirstSentence(text) {
|
||||||
|
if (!text) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const normalized = String(text).replace(/\s+/g, ' ').trim();
|
||||||
|
const match = normalized.match(/(.+?[。!?.!?])/);
|
||||||
|
if (match) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
};
|
||||||
247
static/src/app/methods/tooling.ts
Normal file
247
static/src/app/methods/tooling.ts
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { usePersonalizationStore } from '../../stores/personalization';
|
||||||
|
import { usePolicyStore } from '../../stores/policy';
|
||||||
|
import {
|
||||||
|
getToolIcon,
|
||||||
|
getToolAnimationClass,
|
||||||
|
getToolStatusText as baseGetToolStatusText,
|
||||||
|
getToolDescription,
|
||||||
|
cloneToolArguments,
|
||||||
|
buildToolLabel,
|
||||||
|
formatSearchTopic,
|
||||||
|
formatSearchTime,
|
||||||
|
formatSearchDomains,
|
||||||
|
getLanguageClass
|
||||||
|
} from '../../utils/chatDisplay';
|
||||||
|
import { debugLog } from './common';
|
||||||
|
|
||||||
|
export const toolingMethods = {
|
||||||
|
toolCategoryIcon(categoryId) {
|
||||||
|
return this.toolCategoryIcons[categoryId] || 'settings';
|
||||||
|
},
|
||||||
|
|
||||||
|
findMessageByAction(action) {
|
||||||
|
if (!action) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (const message of this.messages) {
|
||||||
|
if (!message.actions) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (message.actions.includes(action)) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
cleanupStaleToolActions() {
|
||||||
|
this.messages.forEach(msg => {
|
||||||
|
if (!msg.actions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
msg.actions.forEach(action => {
|
||||||
|
if (action.type !== 'tool' || !action.tool) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (['running', 'preparing'].includes(action.tool.status)) {
|
||||||
|
action.tool.status = 'stale';
|
||||||
|
action.tool.message = action.tool.message || '已被新的响应中断';
|
||||||
|
this.toolUnregisterAction(action);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.preparingTools.clear();
|
||||||
|
this.toolActionIndex.clear();
|
||||||
|
},
|
||||||
|
|
||||||
|
hasPendingToolActions() {
|
||||||
|
const mapHasEntries = map => map && typeof map.size === 'number' && map.size > 0;
|
||||||
|
if (mapHasEntries(this.preparingTools) || mapHasEntries(this.activeTools)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(this.messages)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.messages.some(msg => {
|
||||||
|
if (!msg || msg.role !== 'assistant' || !Array.isArray(msg.actions)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return msg.actions.some(action => {
|
||||||
|
if (!action || action.type !== 'tool' || !action.tool) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (action.tool.awaiting_content) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const status =
|
||||||
|
typeof action.tool.status === 'string'
|
||||||
|
? action.tool.status.toLowerCase()
|
||||||
|
: '';
|
||||||
|
return !status || ['preparing', 'running', 'pending', 'queued'].includes(status);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
maybeResetStreamingState(reason = 'unspecified') {
|
||||||
|
if (!this.streamingMessage) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.hasPendingToolActions()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.streamingMessage = false;
|
||||||
|
this.stopRequested = false;
|
||||||
|
debugLog('流式状态已结束', { reason });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
applyToolSettingsSnapshot(categories) {
|
||||||
|
if (!Array.isArray(categories)) {
|
||||||
|
console.warn('[ToolSettings] Snapshot skipped: categories not array', categories);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalized = categories.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
label: item.label || item.id,
|
||||||
|
enabled: !!item.enabled,
|
||||||
|
tools: Array.isArray(item.tools) ? item.tools : [],
|
||||||
|
locked: !!item.locked,
|
||||||
|
locked_state: typeof item.locked_state === 'boolean' ? item.locked_state : null
|
||||||
|
}));
|
||||||
|
debugLog('[ToolSettings] Snapshot applied', {
|
||||||
|
received: categories.length,
|
||||||
|
normalized,
|
||||||
|
anyEnabled: normalized.some(cat => cat.enabled),
|
||||||
|
toolExamples: normalized.slice(0, 3)
|
||||||
|
});
|
||||||
|
this.toolSetSettings(normalized);
|
||||||
|
this.toolSetSettingsLoading(false);
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadToolSettings(force = false) {
|
||||||
|
if (!this.isConnected && !force) {
|
||||||
|
debugLog('[ToolSettings] Skip load: disconnected & not forced');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.toolSettingsLoading) {
|
||||||
|
debugLog('[ToolSettings] Skip load: already loading');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!force && this.toolSettings.length > 0) {
|
||||||
|
debugLog('[ToolSettings] Skip load: already have settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debugLog('[ToolSettings] Fetch start', { force, hasConnection: this.isConnected });
|
||||||
|
this.toolSetSettingsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/tool-settings');
|
||||||
|
const data = await response.json();
|
||||||
|
debugLog('[ToolSettings] Fetch response', { status: response.status, data });
|
||||||
|
if (response.ok && data.success && Array.isArray(data.categories)) {
|
||||||
|
this.applyToolSettingsSnapshot(data.categories);
|
||||||
|
} else {
|
||||||
|
console.warn('获取工具设置失败:', data);
|
||||||
|
this.toolSetSettingsLoading(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取工具设置异常:', error);
|
||||||
|
this.toolSetSettingsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateToolCategory(categoryId, enabled) {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.toolSettingsLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const policyStore = usePolicyStore();
|
||||||
|
if (policyStore.isCategoryLocked(categoryId)) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '无法修改',
|
||||||
|
message: '该工具类别被管理员强制设置',
|
||||||
|
type: 'warning'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previousSnapshot = this.toolSettings.map((item) => ({ ...item }));
|
||||||
|
const updatedSettings = this.toolSettings.map((item) => {
|
||||||
|
if (item.id === categoryId) {
|
||||||
|
return { ...item, enabled };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
this.toolSetSettings(updatedSettings);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/tool-settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
category: categoryId,
|
||||||
|
enabled
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok && data.success && Array.isArray(data.categories)) {
|
||||||
|
this.applyToolSettingsSnapshot(data.categories);
|
||||||
|
} else {
|
||||||
|
console.warn('更新工具设置失败:', data);
|
||||||
|
if (data && (data.message || data.error)) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '无法切换工具',
|
||||||
|
message: data.message || data.error,
|
||||||
|
type: 'warning'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.toolSetSettings(previousSnapshot);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新工具设置异常:', error);
|
||||||
|
this.toolSetSettings(previousSnapshot);
|
||||||
|
}
|
||||||
|
this.toolSetSettingsLoading(false);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleToolMenu() {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.isPolicyBlocked('block_tool_toggle', '工具启用/禁用已被管理员锁定')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.modeMenuOpen = false;
|
||||||
|
this.modelMenuOpen = false;
|
||||||
|
const nextState = this.inputToggleToolMenu();
|
||||||
|
if (nextState) {
|
||||||
|
this.inputSetSettingsOpen(false);
|
||||||
|
if (!this.quickMenuOpen) {
|
||||||
|
this.inputOpenQuickMenu();
|
||||||
|
}
|
||||||
|
this.loadToolSettings(true);
|
||||||
|
} else {
|
||||||
|
this.inputSetToolMenuOpen(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getToolIcon,
|
||||||
|
getToolAnimationClass,
|
||||||
|
getToolStatusText(tool: any) {
|
||||||
|
const personalization = usePersonalizationStore();
|
||||||
|
const intentEnabled =
|
||||||
|
personalization?.form?.tool_intent_enabled ??
|
||||||
|
personalization?.tool_intent_enabled ??
|
||||||
|
true;
|
||||||
|
return baseGetToolStatusText(tool, { intentEnabled });
|
||||||
|
},
|
||||||
|
getToolDescription,
|
||||||
|
cloneToolArguments,
|
||||||
|
buildToolLabel,
|
||||||
|
formatSearchTopic,
|
||||||
|
formatSearchTime,
|
||||||
|
formatSearchDomains,
|
||||||
|
getLanguageClass
|
||||||
|
};
|
||||||
1174
static/src/app/methods/ui.ts
Normal file
1174
static/src/app/methods/ui.ts
Normal file
File diff suppressed because it is too large
Load Diff
404
static/src/app/methods/upload.ts
Normal file
404
static/src/app/methods/upload.ts
Normal file
@ -0,0 +1,404 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { usePolicyStore } from '../../stores/policy';
|
||||||
|
|
||||||
|
export const uploadMethods = {
|
||||||
|
triggerFileUpload() {
|
||||||
|
if (this.uploading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const input = this.getComposerElement('fileUploadInput');
|
||||||
|
if (input) {
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleFileSelected(files) {
|
||||||
|
const policyStore = usePolicyStore();
|
||||||
|
if (policyStore.uiBlocks?.block_upload) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '上传被禁用',
|
||||||
|
message: '已被管理员禁用上传功能',
|
||||||
|
type: 'warning'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.uploadHandleSelected(files);
|
||||||
|
},
|
||||||
|
|
||||||
|
normalizeLocalFiles(files) {
|
||||||
|
if (!files) return [];
|
||||||
|
const list = Array.isArray(files) ? files : Array.from(files);
|
||||||
|
return list.filter(Boolean);
|
||||||
|
},
|
||||||
|
|
||||||
|
isImageFile(file) {
|
||||||
|
const name = file?.name || '';
|
||||||
|
const type = file?.type || '';
|
||||||
|
return type.startsWith('image/') || /\.(png|jpe?g|webp|gif|bmp|svg)$/i.test(name);
|
||||||
|
},
|
||||||
|
|
||||||
|
isVideoFile(file) {
|
||||||
|
const name = file?.name || '';
|
||||||
|
const type = file?.type || '';
|
||||||
|
return type.startsWith('video/') || /\.(mp4|mov|m4v|webm|avi|mkv|flv|mpg|mpeg)$/i.test(name);
|
||||||
|
},
|
||||||
|
|
||||||
|
upsertImageEntry(path, filename) {
|
||||||
|
if (!path) return;
|
||||||
|
const name = filename || path.split('/').pop() || path;
|
||||||
|
const list = Array.isArray(this.imageEntries) ? this.imageEntries : [];
|
||||||
|
if (list.some((item) => item.path === path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.imageEntries = [{ name, path }, ...list];
|
||||||
|
},
|
||||||
|
|
||||||
|
upsertVideoEntry(path, filename) {
|
||||||
|
if (!path) return;
|
||||||
|
const name = filename || path.split('/').pop() || path;
|
||||||
|
const list = Array.isArray(this.videoEntries) ? this.videoEntries : [];
|
||||||
|
if (list.some((item) => item.path === path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.videoEntries = [{ name, path }, ...list];
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleLocalImageFiles(files) {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.mediaUploading) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '上传中',
|
||||||
|
message: '请等待当前图片上传完成',
|
||||||
|
type: 'info'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = this.normalizeLocalFiles(files);
|
||||||
|
if (!list.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existingCount = Array.isArray(this.selectedImages) ? this.selectedImages.length : 0;
|
||||||
|
const remaining = Math.max(0, 9 - existingCount);
|
||||||
|
if (!remaining) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '已达上限',
|
||||||
|
message: '最多只能选择 9 张图片',
|
||||||
|
type: 'warning'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const valid = list.filter((file) => this.isImageFile(file));
|
||||||
|
if (!valid.length) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '无法上传',
|
||||||
|
message: '仅支持图片文件',
|
||||||
|
type: 'warning'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (valid.length < list.length) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '已忽略',
|
||||||
|
message: '已跳过非图片文件',
|
||||||
|
type: 'info'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const limited = valid.slice(0, remaining);
|
||||||
|
if (valid.length > remaining) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '已超出数量',
|
||||||
|
message: `最多还能添加 ${remaining} 张图片,已自动截断`,
|
||||||
|
type: 'warning'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const uploaded = await this.uploadBatchFiles(limited, {
|
||||||
|
markUploading: true,
|
||||||
|
markMediaUploading: true
|
||||||
|
});
|
||||||
|
if (!uploaded.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploaded.forEach((item) => {
|
||||||
|
if (!item?.path) return;
|
||||||
|
this.inputAddSelectedImage(item.path);
|
||||||
|
this.upsertImageEntry(item.path, item.filename);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleLocalVideoFiles(files) {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.mediaUploading) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '上传中',
|
||||||
|
message: '请等待当前视频上传完成',
|
||||||
|
type: 'info'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = this.normalizeLocalFiles(files);
|
||||||
|
if (!list.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const valid = list.filter((file) => this.isVideoFile(file));
|
||||||
|
if (!valid.length) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '无法上传',
|
||||||
|
message: '仅支持视频文件',
|
||||||
|
type: 'warning'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (valid.length < list.length) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '已忽略',
|
||||||
|
message: '已跳过非视频文件',
|
||||||
|
type: 'info'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (valid.length > 1) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '视频数量过多',
|
||||||
|
message: '一次只能选择 1 个视频,已使用第一个',
|
||||||
|
type: 'warning'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const [file] = valid;
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uploaded = await this.uploadBatchFiles([file], {
|
||||||
|
markUploading: true,
|
||||||
|
markMediaUploading: true
|
||||||
|
});
|
||||||
|
const [item] = uploaded;
|
||||||
|
if (!item?.path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.inputSetSelectedVideos([item.path]);
|
||||||
|
this.inputClearSelectedImages();
|
||||||
|
this.upsertVideoEntry(item.path, item.filename);
|
||||||
|
},
|
||||||
|
|
||||||
|
async openImagePicker() {
|
||||||
|
if (!['qwen3-vl-plus', 'kimi-k2.5'].includes(this.currentModelKey)) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '当前模型不支持图片',
|
||||||
|
message: '请选择 Qwen3.5 或 Kimi-k2.5 后再发送图片',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.closeQuickMenu();
|
||||||
|
this.inputSetImagePickerOpen(true);
|
||||||
|
await this.loadWorkspaceImages();
|
||||||
|
},
|
||||||
|
|
||||||
|
closeImagePicker() {
|
||||||
|
this.inputSetImagePickerOpen(false);
|
||||||
|
},
|
||||||
|
|
||||||
|
async openVideoPicker() {
|
||||||
|
if (!['qwen3-vl-plus', 'kimi-k2.5'].includes(this.currentModelKey)) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '当前模型不支持视频',
|
||||||
|
message: '请切换到 Qwen3.5 或 Kimi-k2.5 后再发送视频',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.closeQuickMenu();
|
||||||
|
this.inputSetVideoPickerOpen(true);
|
||||||
|
await this.loadWorkspaceVideos();
|
||||||
|
},
|
||||||
|
|
||||||
|
closeVideoPicker() {
|
||||||
|
this.inputSetVideoPickerOpen(false);
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadWorkspaceImages() {
|
||||||
|
this.imageLoading = true;
|
||||||
|
try {
|
||||||
|
const entries = await this.fetchAllImageEntries('');
|
||||||
|
this.imageEntries = entries;
|
||||||
|
if (!entries.length) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '未找到图片',
|
||||||
|
message: '工作区内没有可用的图片文件',
|
||||||
|
type: 'info'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载图片列表失败', error);
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '加载图片失败',
|
||||||
|
message: error?.message || '请稍后重试',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.imageLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchAllImageEntries(startPath = '') {
|
||||||
|
const queue: string[] = [startPath || ''];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const results: Array<{ name: string; path: string }> = [];
|
||||||
|
const exts = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.svg']);
|
||||||
|
const maxFolders = 120;
|
||||||
|
|
||||||
|
while (queue.length && visited.size < maxFolders) {
|
||||||
|
const path = queue.shift() || '';
|
||||||
|
if (visited.has(path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visited.add(path);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/gui/files/entries?path=${encodeURIComponent(path)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json' }
|
||||||
|
});
|
||||||
|
const data = await resp.json().catch(() => null);
|
||||||
|
if (!data?.success) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const items = Array.isArray(data?.data?.items) ? data.data.items : [];
|
||||||
|
for (const item of items) {
|
||||||
|
const rawPath =
|
||||||
|
item?.path ||
|
||||||
|
[path, item?.name].filter(Boolean).join('/').replace(/\\/g, '/').replace(/\/{2,}/g, '/');
|
||||||
|
const type = String(item?.type || '').toLowerCase();
|
||||||
|
if (type === 'directory' || type === 'folder') {
|
||||||
|
queue.push(rawPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ext =
|
||||||
|
String(item?.extension || '').toLowerCase() ||
|
||||||
|
(rawPath.includes('.') ? `.${rawPath.split('.').pop()?.toLowerCase()}` : '');
|
||||||
|
if (exts.has(ext)) {
|
||||||
|
results.push({
|
||||||
|
name: item?.name || rawPath.split('/').pop() || rawPath,
|
||||||
|
path: rawPath
|
||||||
|
});
|
||||||
|
if (results.length >= 400) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('遍历文件夹失败', path, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchAllVideoEntries(startPath = '') {
|
||||||
|
const queue: string[] = [startPath || ''];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const results: Array<{ name: string; path: string }> = [];
|
||||||
|
const exts = new Set(['.mp4', '.mov', '.mkv', '.avi', '.webm']);
|
||||||
|
const maxFolders = 120;
|
||||||
|
|
||||||
|
while (queue.length && visited.size < maxFolders) {
|
||||||
|
const path = queue.shift() || '';
|
||||||
|
if (visited.has(path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visited.add(path);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/gui/files/entries?path=${encodeURIComponent(path)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json' }
|
||||||
|
});
|
||||||
|
const data = await resp.json().catch(() => null);
|
||||||
|
if (!data?.success) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const items = Array.isArray(data?.data?.items) ? data.data.items : [];
|
||||||
|
for (const item of items) {
|
||||||
|
const rawPath =
|
||||||
|
item?.path ||
|
||||||
|
[path, item?.name].filter(Boolean).join('/').replace(/\\/g, '/').replace(/\/{2,}/g, '/');
|
||||||
|
const type = String(item?.type || '').toLowerCase();
|
||||||
|
if (type === 'directory' || type === 'folder') {
|
||||||
|
queue.push(rawPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ext =
|
||||||
|
String(item?.extension || '').toLowerCase() ||
|
||||||
|
(rawPath.includes('.') ? `.${rawPath.split('.').pop()?.toLowerCase()}` : '');
|
||||||
|
if (exts.has(ext)) {
|
||||||
|
results.push({
|
||||||
|
name: item?.name || rawPath.split('/').pop() || rawPath,
|
||||||
|
path: rawPath
|
||||||
|
});
|
||||||
|
if (results.length >= 200) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('遍历文件夹失败', path, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadWorkspaceVideos() {
|
||||||
|
this.videoLoading = true;
|
||||||
|
try {
|
||||||
|
const entries = await this.fetchAllVideoEntries('');
|
||||||
|
this.videoEntries = entries;
|
||||||
|
if (!entries.length) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '未找到视频',
|
||||||
|
message: '工作区内没有可用的视频文件',
|
||||||
|
type: 'info'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载视频列表失败', error);
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '加载视频失败',
|
||||||
|
message: error?.message || '请稍后重试',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.videoLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleImagesConfirmed(list) {
|
||||||
|
this.inputSetSelectedImages(Array.isArray(list) ? list : []);
|
||||||
|
this.inputSetImagePickerOpen(false);
|
||||||
|
},
|
||||||
|
handleRemoveImage(path) {
|
||||||
|
this.inputRemoveSelectedImage(path);
|
||||||
|
},
|
||||||
|
handleVideosConfirmed(list) {
|
||||||
|
const arr = Array.isArray(list) ? list.slice(0, 1) : [];
|
||||||
|
this.inputSetSelectedVideos(arr);
|
||||||
|
this.inputSetVideoPickerOpen(false);
|
||||||
|
if (arr.length) {
|
||||||
|
this.inputClearSelectedImages();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleRemoveVideo(path) {
|
||||||
|
this.inputRemoveSelectedVideo(path);
|
||||||
|
},
|
||||||
|
|
||||||
|
handleQuickUpload() {
|
||||||
|
if (this.uploading || !this.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.isPolicyBlocked('block_upload', '上传功能已被管理员禁用')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.triggerFileUpload();
|
||||||
|
}
|
||||||
|
};
|
||||||
90
static/src/app/state.ts
Normal file
90
static/src/app/state.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { ICONS, TOOL_CATEGORY_ICON_MAP } from '../utils/icons';
|
||||||
|
|
||||||
|
export function dataState() {
|
||||||
|
return {
|
||||||
|
// 路由相关
|
||||||
|
initialRouteResolved: false,
|
||||||
|
dropToolEvents: false,
|
||||||
|
|
||||||
|
// 工具状态跟踪
|
||||||
|
preparingTools: new Map(),
|
||||||
|
activeTools: new Map(),
|
||||||
|
toolActionIndex: new Map(),
|
||||||
|
toolStacks: new Map(),
|
||||||
|
// 当前任务是否仍在进行中(用于保持输入区的“停止”状态)
|
||||||
|
taskInProgress: false,
|
||||||
|
// 记录上一次成功加载历史的对话ID,防止初始化阶段重复加载导致动画播放两次
|
||||||
|
lastHistoryLoadedConversationId: null,
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 对话管理相关状态
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// 搜索功能
|
||||||
|
// ==========================================
|
||||||
|
searchRequestSeq: 0,
|
||||||
|
searchActiveQuery: '',
|
||||||
|
searchResultIdSet: new Set(),
|
||||||
|
searchPreviewCache: {},
|
||||||
|
|
||||||
|
// Token统计相关状态(修复版)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// 对话压缩状态
|
||||||
|
compressing: false,
|
||||||
|
skipConversationLoadedEvent: false,
|
||||||
|
skipConversationHistoryReload: false,
|
||||||
|
_scrollListenerReady: false,
|
||||||
|
historyLoading: false,
|
||||||
|
historyLoadingFor: null,
|
||||||
|
historyLoadSeq: 0,
|
||||||
|
blankHeroActive: false,
|
||||||
|
blankHeroExiting: false,
|
||||||
|
blankWelcomeText: '',
|
||||||
|
lastBlankConversationId: null,
|
||||||
|
// 对话标题打字效果
|
||||||
|
titleTypingText: '',
|
||||||
|
titleTypingTarget: '',
|
||||||
|
titleTypingTimer: null,
|
||||||
|
titleReady: false,
|
||||||
|
suppressTitleTyping: false,
|
||||||
|
headerMenuOpen: false,
|
||||||
|
blankWelcomePool: [
|
||||||
|
'有什么可以帮忙的?',
|
||||||
|
'想了解些热点吗?',
|
||||||
|
'要我帮你完成作业吗?',
|
||||||
|
'整点代码?',
|
||||||
|
'随便聊点什么?',
|
||||||
|
'想让我帮你整理一下思路吗?',
|
||||||
|
'要不要我帮你写个小工具?',
|
||||||
|
'发我一句话,我来接着做。'
|
||||||
|
],
|
||||||
|
mobileViewportQuery: null,
|
||||||
|
modeMenuOpen: false,
|
||||||
|
modelMenuOpen: false,
|
||||||
|
imageEntries: [],
|
||||||
|
imageLoading: false,
|
||||||
|
videoEntries: [],
|
||||||
|
videoLoading: false,
|
||||||
|
conversationHasImages: false,
|
||||||
|
conversationHasVideos: false,
|
||||||
|
conversationListRequestSeq: 0,
|
||||||
|
conversationListRefreshToken: 0,
|
||||||
|
|
||||||
|
// 工具控制菜单
|
||||||
|
icons: ICONS,
|
||||||
|
toolCategoryIcons: TOOL_CATEGORY_ICON_MAP,
|
||||||
|
|
||||||
|
// 对话回顾
|
||||||
|
reviewDialogOpen: false,
|
||||||
|
reviewSelectedConversationId: null,
|
||||||
|
reviewSubmitting: false,
|
||||||
|
reviewPreviewLines: [],
|
||||||
|
reviewPreviewLoading: false,
|
||||||
|
reviewPreviewError: null,
|
||||||
|
reviewPreviewLimit: 20,
|
||||||
|
reviewSendToModel: true,
|
||||||
|
reviewGeneratedPath: null
|
||||||
|
};
|
||||||
|
}
|
||||||
62
static/src/app/watchers.ts
Normal file
62
static/src/app/watchers.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { debugLog, traceLog } from './methods/common';
|
||||||
|
|
||||||
|
export const watchers = {
|
||||||
|
inputMessage() {
|
||||||
|
this.autoResizeInput();
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
deep: true,
|
||||||
|
handler() {
|
||||||
|
this.refreshBlankHeroState();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
currentConversationTitle(newVal, oldVal) {
|
||||||
|
const target = (newVal && newVal.trim()) || '';
|
||||||
|
if (this.suppressTitleTyping) {
|
||||||
|
this.titleTypingText = target;
|
||||||
|
this.titleTypingTarget = target;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previous = (oldVal && oldVal.trim()) || (this.titleTypingText && this.titleTypingText.trim()) || '';
|
||||||
|
const placeholderPrev = !previous || previous === '新对话';
|
||||||
|
const placeholderTarget = !target || target === '新对话';
|
||||||
|
const animate = placeholderPrev && !placeholderTarget; // 仅从空/占位切换到真实标题时动画
|
||||||
|
this.startTitleTyping(target, { animate });
|
||||||
|
},
|
||||||
|
currentConversationId: {
|
||||||
|
immediate: false,
|
||||||
|
handler(newValue, oldValue) {
|
||||||
|
debugLog('currentConversationId 变化', { oldValue, newValue, skipConversationHistoryReload: this.skipConversationHistoryReload });
|
||||||
|
traceLog('watch:currentConversationId', {
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
skipConversationHistoryReload: this.skipConversationHistoryReload,
|
||||||
|
historyLoading: this.historyLoading,
|
||||||
|
historyLoadingFor: this.historyLoadingFor,
|
||||||
|
historyLoadSeq: this.historyLoadSeq
|
||||||
|
});
|
||||||
|
this.refreshBlankHeroState();
|
||||||
|
this.logMessageState('watch:currentConversationId', { oldValue, newValue, skipConversationHistoryReload: this.skipConversationHistoryReload });
|
||||||
|
if (!newValue || typeof newValue !== 'string' || newValue.startsWith('temp_')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.skipConversationHistoryReload) {
|
||||||
|
this.skipConversationHistoryReload = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (oldValue && newValue === oldValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.fetchAndDisplayHistory();
|
||||||
|
this.fetchConversationTokenStatistics();
|
||||||
|
this.updateCurrentContextTokens();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fileTree: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newValue) {
|
||||||
|
this.monitorSyncDesktop(newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user