👷 build: add agent skills database schema (#12197)

* add skills db

* improve shell

* fix tests

* fix build

* fix tests
This commit is contained in:
Arvin Xu
2026-02-08 19:53:27 +08:00
committed by GitHub
parent 37814db6df
commit 16b1904088
16 changed files with 12278 additions and 9 deletions

View File

@@ -20,6 +20,7 @@ config.rules['unicorn/no-array-for-each'] = 0;
config.rules['unicorn/prefer-number-properties'] = 0;
config.rules['unicorn/prefer-query-selector'] = 0;
config.rules['unicorn/no-array-callback-reference'] = 0;
config.rules['unicorn/text-encoding-identifier-case'] = 0;
config.rules['@typescript-eslint/no-use-before-define'] = 0;
// FIXME: Linting error in src/app/[variants]/(main)/chat/features/Migration/DBReader.ts, the fundamental solution should be upgrading typescript-eslint
config.rules['@typescript-eslint/no-useless-constructor'] = 0;

View File

@@ -18,6 +18,7 @@ concurrency:
jobs:
release:
name: Release
if: github.repository == 'lobehub/lobehub'
runs-on: ubuntu-latest
services:

View File

@@ -16,15 +16,26 @@ import { ControllerModule, IpcMethod } from './index';
const logger = createLogger('controllers:ShellCommandCtr');
// Maximum output length to prevent context explosion
const MAX_OUTPUT_LENGTH = 10_000;
const MAX_OUTPUT_LENGTH = 80_000;
/**
* Strip ANSI escape codes from terminal output
*/
// eslint-disable-next-line no-control-regex
const ANSI_REGEX = /\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
const stripAnsi = (str: string): string => str.replaceAll(ANSI_REGEX, '');
/**
* Truncate string to max length with ellipsis indicator
*/
const truncateOutput = (str: string, maxLength: number = MAX_OUTPUT_LENGTH): string => {
if (str.length <= maxLength) return str;
const cleaned = stripAnsi(str);
if (cleaned.length <= maxLength) return cleaned;
return (
str.slice(0, maxLength) + '\n... [truncated, ' + (str.length - maxLength) + ' more characters]'
cleaned.slice(0, maxLength) +
'\n... [truncated, ' +
(cleaned.length - maxLength) +
' more characters]'
);
};

View File

@@ -193,6 +193,62 @@ describe('ShellCommandCtr', () => {
expect(result.stderr).toBe('error message\n');
});
it('should strip ANSI escape codes from output', async () => {
let exitCallback: (code: number) => void;
let stdoutCallback: (data: Buffer) => void;
let stderrCallback: (data: Buffer) => void;
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
if (event === 'exit') {
exitCallback = callback;
setTimeout(() => exitCallback(0), 10);
}
return mockChildProcess;
});
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
if (event === 'data') {
stdoutCallback = callback;
// Simulate output with ANSI color codes
setTimeout(
() =>
stdoutCallback(
Buffer.from(
'\x1b[38;5;250m███████╗\x1b[0m\n\x1b[1;32mSuccess\x1b[0m\n\x1b[31mError\x1b[0m',
),
),
5,
);
}
return mockChildProcess.stdout;
});
mockChildProcess.stderr.on.mockImplementation((event: string, callback: any) => {
if (event === 'data') {
stderrCallback = callback;
setTimeout(
() => stderrCallback(Buffer.from('\x1b[33mwarning:\x1b[0m something happened')),
5,
);
}
return mockChildProcess.stderr;
});
const result = await shellCommandCtr.handleRunCommand({
command: 'npx skills find react',
description: 'search skills',
});
expect(result.success).toBe(true);
// ANSI codes should be stripped
expect(result.stdout).not.toContain('\x1b[');
expect(result.stdout).toContain('███████╗');
expect(result.stdout).toContain('Success');
expect(result.stdout).toContain('Error');
expect(result.stderr).not.toContain('\x1b[');
expect(result.stderr).toContain('warning: something happened');
});
it('should truncate long output to prevent context explosion', async () => {
let exitCallback: (code: number) => void;
let stdoutCallback: (data: Buffer) => void;
@@ -208,8 +264,8 @@ describe('ShellCommandCtr', () => {
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
if (event === 'data') {
stdoutCallback = callback;
// Simulate very long output (15k characters)
const longOutput = 'x'.repeat(15_000);
// Simulate very long output (100k characters, exceeding 80k MAX_OUTPUT_LENGTH)
const longOutput = 'x'.repeat(100_000);
setTimeout(() => stdoutCallback(Buffer.from(longOutput)), 5);
}
return mockChildProcess.stdout;
@@ -223,8 +279,8 @@ describe('ShellCommandCtr', () => {
});
expect(result.success).toBe(true);
// Output should be truncated to ~10k + truncation message
expect(result.stdout!.length).toBeLessThan(15_000);
// Output should be truncated to 80k + truncation message
expect(result.stdout!.length).toBeLessThan(100_000);
expect(result.stdout).toContain('truncated');
expect(result.stdout).toContain('more characters');
});

View File

@@ -0,0 +1,26 @@
CREATE TABLE IF NOT EXISTS "agent_skills" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"description" text NOT NULL,
"identifier" text NOT NULL,
"source" text NOT NULL,
"manifest" jsonb DEFAULT '{}'::jsonb NOT NULL,
"content" text,
"editor_data" jsonb,
"resources" jsonb DEFAULT '{}'::jsonb,
"zip_file_hash" varchar(64),
"user_id" text NOT NULL,
"accessed_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "agent_skills" DROP CONSTRAINT IF EXISTS "agent_skills_zip_file_hash_global_files_hash_id_fk";--> statement-breakpoint
ALTER TABLE "agent_skills" ADD CONSTRAINT "agent_skills_zip_file_hash_global_files_hash_id_fk" FOREIGN KEY ("zip_file_hash") REFERENCES "public"."global_files"("hash_id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "agent_skills" DROP CONSTRAINT IF EXISTS "agent_skills_user_id_users_id_fk";--> statement-breakpoint
ALTER TABLE "agent_skills" ADD CONSTRAINT "agent_skills_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "agent_skills_user_name_idx" ON "agent_skills" USING btree ("user_id","name");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "agent_skills_identifier_idx" ON "agent_skills" USING btree ("identifier");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "agent_skills_user_id_idx" ON "agent_skills" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "agent_skills_source_idx" ON "agent_skills" USING btree ("source");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "agent_skills_zip_hash_idx" ON "agent_skills" USING btree ("zip_file_hash");--> statement-breakpoint

File diff suppressed because it is too large Load Diff

View File

@@ -539,6 +539,13 @@
"when": 1770179814971,
"tag": "0076_add_message_group_index",
"breakpoints": true
},
{
"idx": 77,
"version": "7",
"when": 1770392264696,
"tag": "0077_add_agent_skills",
"breakpoints": true
}
],
"version": "6"

View File

@@ -0,0 +1,302 @@
// @vitest-environment node
import { SkillManifest } from '@lobechat/types';
import { eq } from 'drizzle-orm';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getTestDB } from '../../core/getTestDB';
import { agentSkills, users } from '../../schemas';
import { LobeChatDatabase } from '../../type';
import { AgentSkillModel } from '../agentSkill';
const serverDB: LobeChatDatabase = await getTestDB();
const userId = 'agent-skill-model-test-user-id';
const agentSkillModel = new AgentSkillModel(serverDB, userId);
// Helper to create valid manifest for tests
const createManifest = (overrides?: Partial<SkillManifest>): SkillManifest => ({
description: 'Test skill description',
name: 'Test Skill',
...overrides,
});
beforeEach(async () => {
await serverDB.delete(users);
await serverDB.insert(users).values([{ id: userId }]);
});
afterEach(async () => {
await serverDB.delete(users).where(eq(users.id, userId));
});
describe('AgentSkillModel', () => {
describe('create', () => {
it('should create a new agent skill', async () => {
const params = {
name: 'Test Skill',
description: 'A test skill',
identifier: 'test.skill',
source: 'user' as const,
manifest: createManifest({ version: '1.0.0' }),
content: '# Test Skill Content',
};
const skill = await agentSkillModel.create(params);
expect(skill).toMatchObject(params);
expect(skill.id).toBeDefined();
});
});
describe('delete', () => {
it('should delete an agent skill by id', async () => {
const { id } = await serverDB
.insert(agentSkills)
.values({
name: 'To Delete',
description: 'To delete skill',
identifier: 'to.delete',
source: 'user',
manifest: createManifest(),
userId,
})
.returning()
.then((res) => res[0]);
await agentSkillModel.delete(id);
const skill = await serverDB.query.agentSkills.findFirst({
where: eq(agentSkills.id, id),
});
expect(skill).toBeUndefined();
});
});
describe('findById', () => {
it('should find an agent skill by id', async () => {
const { id } = await serverDB
.insert(agentSkills)
.values({
name: 'Find Me',
description: 'Find me skill',
identifier: 'find.me',
source: 'user',
manifest: createManifest(),
userId,
})
.returning()
.then((res) => res[0]);
const skill = await agentSkillModel.findById(id);
expect(skill).toBeDefined();
expect(skill?.id).toBe(id);
});
it('should return undefined for non-existent id', async () => {
const skill = await agentSkillModel.findById('non-existent-id');
expect(skill).toBeUndefined();
});
});
describe('findByIdentifier', () => {
it('should find an agent skill by identifier', async () => {
await serverDB.insert(agentSkills).values({
name: 'By Identifier',
description: 'By identifier skill',
identifier: 'by.identifier',
source: 'user',
manifest: createManifest(),
userId,
});
const skill = await agentSkillModel.findByIdentifier('by.identifier');
expect(skill).toBeDefined();
expect(skill?.identifier).toBe('by.identifier');
});
});
describe('findAll', () => {
it('should find all agent skills for user', async () => {
await serverDB.insert(agentSkills).values([
{
name: 'Skill 1',
description: 'Skill 1 description',
identifier: 'skill.1',
source: 'user',
manifest: createManifest(),
userId,
},
{
name: 'Skill 2',
description: 'Skill 2 description',
identifier: 'skill.2',
source: 'market',
manifest: createManifest(),
userId,
},
]);
const skills = await agentSkillModel.findAll();
expect(skills.data).toHaveLength(2);
expect(skills.total).toBe(2);
});
});
describe('findByIds', () => {
it('should find agent skills by ids', async () => {
const inserted = await serverDB
.insert(agentSkills)
.values([
{
name: 'Skill A',
description: 'Skill A description',
identifier: 'skill.a',
source: 'user',
manifest: createManifest(),
userId,
},
{
name: 'Skill B',
description: 'Skill B description',
identifier: 'skill.b',
source: 'user',
manifest: createManifest(),
userId,
},
{
name: 'Skill C',
description: 'Skill C description',
identifier: 'skill.c',
source: 'user',
manifest: createManifest(),
userId,
},
])
.returning();
const ids = [inserted[0].id, inserted[2].id];
const skills = await agentSkillModel.findByIds(ids);
expect(skills).toHaveLength(2);
});
it('should return empty array for empty ids', async () => {
const skills = await agentSkillModel.findByIds([]);
expect(skills).toHaveLength(0);
});
});
describe('update', () => {
it('should update an agent skill', async () => {
const { id } = await serverDB
.insert(agentSkills)
.values({
name: 'Original Name',
description: 'Original description',
identifier: 'original',
source: 'user',
manifest: createManifest(),
userId,
})
.returning()
.then((res) => res[0]);
await agentSkillModel.update(id, { name: 'Updated Name' });
const updated = await serverDB.query.agentSkills.findFirst({
where: eq(agentSkills.id, id),
});
expect(updated?.name).toBe('Updated Name');
});
});
describe('listBySource', () => {
it('should list agent skills by source', async () => {
await serverDB.insert(agentSkills).values([
{
name: 'User Skill',
description: 'User skill description',
identifier: 'user.skill',
source: 'user',
manifest: createManifest(),
userId,
},
{
name: 'Market Skill',
description: 'Market skill description',
identifier: 'market.skill',
source: 'market',
manifest: createManifest(),
userId,
},
{
name: 'Builtin Skill',
description: 'Builtin skill description',
identifier: 'builtin.skill',
source: 'builtin',
manifest: createManifest(),
userId,
},
]);
const userSkills = await agentSkillModel.listBySource('user');
expect(userSkills.data).toHaveLength(1);
expect(userSkills.data[0].source).toBe('user');
const marketSkills = await agentSkillModel.listBySource('market');
expect(marketSkills.data).toHaveLength(1);
});
});
describe('search', () => {
it('should search agent skills by name', async () => {
await serverDB.insert(agentSkills).values([
{
name: 'Coding Wizard',
description: 'Coding wizard skill',
identifier: 'coding',
source: 'user',
manifest: createManifest(),
userId,
},
{
name: 'Writing Helper',
description: 'Writing helper skill',
identifier: 'writing',
source: 'user',
manifest: createManifest(),
userId,
},
]);
const results = await agentSkillModel.search('Coding');
expect(results.data).toHaveLength(1);
expect(results.data[0].name).toBe('Coding Wizard');
});
it('should search agent skills by description', async () => {
await serverDB.insert(agentSkills).values([
{
name: 'Skill A',
description: 'Helps with coding tasks',
identifier: 'a',
source: 'user',
manifest: createManifest(),
userId,
},
{
name: 'Skill B',
description: 'Helps with writing',
identifier: 'b',
source: 'user',
manifest: createManifest(),
userId,
},
]);
const results = await agentSkillModel.search('coding');
expect(results.data).toHaveLength(1);
expect(results.total).toBe(1);
});
});
});

