mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
👷 build: add agent skills database schema (#12197)
* add skills db * improve shell * fix tests * fix build * fix tests
This commit is contained in:
@@ -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;
|
||||
|
||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -18,6 +18,7 @@ concurrency:
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
if: github.repository == 'lobehub/lobehub'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
|
||||
@@ -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]'
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
26
packages/database/migrations/0077_add_agent_skills.sql
Normal file
26
packages/database/migrations/0077_add_agent_skills.sql
Normal 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
|
||||
11473
packages/database/migrations/meta/0077_snapshot.json
Normal file
11473
packages/database/migrations/meta/0077_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
302
packages/database/src/models/__tests__/agentSkill.test.ts
Normal file
302
packages/database/src/models/__tests__/agentSkill.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
152
packages/database/src/models/agentSkill.ts
Normal file
152
packages/database/src/models/agentSkill.ts
Normal 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 };
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
71
packages/database/src/schemas/agentSkill.ts
Normal file
71
packages/database/src/schemas/agentSkill.ts
Normal 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;
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './agent';
|
||||
export * from './agentCronJob';
|
||||
export * from './agentSkill';
|
||||
export * from './aiInfra';
|
||||
export * from './apiKey';
|
||||
export * from './asyncTask';
|
||||
|
||||
@@ -7,6 +7,7 @@ export const createNanoId = (size = 8) =>
|
||||
|
||||
const prefixes = {
|
||||
agentCronJobs: 'cron',
|
||||
agentSkills: 'skl',
|
||||
agents: 'agt',
|
||||
budget: 'bgt',
|
||||
chatGroups: 'cg',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
166
packages/types/src/skill/index.ts
Normal file
166
packages/types/src/skill/index.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user