Refactor UUID generation, remove unused logger and encryption utilities, and clean up request handling
- Updated `generateUUID` function for improved readability and performance. - Deleted `logger.ts`, `other.ts`, `request.ts`, `storage.ts`, `tansParams.ts`, and `validate.ts` as they were no longer needed. - Simplified TypeScript configuration by removing unnecessary paths and aliases. - Enhanced Vite configuration for better project structure and maintainability.
This commit is contained in:
@@ -1,75 +0,0 @@
|
||||
<template>
|
||||
<!-- macOS: just drag region -->
|
||||
<div v-if="platform === 'darwin'" class="drag-region h-10 shrink-0 border-b" :class="borderColorClass" style="background: transparent;" />
|
||||
|
||||
<!-- Linux: no custom title bar -->
|
||||
<template v-else-if="platform !== 'win32'" />
|
||||
|
||||
<!-- Windows: custom controls -->
|
||||
<div v-else class="drag-region flex h-10 shrink-0 items-center justify-end border-b" :class="borderColorClass" style="background: transparent;">
|
||||
<div class="no-drag flex h-full">
|
||||
<button
|
||||
@click="handleMinimize"
|
||||
class="flex h-full w-11 items-center justify-center hover:bg-[#999] hover:text-white transition-colors"
|
||||
:class="iconColorClass"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minus class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="handleMaximize"
|
||||
class="flex h-full w-11 items-center justify-center hover:bg-[#999] hover:text-white transition-colors"
|
||||
:class="iconColorClass"
|
||||
:title="maximized ? 'Restore' : 'Maximize'"
|
||||
>
|
||||
<Copy v-if="maximized" class="h-3.5 w-3.5" />
|
||||
<Square v-else class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="flex h-full w-11 items-center justify-center hover:bg-[#ff0000] hover:text-white transition-colors"
|
||||
:class="iconColorClass"
|
||||
title="Close"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { Minus, Square, Copy, X } from '@lucide/vue'
|
||||
|
||||
const props = defineProps<{
|
||||
variant?: 'default' | 'light'
|
||||
}>()
|
||||
|
||||
const platform = (window as any).api?.platform ?? ''
|
||||
const maximized = ref(false)
|
||||
|
||||
const iconColorClass = computed(() =>
|
||||
props.variant === 'light' ? 'text-white' : 'text-[#525866] dark:text-gray-300'
|
||||
)
|
||||
|
||||
const borderColorClass = computed(() =>
|
||||
props.variant === 'light' ? 'border-b-white/30' : 'border-b-gray-300 dark:border-gray-700'
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
maximized.value = await (window as any).api.windowIsMaximized()
|
||||
})
|
||||
|
||||
const handleMinimize = () => {
|
||||
(window as any).api.windowMinimize()
|
||||
}
|
||||
|
||||
const handleMaximize = async () => {
|
||||
await (window as any).api.windowMaximize()
|
||||
maximized.value = await (window as any).api.windowIsMaximized()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
(window as any).api.windowClose()
|
||||
}
|
||||
</script>
|
||||
@@ -1,27 +0,0 @@
|
||||
<template>
|
||||
<div class="bg-color dark:bg-[#0f0f10]! h-screen flex flex-col">
|
||||
<title-bar v-if="platform !== 'linux'" />
|
||||
|
||||
<main
|
||||
class="box-border w-full flex pt-2 pb-2 pl-2"
|
||||
:style="{ height: platform === 'linux' ? '100vh' : 'calc(100vh - 40px)' }"
|
||||
>
|
||||
<div class="flex-1 flex">
|
||||
<slot />
|
||||
</div>
|
||||
<SideMenus />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="Layout">
|
||||
import { computed } from 'vue'
|
||||
import SideMenus from '@src/components/SideMenus/index.vue'
|
||||
import TitleBar from '@components/layout/TitleBar/index.vue'
|
||||
|
||||
const platform = computed(() => (window as any).api?.platform ?? '')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -1,43 +0,0 @@
|
||||
<template>
|
||||
<template v-if="slots.default()[0].el">
|
||||
<slot></slot>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span :title="content">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { logger } from '@utils/logger'
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'NativeTooltip' });
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const slots = defineSlots()
|
||||
|
||||
if (slots?.default?.().length > 1) {
|
||||
logger.warn('NativeTooltip only support one slot.')
|
||||
}
|
||||
|
||||
const updateTooltipContent = (content: string) => {
|
||||
const defaultSlot = slots?.default?.();
|
||||
|
||||
if (defaultSlot) {
|
||||
const slotElement = defaultSlot[0]?.el
|
||||
|
||||
if (slotElement && slotElement instanceof HTMLElement) {
|
||||
slotElement.title = content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => updateTooltipContent(props.content))
|
||||
|
||||
watch(() => props.content, (val: string) => updateTooltipContent(val));
|
||||
</script>
|
||||
@@ -1,49 +0,0 @@
|
||||
<template>
|
||||
<el-pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" class="mt-[15px]"
|
||||
:pager-count="5" :page-sizes="pageSizes" :current-page="current" background :page-size="size" :layout="layout"
|
||||
:total="total">
|
||||
</el-pagination>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="pagination">
|
||||
const emit = defineEmits(['sizeChange', 'currentChange']);
|
||||
|
||||
const props = defineProps({
|
||||
current: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 10,
|
||||
},
|
||||
total: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
pageSizes: {
|
||||
type: Array as () => number[],
|
||||
default: () => {
|
||||
return [10, 20, 50, 100, 200];
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
type: String,
|
||||
default: 'total, sizes, prev, pager, next, jumper',
|
||||
},
|
||||
});
|
||||
// 分页改变
|
||||
const sizeChangeHandle = (val: number) => {
|
||||
emit('sizeChange', val);
|
||||
};
|
||||
// 分页改变
|
||||
const currentChangeHandle = (val: number) => {
|
||||
emit('currentChange', val);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.el-pagination__sizes) {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,47 +0,0 @@
|
||||
<template>
|
||||
<div class="w-[80px] h-full box-border flex flex-col items-center pb-[8px]">
|
||||
<div :class="['flex flex-col gap-[16px]', { 'mt-auto mb-[8px] shrink-1': item.url === '/setting' }]"
|
||||
v-for="(item) in menus" :key="item.id">
|
||||
<div :class="['cursor-pointer flex flex-col items-center justify-center']" @click="handleClick(item)">
|
||||
<div :class="['box-border rounded-[16px] w-[48px] h-[48px] flex flex-col items-center justify-center hover:bg-white dark:hover:bg-[#222225]', { 'bg-white dark:bg-[#222225]': item.id === currentId }]">
|
||||
<component :is="item.icon" :color="item.id === currentId ? item.activeColor : item.color"
|
||||
:class="['w-[32px] h-[32px]]']" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-[14px] mt-[4px] mb-[8px] hover:text-[#2B7FFF]"
|
||||
:style="{ color: item.id === currentId ? item.activeColor : item.color }">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-[48px] h-[48px] rounded-full overflow-hidden">
|
||||
<img class="w-full h-full object-cover" src="@assets/images/login/black_logo.png" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { menus, type MenuItem } from '../../constant/menus'
|
||||
|
||||
const currentId = ref(1)
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
watch(() => route.path, (newPath) => {
|
||||
const currentMenu = menus.find(m => newPath.startsWith(m.url))
|
||||
if (currentMenu) {
|
||||
currentId.value = currentMenu.id
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const handleClick = async (item: MenuItem) => {
|
||||
console.log("🚀 ~ handleClick ~ item:", item)
|
||||
currentId.value = item.id
|
||||
router.push(item.url)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -1,44 +0,0 @@
|
||||
<!--
|
||||
* @Author: kongbeiwu lishaohua-520@qq.com
|
||||
* @Date: 2025-12-21 23:02:06
|
||||
* @LastEditors: kongbeiwu lishaohua-520@qq.com
|
||||
* @LastEditTime: 2026-01-04 10:35:46
|
||||
* @FilePath: /project/zn-ai/src/renderer/components/TitleSection/index.vue
|
||||
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
|
||||
-->
|
||||
<template>
|
||||
<div class="flex justify-between items-center box-border border-b-[1px] border-b-[#E5E8EE] dark:border-b-gray-700 mb-[20px] pb-[20px]">
|
||||
<div class="flex">
|
||||
<div class="flex items-center">
|
||||
<el-icon v-if="attrs.onBackTo" @click="emits('back-to', true)" size="18px" color="#525866" class="mr-[15px] cursor-pointer dark:!text-gray-400">
|
||||
<ArrowLeftBold />
|
||||
</el-icon>
|
||||
<span class="text-[24px] font-500 text-[#171717] dark:text-gray-100 leading-[32px] mr-[8px]">
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-[12px] font-400 text-[#99A0AE] dark:text-gray-500 leading-[16px]" style="align-self: flex-end;">
|
||||
{{ desc }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<slot name="right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAttrs, defineEmits } from 'vue';
|
||||
import { ArrowLeftBold } from '@element-plus/icons-vue';
|
||||
interface TitleSectionProps {
|
||||
title?: string
|
||||
desc?: string
|
||||
}
|
||||
|
||||
withDefaults(
|
||||
defineProps<TitleSectionProps>(),
|
||||
{ title: '', desc: '' }
|
||||
)
|
||||
const emits = defineEmits(["update:back-to"]);
|
||||
const attrs = useAttrs();
|
||||
</script>
|
||||
125
src/components/chat/ChatComposer.tsx
Normal file
125
src/components/chat/ChatComposer.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useRef } from 'react';
|
||||
import type { AttachedFileMeta } from '../../shared/chat-model';
|
||||
|
||||
type ChatComposerProps = {
|
||||
value: string;
|
||||
isSending: boolean;
|
||||
attachments: AttachedFileMeta[];
|
||||
error?: string | null;
|
||||
onChange: (value: string) => void;
|
||||
onSend: () => void;
|
||||
onStop: () => void;
|
||||
onAttach: (files: File[]) => void | Promise<void>;
|
||||
onRemoveAttachment: (index: number) => void;
|
||||
onDismissError?: () => void;
|
||||
};
|
||||
|
||||
export default function ChatComposer({
|
||||
value,
|
||||
isSending,
|
||||
attachments,
|
||||
error,
|
||||
onChange,
|
||||
onSend,
|
||||
onStop,
|
||||
onAttach,
|
||||
onRemoveAttachment,
|
||||
onDismissError,
|
||||
}: ChatComposerProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
return (
|
||||
<div className="border-t border-[#edf2f7] px-6 py-4 dark:border-[#2a2a2d]">
|
||||
<div className="rounded-[18px] border border-[#dfeaf6] bg-white p-4 shadow-[0_10px_30px_rgba(15,23,42,0.04)] dark:border-[#2a2a2d] dark:bg-[#1f1f22]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1 flex h-9 w-9 flex-none items-center justify-center rounded-full bg-[#eff6ff] text-xs font-bold text-[#2B7FFF] dark:bg-[#222225]">
|
||||
AI
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{error ? (
|
||||
<div className="mb-3 flex items-center justify-between gap-3 rounded-[14px] border border-[#fecaca] bg-[#fff1f2] px-4 py-3 text-sm text-[#b91c1c] dark:border-[#7f1d1d] dark:bg-[#2d1618] dark:text-[#fca5a5]">
|
||||
<span className="min-w-0 flex-1">{error}</span>
|
||||
{onDismissError ? (
|
||||
<button type="button" className="text-xs transition-colors hover:text-[#7f1d1d]" onClick={onDismissError}>
|
||||
关闭
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
className="min-h-[120px] w-full resize-none rounded-[14px] border border-[#BEDBFF] bg-[#f8fbff] px-4 py-3 text-sm text-[#171717] outline-none transition-colors placeholder:text-[#99A0AE] focus:border-[#2B7FFF] dark:border-[#2a2a2d] dark:bg-[#232327] dark:text-gray-100 dark:placeholder:text-gray-500"
|
||||
placeholder="输入消息,按 Enter 发送,Shift + Enter 换行"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (isSending) {
|
||||
onStop();
|
||||
return;
|
||||
}
|
||||
onSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{attachments.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{attachments.map((attachment, index) => (
|
||||
<div
|
||||
key={attachment.filePath || `${attachment.fileName}-${index}`}
|
||||
className="flex items-center gap-2 rounded-full border border-[#E5E8EE] bg-white px-3 py-1.5 text-xs text-[#525866] dark:border-[#2a2a2d] dark:bg-[#232327] dark:text-gray-300"
|
||||
>
|
||||
<span className="max-w-[180px] truncate">{attachment.fileName}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[#99A0AE] transition-colors hover:text-[#ef4444]"
|
||||
onClick={() => onRemoveAttachment(index)}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
hidden
|
||||
multiple
|
||||
type="file"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length > 0) {
|
||||
void onAttach(files);
|
||||
}
|
||||
event.currentTarget.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-[#E5E8EE] px-3 py-1.5 text-xs text-[#525866] transition-colors hover:border-[#2B7FFF] hover:text-[#2B7FFF] dark:border-[#2a2a2d] dark:text-gray-300"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
添加附件
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-[#E5E8EE] px-3 py-1.5 text-xs text-[#525866] transition-colors hover:border-[#2B7FFF] hover:text-[#2B7FFF] dark:border-[#2a2a2d] dark:text-gray-300"
|
||||
onClick={isSending ? onStop : onSend}
|
||||
>
|
||||
{isSending ? '停止生成' : '发送消息'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-[#E5E8EE] px-3 py-1.5 text-xs text-[#525866] transition-colors hover:border-[#2B7FFF] hover:text-[#2B7FFF] dark:border-[#2a2a2d] dark:text-gray-300"
|
||||
onClick={() => onChange('')}
|
||||
>
|
||||
清空输入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
src/components/chat/ChatHistoryPanel.tsx
Normal file
108
src/components/chat/ChatHistoryPanel.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { ChatHistoryBucket } from './types';
|
||||
|
||||
type ChatHistoryPanelProps = {
|
||||
buckets: ChatHistoryBucket[];
|
||||
selectedConversationId?: string;
|
||||
loading?: boolean;
|
||||
onNewChat?: () => void;
|
||||
onSelectConversation?: (conversationId: string) => void;
|
||||
onRenameConversation?: (conversationId: string) => void;
|
||||
onDeleteConversation?: (conversationId: string) => void;
|
||||
};
|
||||
|
||||
export default function ChatHistoryPanel({
|
||||
buckets,
|
||||
selectedConversationId,
|
||||
loading,
|
||||
onNewChat,
|
||||
onSelectConversation,
|
||||
onRenameConversation,
|
||||
onDeleteConversation,
|
||||
}: ChatHistoryPanelProps) {
|
||||
const hasSessions = buckets.some((bucket) => bucket.sessions.length > 0);
|
||||
|
||||
return (
|
||||
<aside className="flex h-full min-h-0 w-full flex-none flex-col transition-all duration-300 md:w-[220px] lg:w-[230px]">
|
||||
<div className="flex h-full min-h-0 flex-col rounded-[20px] bg-white p-2 shadow-[0_10px_30px_rgba(15,23,42,0.06)] dark:bg-[#1b1b1d]">
|
||||
<div className="flex items-center gap-3 px-2 py-1.5">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[#eff6ff] text-sm font-bold text-[#2B7FFF] dark:bg-[#222225]">
|
||||
YN
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-[#171717] dark:text-gray-100">YINIAN</div>
|
||||
<div className="truncate text-xs text-[#99A0AE] dark:text-gray-500">对话历史</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 flex items-center justify-center gap-2 rounded-lg border border-[#E5E8EE] bg-white px-3 py-2.5 text-sm text-[#171717] shadow-sm transition-colors hover:border-[#2B7FFF] hover:text-[#2B7FFF] dark:border-[#2a2a2d] dark:bg-[#1f1f22] dark:text-gray-100 dark:hover:border-[#2B7FFF]"
|
||||
onClick={onNewChat}
|
||||
>
|
||||
<span className="text-lg leading-none">+</span>
|
||||
<span>新对话</span>
|
||||
</button>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-1.5 py-3">
|
||||
{loading ? (
|
||||
<div className="rounded-lg border border-dashed border-[#dfeaf6] bg-[#f8fbff] px-4 py-6 text-sm text-[#99A0AE] dark:border-[#2a2a2d] dark:bg-[#1f1f22] dark:text-gray-400">
|
||||
正在加载会话历史...
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && !hasSessions ? (
|
||||
<div className="rounded-lg border border-dashed border-[#dfeaf6] bg-[#f8fbff] px-4 py-6 text-sm text-[#99A0AE] dark:border-[#2a2a2d] dark:bg-[#1f1f22] dark:text-gray-400">
|
||||
还没有对话,点击上方“新对话”开始。
|
||||
</div>
|
||||
) : null}
|
||||
{buckets.map((bucket) => (
|
||||
<div key={bucket.key} className="mb-3 last:mb-0">
|
||||
<div className="px-2 pb-1 text-[11px] font-medium tracking-tight text-gray-400">{bucket.label}</div>
|
||||
<ul className="list-none space-y-2">
|
||||
{bucket.sessions.map((session) => {
|
||||
const isActive = session.conversationId === selectedConversationId;
|
||||
|
||||
return (
|
||||
<li key={session.conversationId}>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
'flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm transition-colors',
|
||||
isActive
|
||||
? 'border border-[#E5E8EE] bg-white text-[#171717] shadow-sm dark:border-[#2a2a2d] dark:bg-[#1f1f22] dark:text-gray-100'
|
||||
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700/50',
|
||||
].join(' ')}
|
||||
onClick={() => onSelectConversation?.(session.conversationId)}
|
||||
>
|
||||
<span className="h-2 w-2 flex-none rounded-full bg-[#BEDBFF]" />
|
||||
<span className="min-w-0 flex-1 truncate">{session.title}</span>
|
||||
<span className="shrink-0 text-[11px] text-[#99A0AE] dark:text-gray-500">{session.updatedAt}</span>
|
||||
</button>
|
||||
{isActive ? (
|
||||
<div className="mt-1 flex items-center justify-end gap-2 pr-2 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
className="text-[#99A0AE] transition-colors hover:text-[#2B7FFF] dark:text-gray-500"
|
||||
onClick={() => onRenameConversation?.(session.conversationId)}
|
||||
>
|
||||
重命名
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[#99A0AE] transition-colors hover:text-[#ef4444] dark:text-gray-500"
|
||||
onClick={() => onDeleteConversation?.(session.conversationId)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
92
src/components/chat/ChatMessageList.tsx
Normal file
92
src/components/chat/ChatMessageList.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { ChatMessageItem } from './types';
|
||||
|
||||
type ChatMessageListProps = {
|
||||
messages: ChatMessageItem[];
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export default function ChatMessageList({ messages, loading }: ChatMessageListProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden px-6 py-6">
|
||||
<div ref={containerRef} className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto pr-1">
|
||||
{loading ? (
|
||||
<div className="rounded-[18px] border border-dashed border-[#BEDBFF] bg-[#EFF6FF] px-4 py-3 text-sm text-[#525866] dark:border-[#2a2a2d] dark:bg-[#1f1f22] dark:text-gray-400">
|
||||
正在加载会话内容...
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && messages.length === 0 ? (
|
||||
<div className="rounded-[18px] border border-dashed border-[#BEDBFF] bg-[#EFF6FF] px-4 py-6 text-sm leading-7 text-[#525866] dark:border-[#2a2a2d] dark:bg-[#1f1f22] dark:text-gray-400">
|
||||
输入你的问题开始一段新对话,现有会话和流式响应都会直接显示在这里。
|
||||
</div>
|
||||
) : null}
|
||||
{messages.map((message) => (
|
||||
<article
|
||||
key={message.id}
|
||||
className={[
|
||||
'flex gap-3 rounded-[18px] border p-4',
|
||||
message.role === 'assistant'
|
||||
? 'border-[#E5E8EE] bg-[#f8fbff] dark:border-[#2a2a2d] dark:bg-[#1f1f22]'
|
||||
: 'border-[#dfeaf6] bg-white dark:border-[#2a2a2d] dark:bg-[#232327]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-full bg-[#eff6ff] text-sm font-bold text-[#2B7FFF] dark:bg-[#222225]">
|
||||
{message.role === 'assistant' ? 'AI' : 'ME'}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-[#171717] dark:text-gray-100">{message.name}</div>
|
||||
<div className="text-xs text-[#99A0AE] dark:text-gray-500">{message.time}</div>
|
||||
</div>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-[#525866] dark:text-gray-300">{message.content}</p>
|
||||
{message.attachments && message.attachments.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{message.attachments.map((attachment, index) => {
|
||||
const attachmentKey = attachment.filePath || `${attachment.fileName}-${index}`;
|
||||
const isImage = attachment.mimeType.startsWith('image/') && Boolean(attachment.preview);
|
||||
|
||||
if (isImage && attachment.preview) {
|
||||
return (
|
||||
<div
|
||||
key={attachmentKey}
|
||||
className="overflow-hidden rounded-xl border border-[#E5E8EE] bg-white dark:border-[#2a2a2d] dark:bg-[#232327]"
|
||||
>
|
||||
<img
|
||||
alt={attachment.fileName}
|
||||
className="h-24 w-24 object-cover"
|
||||
src={attachment.preview}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={attachmentKey}
|
||||
className="rounded-full border border-[#E5E8EE] px-3 py-1.5 text-xs text-[#525866] dark:border-[#2a2a2d] dark:text-gray-300"
|
||||
>
|
||||
{attachment.fileName}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{message.isStreaming ? (
|
||||
<div className="mt-3 text-xs text-[#2B7FFF]">正在生成回复...</div>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
src/components/chat/TaskBoard.tsx
Normal file
114
src/components/chat/TaskBoard.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { TaskItem } from './types';
|
||||
|
||||
type TaskBoardProps = {
|
||||
activeTab: 'pending' | 'completed';
|
||||
pendingItems: TaskItem[];
|
||||
completedItems: TaskItem[];
|
||||
onTabChange: (tab: 'pending' | 'completed') => void;
|
||||
onRemoveTask?: (taskId: string) => void;
|
||||
onRetryTask?: (taskId: string) => void;
|
||||
currentDateLabel?: string;
|
||||
currentTime?: string;
|
||||
};
|
||||
|
||||
export default function TaskBoard({
|
||||
activeTab,
|
||||
pendingItems,
|
||||
completedItems,
|
||||
onTabChange,
|
||||
onRemoveTask,
|
||||
onRetryTask,
|
||||
currentDateLabel,
|
||||
currentTime,
|
||||
}: TaskBoardProps) {
|
||||
const items = activeTab === 'pending' ? pendingItems : completedItems;
|
||||
|
||||
return (
|
||||
<aside className="h-full min-h-0 w-full flex-none lg:w-[288px]">
|
||||
<div className="flex h-full min-h-0 flex-col rounded-[20px] bg-white p-3 shadow-[0_10px_30px_rgba(15,23,42,0.06)] dark:bg-[#1b1b1d]">
|
||||
<div className="flex rounded-[10px] border border-[#BEDBFF] bg-[#EFF6FF] p-1 dark:border-[#2a2a2d] dark:bg-[#1f1f22]">
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
'flex-1 rounded-[8px] px-3 py-2 text-sm transition-colors',
|
||||
activeTab === 'pending'
|
||||
? 'bg-white text-[#2B7FFF] shadow-sm dark:bg-[#1f1f22]'
|
||||
: 'text-[#525866] dark:text-gray-300',
|
||||
].join(' ')}
|
||||
onClick={() => onTabChange('pending')}
|
||||
>
|
||||
待处理{pendingItems.length > 0 ? `(${pendingItems.length})` : ''}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
'flex-1 rounded-[8px] px-3 py-2 text-sm transition-colors',
|
||||
activeTab === 'completed'
|
||||
? 'bg-white text-[#2B7FFF] shadow-sm dark:bg-[#1f1f22]'
|
||||
: 'text-[#525866] dark:text-gray-300',
|
||||
].join(' ')}
|
||||
onClick={() => onTabChange('completed')}
|
||||
>
|
||||
已处理{completedItems.length > 0 ? `(${completedItems.length})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(currentDateLabel || currentTime) ? (
|
||||
<div className="flex items-center justify-between px-1 pb-3 pt-3 text-[13px] text-[#99A0AE] dark:text-gray-500">
|
||||
<span>{currentDateLabel || '执行时段'}</span>
|
||||
<span>{currentTime || '--'}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 overflow-y-auto">
|
||||
{items.length > 0 ? (
|
||||
items.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="relative flex gap-3 rounded-[12px] border border-[#dfeaf6] bg-white p-3.5 transition-colors hover:bg-[#F5F7FA] dark:border-[#2a2a2d] dark:bg-[#1f1f22] dark:hover:bg-[#2a2a2d]"
|
||||
>
|
||||
{(onRemoveTask || onRetryTask) ? (
|
||||
<div className="absolute right-2 top-2 flex items-center gap-2">
|
||||
{onRetryTask && (item.status === 'failed' || item.status === 'partial_failed') ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] text-[#2B7FFF] transition-colors hover:text-[#1d4ed8]"
|
||||
onClick={() => onRetryTask(item.id)}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
) : null}
|
||||
{onRemoveTask ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] text-[#99A0AE] transition-colors hover:text-[#ef4444]"
|
||||
onClick={() => onRemoveTask(item.removeTaskId || item.id)}
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-lg border border-dashed border-[#9fc0e8] bg-[#EFF6FF] text-[23px] text-[#3b82f6] dark:border-gray-700 dark:bg-[#222225]">
|
||||
•
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-[#171717] dark:text-gray-100">{item.title}</div>
|
||||
<div className="mt-1.5 text-[13px] leading-6 text-[#9aa5b1] dark:text-gray-400">{item.description}</div>
|
||||
{item.meta ? (
|
||||
<div className="mt-1 text-[11px] text-[#99A0AE] dark:text-gray-500">{item.meta}</div>
|
||||
) : null}
|
||||
<div className="mt-2 text-xs font-medium text-[#2B7FFF]">{item.status}</div>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-[12px] border border-dashed border-[#dfeaf6] bg-[#f8fbff] px-4 py-6 text-sm text-[#99A0AE] dark:border-[#2a2a2d] dark:bg-[#1f1f22] dark:text-gray-400">
|
||||
当前分类下暂无任务卡片。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
5
src/components/chat/index.ts
Normal file
5
src/components/chat/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as ChatComposer } from './ChatComposer';
|
||||
export { default as ChatHistoryPanel } from './ChatHistoryPanel';
|
||||
export { default as ChatMessageList } from './ChatMessageList';
|
||||
export { default as TaskBoard } from './TaskBoard';
|
||||
export type { ChatHistoryBucket, ChatMessageItem, TaskItem, TaskTabValue } from './types';
|
||||
33
src/components/chat/types.ts
Normal file
33
src/components/chat/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { AttachedFileMeta } from '../../shared/chat-model';
|
||||
|
||||
export type TaskTabValue = 'pending' | 'completed';
|
||||
|
||||
export type ChatHistoryBucket = {
|
||||
key: string;
|
||||
label: string;
|
||||
sessions: Array<{
|
||||
conversationId: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ChatMessageItem = {
|
||||
id: string;
|
||||
role: 'assistant' | 'user';
|
||||
name: string;
|
||||
time: string;
|
||||
content: string;
|
||||
attachments?: AttachedFileMeta[];
|
||||
isStreaming?: boolean;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
export type TaskItem = {
|
||||
id: string;
|
||||
removeTaskId?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
meta?: string;
|
||||
};
|
||||
23
src/components/layout/MainLayout.tsx
Normal file
23
src/components/layout/MainLayout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Sidebar from './Sidebar';
|
||||
import TitleBar from './TitleBar';
|
||||
|
||||
export default function MainLayout() {
|
||||
const platform = (window as any).api?.platform ?? '';
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:!bg-[#0f0f10] h-screen flex flex-col">
|
||||
<TitleBar />
|
||||
|
||||
<main
|
||||
className="box-border w-full flex pt-2 pb-2 pl-2"
|
||||
style={{ height: platform === 'linux' ? '100vh' : 'calc(100vh - 40px)' }}
|
||||
>
|
||||
<div className="flex-1 flex min-w-0 min-h-0">
|
||||
<Outlet />
|
||||
</div>
|
||||
<Sidebar />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
src/components/layout/Sidebar.tsx
Normal file
67
src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { NAV_ITEMS, normalizeWorkspacePath } from '../../router/routes';
|
||||
|
||||
const MENU_MARKS: Record<string, string> = {
|
||||
'/home': '首',
|
||||
'/knowledge': '知',
|
||||
'/agents': '模',
|
||||
'/skills': '技',
|
||||
'/cron': '时',
|
||||
'/scripts': '脚',
|
||||
'/setting': '设',
|
||||
};
|
||||
|
||||
export default function Sidebar() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const currentId = normalizeWorkspacePath(location.pathname);
|
||||
|
||||
return (
|
||||
<aside className="w-[80px] h-full box-border flex flex-col items-center pb-[8px]">
|
||||
<div className="flex flex-col gap-[16px] w-full">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = currentId === item.path;
|
||||
const isSetting = item.path === '/setting';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.path}
|
||||
className={['flex flex-col gap-[16px]', isSetting ? 'mt-auto mb-[8px] shrink-1' : ''].join(' ')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer flex flex-col items-center justify-center"
|
||||
onClick={() => navigate(item.path)}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
'box-border rounded-[16px] w-[48px] h-[48px] flex flex-col items-center justify-center hover:bg-white dark:hover:bg-[#222225]',
|
||||
active ? 'bg-white dark:bg-[#222225]' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
className="text-[18px] font-semibold leading-none"
|
||||
style={{ color: active ? '#2B7FFF' : '#525866' }}
|
||||
>
|
||||
{MENU_MARKS[item.path]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="text-[14px] mt-[4px] mb-[8px] hover:text-[#2B7FFF]"
|
||||
style={{ color: active ? '#2B7FFF' : '#525866' }}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="w-[48px] h-[48px] rounded-full overflow-hidden mt-auto bg-white dark:bg-[#222225] flex items-center justify-center border border-black/10 dark:border-[#2a2a2d]">
|
||||
<span className="text-[16px] font-bold text-[#2B7FFF]">Z</span>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
71
src/components/layout/TitleBar.tsx
Normal file
71
src/components/layout/TitleBar.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
type TitleBarProps = {
|
||||
variant?: 'default' | 'light';
|
||||
};
|
||||
|
||||
export default function TitleBar({ variant = 'default' }: TitleBarProps) {
|
||||
const platform = (window as any).api?.platform ?? '';
|
||||
|
||||
if (platform === 'linux') return null;
|
||||
|
||||
const iconColorClass =
|
||||
variant === 'light' ? 'text-white' : 'text-[#525866] dark:text-gray-300';
|
||||
const borderColorClass =
|
||||
variant === 'light' ? 'border-b-white/30' : 'border-b-gray-300 dark:border-gray-700';
|
||||
|
||||
if (platform === 'darwin') {
|
||||
return (
|
||||
<div
|
||||
className={['drag-region h-10 shrink-0 border-b', borderColorClass].join(' ')}
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={['drag-region flex h-10 shrink-0 items-center justify-end border-b', borderColorClass].join(
|
||||
' ',
|
||||
)}
|
||||
style={{ background: 'transparent' }}
|
||||
>
|
||||
<div className="no-drag flex h-full">
|
||||
<button
|
||||
type="button"
|
||||
className={['flex h-full w-11 items-center justify-center hover:bg-[#999] hover:text-white transition-colors', iconColorClass].join(
|
||||
' ',
|
||||
)}
|
||||
title="Minimize"
|
||||
onClick={() => {
|
||||
(window as any).api?.windowMinimize?.();
|
||||
}}
|
||||
>
|
||||
<span className="text-base leading-none">−</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={['flex h-full w-11 items-center justify-center hover:bg-[#999] hover:text-white transition-colors', iconColorClass].join(
|
||||
' ',
|
||||
)}
|
||||
title="Maximize"
|
||||
onClick={() => {
|
||||
(window as any).api?.windowMaximize?.();
|
||||
}}
|
||||
>
|
||||
<span className="text-[14px] leading-none">▢</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={['flex h-full w-11 items-center justify-center hover:bg-[#ff0000] hover:text-white transition-colors', iconColorClass].join(
|
||||
' ',
|
||||
)}
|
||||
title="Close"
|
||||
onClick={() => {
|
||||
(window as any).api?.windowClose?.();
|
||||
}}
|
||||
>
|
||||
<span className="text-base leading-none">×</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
src/components/layout/index.ts
Normal file
3
src/components/layout/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as MainLayout } from './MainLayout';
|
||||
export { default as Sidebar } from './Sidebar';
|
||||
export { default as TitleBar } from './TitleBar';
|
||||
Reference in New Issue
Block a user