View File

@@ -0,0 +1,152 @@
import { SkillItem, SkillListItem } from '@lobechat/types';
import { merge } from '@lobechat/utils';
import { and, desc, eq, ilike, inArray, or } from 'drizzle-orm';
import { NewAgentSkill, agentSkills } from '../schemas';
import { LobeChatDatabase } from '../type';
const skillItemColumns = {
content: agentSkills.content,
createdAt: agentSkills.createdAt,
description: agentSkills.description,
editorData: agentSkills.editorData,
id: agentSkills.id,
identifier: agentSkills.identifier,
manifest: agentSkills.manifest,
name: agentSkills.name,
resources: agentSkills.resources,
source: agentSkills.source,
updatedAt: agentSkills.updatedAt,
zipFileHash: agentSkills.zipFileHash,
};
const skillListColumns = {
createdAt: agentSkills.createdAt,
description: agentSkills.description,
id: agentSkills.id,
identifier: agentSkills.identifier,
manifest: agentSkills.manifest,
name: agentSkills.name,
source: agentSkills.source,
updatedAt: agentSkills.updatedAt,
zipFileHash: agentSkills.zipFileHash,
};
export class AgentSkillModel {
private userId: string;
private db: LobeChatDatabase;
constructor(db: LobeChatDatabase, userId: string) {
this.db = db;
this.userId = userId;
}
// ========== Create ==========
create = async (data: Omit<NewAgentSkill, 'userId'>): Promise<SkillItem> => {
const [result] = await this.db
.insert(agentSkills)
.values({ ...data, userId: this.userId })
.returning(skillItemColumns);
return result;
};
// ========== Read ==========
findById = async (id: string): Promise<SkillItem | undefined> => {
const [result] = await this.db
.select(skillItemColumns)
.from(agentSkills)
.where(and(eq(agentSkills.id, id), eq(agentSkills.userId, this.userId)))
.limit(1);
return result;
};
findByIdentifier = async (identifier: string): Promise<SkillItem | undefined> => {
const [result] = await this.db
.select(skillItemColumns)
.from(agentSkills)
.where(and(eq(agentSkills.identifier, identifier), eq(agentSkills.userId, this.userId)))
.limit(1);
return result;
};
findByName = async (name: string): Promise<SkillItem | undefined> => {
const [result] = await this.db
.select(skillItemColumns)
.from(agentSkills)
.where(and(eq(agentSkills.name, name), eq(agentSkills.userId, this.userId)))
.limit(1);
return result;
};
findAll = async (): Promise<{ data: SkillListItem[]; total: number }> => {
const data = await this.db
.select(skillListColumns)
.from(agentSkills)
.where(eq(agentSkills.userId, this.userId))
.orderBy(desc(agentSkills.updatedAt));
return { data, total: data.length };
};
findByIds = async (ids: string[]): Promise<SkillItem[]> => {
if (ids.length === 0) return [];
return this.db
.select(skillItemColumns)
.from(agentSkills)
.where(and(inArray(agentSkills.id, ids), eq(agentSkills.userId, this.userId)));
};
listBySource = async (
source: 'builtin' | 'market' | 'user',
): Promise<{ data: SkillListItem[]; total: number }> => {
const data = await this.db
.select(skillListColumns)
.from(agentSkills)
.where(and(eq(agentSkills.source, source), eq(agentSkills.userId, this.userId)))
.orderBy(desc(agentSkills.updatedAt));
return { data, total: data.length };
};
search = async (query: string): Promise<{ data: SkillListItem[]; total: number }> => {
const data = await this.db
.select(skillListColumns)
.from(agentSkills)
.where(
and(
eq(agentSkills.userId, this.userId),
or(ilike(agentSkills.name, `%${query}%`), ilike(agentSkills.description, `%${query}%`)),
),
)
.orderBy(desc(agentSkills.updatedAt));
return { data, total: data.length };
};
// ========== Update ==========
update = async (id: string, data: Partial<NewAgentSkill>): Promise<SkillItem> => {
const existing = await this.findById(id);
const updateData = merge(existing || {}, { ...data, updatedAt: new Date() });
const [result] = await this.db
.update(agentSkills)
.set(updateData)
.where(and(eq(agentSkills.id, id), eq(agentSkills.userId, this.userId)))
.returning(skillItemColumns);
return result;
};
// ========== Delete ==========
delete = async (id: string): Promise<{ success: boolean }> => {
const result = await this.db
.delete(agentSkills)
.where(and(eq(agentSkills.id, id), eq(agentSkills.userId, this.userId)));
return { success: (result.rowCount ?? 0) > 0 };
};
}

