feat: implement task management store with IPC integration
- Added a new task store in `src-react/stores/task.ts` to manage tasks and their statuses. - Implemented functions for creating, executing, and retrying tasks, along with handling task progress and completion. - Introduced persistence for tasks using IPC. - Created utility functions for normalizing room types and building subtasks. - Added a new CSS file for global styles in `src-react/styles.css`. - Created runtime types in `src-react/types/runtime.ts` and exported them. - Updated the main entry points for Vue and React applications to support dynamic framework loading. - Refactored chat model interfaces and utility functions into `src/shared/chat-model.ts`. - Updated TypeScript configuration to include paths for React components and types. - Enhanced Vite configuration to support both Vue and React frameworks.
This commit is contained in:
24
src/framework.ts
Normal file
24
src/framework.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export type UiFramework = 'vue' | 'react';
|
||||
|
||||
export const DEFAULT_UI_FRAMEWORK: UiFramework = 'react';
|
||||
|
||||
export function resolveUiFramework(): UiFramework {
|
||||
const rawValue = String(import.meta.env.VITE_UI_FRAMEWORK ?? DEFAULT_UI_FRAMEWORK)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
return rawValue === 'vue' ? 'vue' : 'react';
|
||||
}
|
||||
|
||||
export async function mountUiApp(): Promise<void> {
|
||||
const framework = resolveUiFramework();
|
||||
|
||||
if (framework === 'vue') {
|
||||
const { mountVueApp } = await import('./main-vue');
|
||||
mountVueApp();
|
||||
return;
|
||||
}
|
||||
|
||||
const { mountReactApp } = await import('../src-react/main');
|
||||
mountReactApp();
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Task } from './task-types';
|
||||
import { WINDOW_NAMES, CONFIG_KEYS } from './constants';
|
||||
|
||||
export type WindowNames = `${WINDOW_NAMES}`;
|
||||
@@ -18,10 +19,16 @@ export interface IConfig {
|
||||
[CONFIG_KEYS.PROVIDER]?: string;
|
||||
// 默认模型
|
||||
[CONFIG_KEYS.DEFAULT_MODEL]?: string | null;
|
||||
// 自动检查更新
|
||||
[CONFIG_KEYS.AUTO_CHECK_UPDATE]?: boolean;
|
||||
// 自动下载更新
|
||||
[CONFIG_KEYS.AUTO_DOWNLOAD_UPDATE]?: boolean;
|
||||
// 选中的渠道
|
||||
[CONFIG_KEYS.SELECTED_CHANNELS]: Array<{ id: string; channelName: string; channelUrl: string }>;
|
||||
// 图片缓存
|
||||
[CONFIG_KEYS.IMAGE_CACHE]: Array<[string, any]>;
|
||||
// 任务列表
|
||||
[CONFIG_KEYS.TASK_LIST]?: Task[];
|
||||
}
|
||||
|
||||
export interface Provider {
|
||||
@@ -39,4 +46,4 @@ export interface Provider {
|
||||
export interface OpenAISetting {
|
||||
baseURL?: string;
|
||||
apiKey?: string;
|
||||
}
|
||||
}
|
||||
|
||||
35
src/main-vue.ts
Normal file
35
src/main-vue.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createApp, type Plugin } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import errorHandler from "@utils/errorHandler";
|
||||
import router from "./router";
|
||||
import App from "./App.vue";
|
||||
|
||||
import ElementPlus from "element-plus";
|
||||
import locale from "element-plus/es/locale/lang/zh-cn";
|
||||
|
||||
import i18n from "./i18n";
|
||||
import "./permission";
|
||||
|
||||
import "./styles/index.css";
|
||||
import "element-plus/dist/index.css";
|
||||
import "element-plus/theme-chalk/dark/css-vars.css";
|
||||
|
||||
import Layout from "@components/Layout/index.vue";
|
||||
|
||||
const components: Plugin = (app) => {
|
||||
app.component("Layout", Layout);
|
||||
};
|
||||
|
||||
export function mountVueApp(): void {
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.use(ElementPlus, { locale });
|
||||
app.use(components);
|
||||
app.use(i18n);
|
||||
app.use(errorHandler);
|
||||
|
||||
app.mount("#app");
|
||||
}
|
||||
43
src/main.ts
43
src/main.ts
@@ -1,40 +1,5 @@
|
||||
import { createApp, type Plugin } from "vue"
|
||||
import { createPinia } from "pinia"
|
||||
import errorHandler from "@utils/errorHandler"
|
||||
import router from "./router"
|
||||
import App from "./App.vue"
|
||||
import { mountUiApp } from './framework';
|
||||
|
||||
// 引入 Element Plus 组件库
|
||||
import ElementPlus from 'element-plus'
|
||||
import locale from 'element-plus/es/locale/lang/zh-cn'
|
||||
|
||||
// 引入 i18n 插件
|
||||
import i18n from './i18n'
|
||||
import './permission'
|
||||
|
||||
// 样式文件隔离
|
||||
import "./styles/index.css";
|
||||
import 'element-plus/dist/index.css'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
|
||||
// 引入全局组件
|
||||
import Layout from '@components/Layout/index.vue'
|
||||
|
||||
const components: Plugin = (app) => {
|
||||
app.component('Layout', Layout);
|
||||
}
|
||||
|
||||
// 创建 Vue 应用实例
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
|
||||
// 使用 Pinia 状态管理
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.use(ElementPlus, { locale })
|
||||
app.use(components)
|
||||
app.use(i18n)
|
||||
app.use(errorHandler)
|
||||
|
||||
// 挂载应用到 DOM
|
||||
app.mount("#app");
|
||||
void mountUiApp().catch((error) => {
|
||||
console.error('Failed to bootstrap UI framework', error);
|
||||
});
|
||||
|
||||
@@ -1,167 +1 @@
|
||||
/// 附件文件元数据(与 ClawX 对齐)
|
||||
export interface AttachedFileMeta {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
preview: string | null;
|
||||
filePath?: string;
|
||||
source?: 'user-upload' | 'tool-result' | 'message-ref';
|
||||
}
|
||||
|
||||
/// 内容块(与 ClawX 对齐,用于未来扩展结构化消息)
|
||||
export interface ContentBlock {
|
||||
type: 'text' | 'image' | 'thinking' | 'tool_use' | 'tool_result' | 'toolCall' | 'toolResult';
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
source?: { type: string; media_type?: string; data?: string; url?: string };
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
input?: unknown;
|
||||
arguments?: unknown;
|
||||
content?: unknown;
|
||||
}
|
||||
|
||||
/// 原始消息(与 ClawX RawMessage 对齐)
|
||||
export interface RawMessage {
|
||||
role: 'user' | 'assistant' | 'system' | 'toolresult';
|
||||
content: string | ContentBlock[];
|
||||
timestamp?: number;
|
||||
id?: string;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
details?: unknown;
|
||||
isError?: boolean;
|
||||
/** zn-ai 特有:问题标签(保留现有能力) */
|
||||
question?: string[];
|
||||
/** zn-ai 特有:工具调用结果(保留现有能力) */
|
||||
toolCall?: any;
|
||||
/** 本地-only:附件 */
|
||||
_attachedFiles?: AttachedFileMeta[];
|
||||
}
|
||||
|
||||
/// 工具状态(与 ClawX 对齐)
|
||||
export interface ToolStatus {
|
||||
id?: string;
|
||||
toolCallId?: string;
|
||||
name: string;
|
||||
status: 'running' | 'completed' | 'error';
|
||||
durationMs?: number;
|
||||
summary?: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/// 会话(与 ClawX ChatSession 对齐)
|
||||
export interface ChatSession {
|
||||
key: string;
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
thinkingLevel?: string;
|
||||
model?: string;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
/// 流式消息辅助:从 RawMessage 提取纯文本
|
||||
export function extractText(message?: RawMessage | null): string {
|
||||
if (!message) return '';
|
||||
const content = message.content;
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return (content as Array<{ type?: string; text?: string }>)
|
||||
.filter((b) => b.type === 'text' && b.text)
|
||||
.map((b) => b.text!)
|
||||
.join('\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/// 流式消息辅助:从 RawMessage 提取 thinking 文本
|
||||
export function extractThinking(message?: RawMessage | null): string | null {
|
||||
if (!message) return null;
|
||||
const content = message.content;
|
||||
if (Array.isArray(content)) {
|
||||
const block = content.find((b: any) => b.type === 'thinking');
|
||||
return block?.thinking || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 流式消息辅助:从 RawMessage 提取图片
|
||||
export function extractImages(message?: RawMessage | null): Array<{ url?: string; data?: string; mimeType: string }> {
|
||||
if (!message) return [];
|
||||
const content = message.content;
|
||||
if (!Array.isArray(content)) return [];
|
||||
const images: Array<{ url?: string; data?: string; mimeType: string }> = [];
|
||||
for (const block of content as ContentBlock[]) {
|
||||
if (block.type === 'image') {
|
||||
if (block.source) {
|
||||
const src = block.source;
|
||||
if (src.type === 'base64' && src.data) {
|
||||
images.push({ data: src.data, mimeType: src.media_type || 'image/jpeg' });
|
||||
} else if (src.type === 'url' && src.url) {
|
||||
images.push({ url: src.url, mimeType: src.media_type || 'image/jpeg' });
|
||||
}
|
||||
} else if (block.data) {
|
||||
images.push({ data: block.data, mimeType: block.mimeType || 'image/jpeg' });
|
||||
}
|
||||
}
|
||||
if ((block.type === 'tool_result' || block.type === 'toolResult') && block.content) {
|
||||
images.push(...extractImages({ role: 'toolresult', content: block.content }));
|
||||
}
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
/// 流式消息辅助:从 RawMessage 提取 tool_use
|
||||
export function extractToolUse(message?: RawMessage | null): Array<{ id?: string; name: string; input?: unknown }> {
|
||||
if (!message) return [];
|
||||
const content = message.content;
|
||||
if (!Array.isArray(content)) return [];
|
||||
return (content as ContentBlock[])
|
||||
.filter((b) => b.type === 'tool_use' || b.type === 'toolCall')
|
||||
.map((b) => ({ id: b.id, name: b.name || b.id || 'tool', input: b.input ?? b.arguments }));
|
||||
}
|
||||
|
||||
/// 格式化时间戳(秒/ms 兼容)
|
||||
export function formatTimestamp(ts?: number): string {
|
||||
if (!ts) return '';
|
||||
const ms = ts < 1e12 ? ts * 1000 : ts;
|
||||
return new Date(ms).toLocaleString();
|
||||
}
|
||||
|
||||
/// 判断是否为 tool-only 消息
|
||||
export function isToolOnlyMessage(message?: RawMessage): boolean {
|
||||
if (!message) return false;
|
||||
const role = message.role;
|
||||
if (role === 'toolresult' || role === 'tool_result') return true;
|
||||
const content = message.content;
|
||||
if (Array.isArray(content)) {
|
||||
const hasTool = content.some((b: any) =>
|
||||
['tool_use', 'tool_result', 'toolCall', 'toolResult'].includes(b.type)
|
||||
);
|
||||
const hasText = content.some(
|
||||
(b: any) => b.type === 'text' && b.text?.trim?.()
|
||||
);
|
||||
const hasImage = content.some((b: any) => b.type === 'image');
|
||||
return hasTool && !hasText && !hasImage;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 判断是否为 tool result 角色
|
||||
export function isToolResultRole(role?: string): boolean {
|
||||
if (!role) return false;
|
||||
const normalized = role.toLowerCase();
|
||||
return normalized === 'toolresult' || normalized === 'tool_result';
|
||||
}
|
||||
|
||||
/// 判断是否为内部消息(不应展示在 UI)
|
||||
export function isInternalMessage(msg: { role?: string; content?: unknown }): boolean {
|
||||
if (msg.role === 'system') return true;
|
||||
if (msg.role === 'assistant') {
|
||||
const text = typeof msg.content === 'string' ? msg.content : extractText(msg as RawMessage);
|
||||
if (/^(HEARTBEAT_OK|NO_REPLY)\s*$/.test(text)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
export * from '@shared/chat-model';
|
||||
|
||||
173
src/shared/chat-model.ts
Normal file
173
src/shared/chat-model.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
export interface AttachedFileMeta {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
preview: string | null;
|
||||
filePath?: string;
|
||||
source?: 'user-upload' | 'tool-result' | 'message-ref';
|
||||
}
|
||||
|
||||
export interface ContentBlockSource {
|
||||
type: string;
|
||||
media_type?: string;
|
||||
data?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface ContentBlock {
|
||||
type: 'text' | 'image' | 'thinking' | 'tool_use' | 'tool_result' | 'toolCall' | 'toolResult';
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
source?: ContentBlockSource;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
input?: unknown;
|
||||
arguments?: unknown;
|
||||
content?: string | ContentBlock[];
|
||||
}
|
||||
|
||||
export type RawMessageRole = 'user' | 'assistant' | 'system' | 'toolresult' | 'tool_result';
|
||||
|
||||
export interface RawMessage {
|
||||
role: RawMessageRole;
|
||||
content: string | ContentBlock[];
|
||||
timestamp?: number;
|
||||
id?: string;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
details?: unknown;
|
||||
isError?: boolean;
|
||||
question?: string[];
|
||||
toolCall?: Record<string, unknown> | null;
|
||||
_attachedFiles?: AttachedFileMeta[];
|
||||
}
|
||||
|
||||
export interface ToolStatus {
|
||||
id?: string;
|
||||
toolCallId?: string;
|
||||
name: string;
|
||||
status: 'running' | 'completed' | 'error';
|
||||
durationMs?: number;
|
||||
summary?: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
key: string;
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
thinkingLevel?: string;
|
||||
model?: string;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export function extractText(message?: RawMessage | null): string {
|
||||
if (!message) return '';
|
||||
|
||||
if (typeof message.content === 'string') {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
return message.content
|
||||
.filter((block) => block.type === 'text' && typeof block.text === 'string')
|
||||
.map((block) => block.text ?? '')
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function extractThinking(message?: RawMessage | null): string | null {
|
||||
if (!message || !Array.isArray(message.content)) return null;
|
||||
|
||||
const thinkingBlock = message.content.find((block) => block.type === 'thinking');
|
||||
return thinkingBlock?.thinking ?? null;
|
||||
}
|
||||
|
||||
export function extractImages(message?: RawMessage | null): Array<{ url?: string; data?: string; mimeType: string }> {
|
||||
if (!message || !Array.isArray(message.content)) return [];
|
||||
|
||||
const images: Array<{ url?: string; data?: string; mimeType: string }> = [];
|
||||
|
||||
for (const block of message.content) {
|
||||
if (block.type === 'image') {
|
||||
if (block.source?.type === 'base64' && block.source.data) {
|
||||
images.push({
|
||||
data: block.source.data,
|
||||
mimeType: block.source.media_type || 'image/jpeg',
|
||||
});
|
||||
} else if (block.source?.type === 'url' && block.source.url) {
|
||||
images.push({
|
||||
url: block.source.url,
|
||||
mimeType: block.source.media_type || 'image/jpeg',
|
||||
});
|
||||
} else if (block.data) {
|
||||
images.push({
|
||||
data: block.data,
|
||||
mimeType: block.mimeType || 'image/jpeg',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if ((block.type === 'tool_result' || block.type === 'toolResult') && Array.isArray(block.content)) {
|
||||
images.push(...extractImages({ role: 'toolresult', content: block.content }));
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
export function extractToolUse(message?: RawMessage | null): Array<{ id?: string; name: string; input?: unknown }> {
|
||||
if (!message || !Array.isArray(message.content)) return [];
|
||||
|
||||
return message.content
|
||||
.filter((block) => block.type === 'tool_use' || block.type === 'toolCall')
|
||||
.map((block) => ({
|
||||
id: block.id,
|
||||
name: block.name || block.id || 'tool',
|
||||
input: block.input ?? block.arguments,
|
||||
}));
|
||||
}
|
||||
|
||||
export function formatTimestamp(ts?: number): string {
|
||||
if (!ts) return '';
|
||||
|
||||
const ms = ts < 1e12 ? ts * 1000 : ts;
|
||||
return new Date(ms).toLocaleString();
|
||||
}
|
||||
|
||||
export function isToolResultRole(role?: string): boolean {
|
||||
if (!role) return false;
|
||||
|
||||
const normalized = role.toLowerCase();
|
||||
return normalized === 'toolresult' || normalized === 'tool_result';
|
||||
}
|
||||
|
||||
export function isToolOnlyMessage(message?: RawMessage): boolean {
|
||||
if (!message) return false;
|
||||
if (isToolResultRole(message.role)) return true;
|
||||
if (!Array.isArray(message.content)) return false;
|
||||
|
||||
const hasToolBlock = message.content.some((block) =>
|
||||
['tool_use', 'tool_result', 'toolCall', 'toolResult'].includes(block.type),
|
||||
);
|
||||
const hasTextBlock = message.content.some((block) => block.type === 'text' && block.text?.trim());
|
||||
const hasImageBlock = message.content.some((block) => block.type === 'image');
|
||||
|
||||
return hasToolBlock && !hasTextBlock && !hasImageBlock;
|
||||
}
|
||||
|
||||
export function isInternalMessage(message: { role?: string; content?: unknown }): boolean {
|
||||
if (message.role === 'system') return true;
|
||||
|
||||
if (message.role === 'assistant') {
|
||||
const text = typeof message.content === 'string'
|
||||
? message.content
|
||||
: extractText(message as RawMessage);
|
||||
|
||||
if (/^(HEARTBEAT_OK|NO_REPLY)\s*$/.test(text)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import type {
|
||||
AttachedFileMeta,
|
||||
ToolStatus,
|
||||
ChatSession,
|
||||
} from '@src/pages/home/model/ChatModel'
|
||||
import { extractText, isToolOnlyMessage, isToolResultRole, isInternalMessage } from '@src/pages/home/model/ChatModel'
|
||||
} from '@shared/chat-model'
|
||||
import { extractText, isToolOnlyMessage, isToolResultRole, isInternalMessage } from '@shared/chat-model'
|
||||
import { hostApiFetch } from '@lib/host-api'
|
||||
import { gatewayRpc, onGatewayEvent } from '@lib/gateway-client'
|
||||
import { useProviderStore } from '@stores/providers'
|
||||
|
||||
Reference in New Issue
Block a user