View File

@@ -315,7 +315,7 @@ describe('CompressionRepository', () => {
* 2. Compression groups should appear as aggregated nodes
* 3. Pinned (favorite) messages within compression groups should be extracted
*/
describe('MessageGroup aggregation query scenarios (LOBE-2066)', () => {
describe('MessageGroup aggregation query scenarios', () => {
describe('compressed messages filtering', () => {
it('should exclude compressed messages from uncompressed query', async () => {
// Setup: Create 5 messages, compress 3 of them

View File

@@ -0,0 +1,71 @@
/* eslint-disable sort-keys-fix/sort-keys-fix */
import { SkillManifest, SkillResourceMeta } from '@lobechat/types';
import { relations } from 'drizzle-orm';
import { index, jsonb, pgTable, text, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
import { idGenerator } from '../utils/idGenerator';
import { timestamps } from './_helpers';
import { globalFiles } from './file';
import { users } from './user';
export const agentSkills = pgTable(
'agent_skills',
{
id: text('id')
.$defaultFn(() => idGenerator('agentSkills'))
.primaryKey(),
// 核心标识
name: text('name').notNull(),
description: text('description').notNull(),
identifier: text('identifier').notNull(),
// 来源控制
source: text('source', { enum: ['builtin', 'market', 'user'] }).notNull(),
// Manifest (version, author, repository 等)
manifest: jsonb('manifest')
.$type<SkillManifest>()
.notNull()
.default({} as SkillManifest),
// 内容与编辑器状态
content: text('content'),
editorData: jsonb('editor_data').$type<Record<string, any>>(),
// 资源映射: Record<VirtualPath, SkillResourceMeta>
resources: jsonb('resources').$type<Record<string, SkillResourceMeta>>().default({}),
// 原始分发包 (CAS)
zipFileHash: varchar('zip_file_hash', { length: 64 }).references(() => globalFiles.hashId, {
onDelete: 'set null',
}),
// 归属
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
...timestamps,
},
(t) => [
uniqueIndex('agent_skills_user_name_idx').on(t.userId, t.name),
index('agent_skills_identifier_idx').on(t.identifier),
index('agent_skills_user_id_idx').on(t.userId),
index('agent_skills_source_idx').on(t.source),
index('agent_skills_zip_hash_idx').on(t.zipFileHash),
],
);
export const agentSkillsRelations = relations(agentSkills, ({ one }) => ({
user: one(users, {
fields: [agentSkills.userId],
references: [users.id],
}),
zipFile: one(globalFiles, {
fields: [agentSkills.zipFileHash],
references: [globalFiles.hashId],
}),
}));
export type NewAgentSkill = typeof agentSkills.$inferInsert;

View File

@@ -1,5 +1,6 @@
export * from './agent';
export * from './agentCronJob';
export * from './agentSkill';
export * from './aiInfra';
export * from './apiKey';
export * from './asyncTask';

View File

@@ -7,6 +7,7 @@ export const createNanoId = (size = 8) =>
const prefixes = {
agentCronJobs: 'cron',
agentSkills: 'skl',
agents: 'agt',
budget: 'bgt',
chatGroups: 'cg',

View File

@@ -621,7 +621,7 @@ describe('QwenAIStream', () => {
});
});
// Test case for parallel tool calls bug (LOBE-3903)
// Test case for parallel tool calls bug
// This test reproduces the issue where Qwen model returns 3 parallel tool calls
// for querying time in Beijing, Shanghai, and Nanjing simultaneously.
// The bug causes arguments from different tool calls to be incorrectly merged.

View File

@@ -29,6 +29,7 @@ export * from './search';
export * from './serverConfig';
export * from './service';
export * from './session';
export * from './skill';
export * from './stepContext';
export * from './tool';
export * from './topic';

View File

@@ -0,0 +1,166 @@
import { z } from 'zod';
// ===== Manifest Schema =====
export const skillAuthorSchema = z.object({
name: z.string(),
url: z.string().url().optional(),
});
export const skillManifestSchema = z
.object({
author: skillAuthorSchema.optional(),
// Required: skill description
description: z.string().min(1, 'Skill description is required'),
license: z.string().optional(),
// Required fields
name: z.string().min(1, 'Skill name is required'),
permissions: z.array(z.string()).optional(),
// Project main repository URL
// e.g. https://github.com/lobehub/skills
repository: z.string().url().optional(),
// Source URL where the skill was imported from
// e.g. https://github.com/lobehub/skills/tree/main/code-review or https://example.com/skill.md
sourceUrl: z.string().url().optional(),
// Optional fields
version: z.string().optional(),
})
.passthrough();
export type SkillManifest = z.infer<typeof skillManifestSchema>;
export type SkillAuthor = z.infer<typeof skillAuthorSchema>;
// ===== Builtin Skill =====
export interface BuiltinSkill {
avatar?: string;
content: string;
description: string;
identifier: string;
name: string;
}
// ===== Skill Source =====
export type SkillSource = 'builtin' | 'market' | 'user';
// ===== Parsed Skill =====
export interface ParsedSkill {
content: string;
manifest: SkillManifest;
raw: string;
}
export interface ParsedZipSkill {
content: string;
manifest: SkillManifest;
resources: Map<string, Buffer>;
/**
* Repacked skill directory ZIP buffer (only when repackSkillZip=true)
* Used for GitHub imports to store only the skill directory, not the full repo
*/
skillZipBuffer?: Buffer;
zipHash?: string;
}
// ===== Resource Types =====
export interface SkillResourceMeta {
documentId?: string;
fileHash: string;
size: number;
}
export interface SkillResourceTreeNode {
children?: SkillResourceTreeNode[];
name: string;
path: string;
type: 'file' | 'directory';
}
export interface SkillResourceContent {
content: string;
encoding: 'utf-8' | 'base64';
fileHash: string;
fileType: string;
path: string;
size: number;
}
// ===== Skill Item (完整结构,用于详情查询) =====
export interface SkillItem {
content?: string | null;
createdAt: Date;
description?: string | null;
editorData?: Record<string, any> | null;
id: string;
identifier: string;
manifest: SkillManifest;
name: string;
resources?: Record<string, SkillResourceMeta> | null;
source: SkillSource;
updatedAt: Date;
zipFileHash?: string | null;
}
// ===== Skill List Item (精简结构,用于列表查询) =====
export interface SkillListItem {
createdAt: Date;
description?: string | null;
id: string;
identifier: string;
manifest: SkillManifest;
name: string;
source: SkillSource;
updatedAt: Date;
zipFileHash?: string | null;
}
// ===== Service Input Types =====
export interface CreateSkillInput {
content: string;
description: string;
identifier?: string;
name: string;
}
export interface ImportZipInput {
zipFileId: string;
}
export interface ImportGitHubInput {
branch?: string;
gitUrl: string;
}
export interface ImportUrlInput {
url: string;
}
export interface UpdateSkillInput {
content?: string;
description?: string;
id: string;
manifest?: Partial<SkillManifest>;
name?: string;
}
// ===== Import Result Types =====
export type SkillImportStatus = 'created' | 'updated' | 'unchanged';
export interface SkillImportResult {
skill: SkillItem;
status: SkillImportStatus;
}