ji
This commit is contained in:
1 parent
65449ffd71
commit
d522498cc9
8 files changed
+779
-165
No files matched your search
@@ -8,7 +8,7 @@ function makeSources(count: number): RuntimeDisplaySource[] {
|
||||
index: position + 1,
|
||||
skillName: "knowledge-base-search",
|
||||
mode: "citation",
|
||||
title: `参考文献 ${position + 1}`,
|
||||
title: `Reference ${position + 1}`,
|
||||
type: "result",
|
||||
snippet: "",
|
||||
raw: {},
|
||||
@@ -19,7 +19,7 @@ function citation(index: number): string {
|
||||
return `[citation:${index}](#reference-${index})`;
|
||||
}
|
||||
|
||||
test("全角单编号 【1】【2】 解析为引用徽章", () => {
|
||||
test("converts full-width numbered references", () => {
|
||||
const out = applyInlineCitations("结论见【1】【2】。", makeSources(2));
|
||||
assert.equal(out.includes(citation(1)), true);
|
||||
assert.equal(out.includes(citation(2)), true);
|
||||
@@ -27,21 +27,21 @@ test("全角单编号 【1】【2】 解析为引用徽章", () => {
|
||||
assert.equal(out.includes("【2】"), false);
|
||||
});
|
||||
|
||||
test("全角数字 【1】 也能识别", () => {
|
||||
test("converts full-width digits", () => {
|
||||
const out = applyInlineCitations("参考【1】和【2】。", makeSources(2));
|
||||
assert.equal(out.includes(citation(1)), true);
|
||||
assert.equal(out.includes(citation(2)), true);
|
||||
});
|
||||
|
||||
test("全角括号合并编号 【1,2】/【1,2】/【1、2】 拆成多个引用", () => {
|
||||
for (const marker of ["【1,2】", "【1,2】", "【1、2】"]) {
|
||||
test("splits merged reference markers", () => {
|
||||
for (const marker of ["【1,2】", "【1、2】", "【1,2】"]) {
|
||||
const out = applyInlineCitations(`见${marker}。`, makeSources(2));
|
||||
assert.equal(out.includes(citation(1)), true, marker);
|
||||
assert.equal(out.includes(citation(2)), true, marker);
|
||||
}
|
||||
});
|
||||
|
||||
test("全角区间编号 【1-3】/【2~5】 展开为连续引用", () => {
|
||||
test("expands reference ranges", () => {
|
||||
const dash = applyInlineCitations("见【1-3】。", makeSources(3));
|
||||
assert.equal(dash.includes(citation(1)), true);
|
||||
assert.equal(dash.includes(citation(2)), true);
|
||||
@@ -53,21 +53,26 @@ test("全角区间编号 【1-3】/【2~5】 展开为连续引用", () => {
|
||||
assert.equal(tilde.includes(citation(4)), true);
|
||||
});
|
||||
|
||||
test("超出来源范围的全角编号保持原样,不误判正文", () => {
|
||||
// 只有 1 条来源,【3】 没有对应来源 → 不应转换
|
||||
test("does not rewrite out-of-range references", () => {
|
||||
const out = applyInlineCitations("正文【3】不是引用。", makeSources(1));
|
||||
assert.equal(out.includes("【3】"), true);
|
||||
assert.equal(out.includes("#reference-3"), false);
|
||||
});
|
||||
|
||||
test("非数字的全角括号不被改写", () => {
|
||||
test("does not rewrite non-numeric bracket text", () => {
|
||||
const out = applyInlineCitations("见【附录】小节。", makeSources(2));
|
||||
assert.equal(out.includes("【附录】"), true);
|
||||
});
|
||||
|
||||
test("半角写法仍然正常工作", () => {
|
||||
test("keeps half-width syntax working", () => {
|
||||
const out = applyInlineCitations("见[1][2]和[1,3]。", makeSources(3));
|
||||
assert.equal(out.includes(citation(1)), true);
|
||||
assert.equal(out.includes(citation(2)), true);
|
||||
assert.equal(out.includes(citation(3)), true);
|
||||
});
|
||||
|
||||
test("accepts spaced and full-width square brackets", () => {
|
||||
const out = applyInlineCitations("参考[ 1 ]和[2]。", makeSources(2));
|
||||
assert.equal(out.includes(citation(1)), true);
|
||||
assert.equal(out.includes(citation(2)), true);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
|
||||
import { buildSkillDisplayMap, collectRuntimeDisplaySources } from "./runtime";
|
||||
|
||||
function makeSkill(): Skill {
|
||||
return {
|
||||
name: "custom-search",
|
||||
description: "test",
|
||||
category: "test",
|
||||
license: "internal",
|
||||
enabled: true,
|
||||
display: {
|
||||
enabled: true,
|
||||
mode: "citation",
|
||||
resultPath: "docs",
|
||||
titleField: "title",
|
||||
summaryField: "summary",
|
||||
contentField: "content",
|
||||
sourceField: "source",
|
||||
authorField: null,
|
||||
typeField: null,
|
||||
timeField: null,
|
||||
scoreField: null,
|
||||
urlField: "url",
|
||||
idField: "id",
|
||||
linkStrategy: "auto",
|
||||
linkTemplate: null,
|
||||
citationToken: "doubleBracket",
|
||||
maxItems: 20,
|
||||
customInstruction: "",
|
||||
tableColumns: [],
|
||||
resultSets: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeMessages(toolContent: Message["content"]): Message[] {
|
||||
return [
|
||||
{
|
||||
id: "ai-1",
|
||||
type: "ai",
|
||||
content: "",
|
||||
tool_calls: [{ id: "call-1", name: "custom-search", args: {} }],
|
||||
} as unknown as Message,
|
||||
{
|
||||
id: "tool-1",
|
||||
type: "tool",
|
||||
name: "custom-search",
|
||||
tool_call_id: "call-1",
|
||||
content: toolContent,
|
||||
} as unknown as Message,
|
||||
];
|
||||
}
|
||||
|
||||
test("collects citations from fenced nested json", () => {
|
||||
const messages = makeMessages(
|
||||
"```json\n{\"payload\":{\"docs\":[{\"id\":\"doc-1\",\"title\":\"Alpha\",\"summary\":\"Brief\",\"content\":\"Body\",\"source\":\"KB\",\"url\":\"https://example.com/a\"}]}}\n```",
|
||||
);
|
||||
const sources = collectRuntimeDisplaySources(
|
||||
messages,
|
||||
buildSkillDisplayMap([makeSkill()]),
|
||||
);
|
||||
assert.equal(sources.length, 1);
|
||||
assert.equal(sources[0]?.title, "Alpha");
|
||||
assert.equal(sources[0]?.url, "https://example.com/a");
|
||||
});
|
||||
|
||||
test("collects citations from structured tool payload arrays", () => {
|
||||
const messages = makeMessages([
|
||||
{
|
||||
type: "json",
|
||||
json: {
|
||||
wrapper: {
|
||||
docs: [
|
||||
{
|
||||
id: "doc-2",
|
||||
title: "Beta",
|
||||
summary: "Second",
|
||||
content: "Payload",
|
||||
source: "Skill",
|
||||
url: "https://example.com/b",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
] as unknown as Message["content"]);
|
||||
const sources = collectRuntimeDisplaySources(
|
||||
messages,
|
||||
buildSkillDisplayMap([makeSkill()]),
|
||||
);
|
||||
assert.equal(sources.length, 1);
|
||||
assert.equal(sources[0]?.title, "Beta");
|
||||
assert.equal(sources[0]?.source, "Skill");
|
||||
});
|
||||
@@ -588,7 +588,7 @@ function replaceBracketCitationTokens(
|
||||
byIndex: Map<number, RuntimeDisplaySource>,
|
||||
orderedSources: RuntimeDisplaySource[],
|
||||
): string {
|
||||
const tokenPattern = /\\?\[(\d{1,3})\\?\](?!\()/g;
|
||||
const tokenPattern = /\\?\[\s*([0-9\uFF10-\uFF19]{1,3})\s*\\?\](?!\()/g;
|
||||
let cursor = 0;
|
||||
let next = "";
|
||||
for (const match of content.matchAll(tokenPattern)) {
|
||||
@@ -598,13 +598,24 @@ function replaceBracketCitationTokens(
|
||||
continue;
|
||||
}
|
||||
next += content.slice(cursor, start);
|
||||
next += citationMarkdown(match[0], Number(match[1]), byIndex, orderedSources);
|
||||
next += citationMarkdown(
|
||||
match[0],
|
||||
Number(normalizeAsciiDigits(match[1] ?? "")),
|
||||
byIndex,
|
||||
orderedSources,
|
||||
);
|
||||
cursor = start + match[0].length;
|
||||
}
|
||||
next += content.slice(cursor);
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeAsciiDigits(value: string): string {
|
||||
return value.replace(/[\uFF10-\uFF19]/g, (digit) =>
|
||||
String.fromCharCode(digit.charCodeAt(0) - 0xfee0),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeLooseCitationTokens(
|
||||
content: string,
|
||||
byIndex: Map<number, RuntimeDisplaySource>,
|
||||
@@ -637,13 +648,17 @@ function normalizeLooseCitationTokens(
|
||||
// 避免把正文里的【…】误判成引用。转成半角后交给下面的半角逻辑统一处理。
|
||||
|
||||
// 全角单编号:【1】/【1】
|
||||
let next = content.replace(
|
||||
let next = content
|
||||
.replace(/[/g, "[")
|
||||
.replace(/]/g, "]")
|
||||
.replace(/[,、]/g, ",")
|
||||
.replace(
|
||||
/【\s*([0-90-9]{1,3})\s*】/g,
|
||||
(match, rawIndex: string) => {
|
||||
const index = Number(toAsciiDigits(rawIndex));
|
||||
const index = Number(normalizeAsciiDigits(rawIndex));
|
||||
return isValidIndex(index) ? `[${index}]` : match;
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
// 全角合并编号:【1,2】/【1,2】/【1、2】
|
||||
next = next.replace(
|
||||
@@ -651,7 +666,7 @@ function normalizeLooseCitationTokens(
|
||||
(match, rawList: string) => {
|
||||
const indexes = rawList
|
||||
.split(/[,,、]/)
|
||||
.map((part) => Number(toAsciiDigits(part.trim())));
|
||||
.map((part) => Number(normalizeAsciiDigits(part.trim())));
|
||||
if (indexes.some((index) => !isValidIndex(index))) {
|
||||
return match;
|
||||
}
|
||||
@@ -663,8 +678,8 @@ function normalizeLooseCitationTokens(
|
||||
next = next.replace(
|
||||
/【\s*([0-90-9]{1,3})\s*[-–~~]\s*([0-90-9]{1,3})\s*】/g,
|
||||
(match, rawStart: string, rawEnd: string) => {
|
||||
const start = Number(toAsciiDigits(rawStart));
|
||||
const end = Number(toAsciiDigits(rawEnd));
|
||||
const start = Number(normalizeAsciiDigits(rawStart));
|
||||
const end = Number(normalizeAsciiDigits(rawEnd));
|
||||
if (
|
||||
end <= start ||
|
||||
end - start > 20 ||
|
||||
@@ -978,20 +993,23 @@ function extractStructuredToolPayload(content: unknown): unknown {
|
||||
}
|
||||
|
||||
function unwrapToolPayloadObject(value: Record<string, unknown>): unknown {
|
||||
if (
|
||||
Array.isArray(value.results) ||
|
||||
Array.isArray(value.data) ||
|
||||
Array.isArray(value.items)
|
||||
) {
|
||||
return value;
|
||||
const directCandidate = findDisplayPayloadCandidate(value, 0);
|
||||
if (directCandidate !== undefined) {
|
||||
return directCandidate;
|
||||
}
|
||||
for (const key of ["result", "output", "stdout", "stderr", "content", "text"]) {
|
||||
const nested = value[key];
|
||||
for (const nested of Object.values(value)) {
|
||||
if (typeof nested === "string") {
|
||||
const parsed = parseJSONLikeText(nested);
|
||||
if (parsed !== undefined) {
|
||||
if (isRecord(parsed)) {
|
||||
const structured = findDisplayPayloadCandidate(parsed, 0);
|
||||
if (structured !== undefined) {
|
||||
return structured;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isRecord(nested)) {
|
||||
const unwrapped = unwrapToolPayloadObject(nested);
|
||||
@@ -1008,6 +1026,14 @@ function parseJSONLikeText(text: string): unknown {
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const fenced = extractFencedJSON(trimmed);
|
||||
if (fenced) {
|
||||
try {
|
||||
return JSON.parse(fenced) as unknown;
|
||||
} catch {
|
||||
// Fall through to best-effort parsing.
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
@@ -1016,17 +1042,9 @@ function parseJSONLikeText(text: string): unknown {
|
||||
}
|
||||
|
||||
function extractJSONFromText(text: string): unknown {
|
||||
const candidates: Array<[number, number]> = [];
|
||||
const firstObj = text.indexOf("{");
|
||||
if (firstObj !== -1) candidates.push([firstObj, text.lastIndexOf("}")]);
|
||||
const firstArr = text.indexOf("[");
|
||||
if (firstArr !== -1) candidates.push([firstArr, text.lastIndexOf("]")]);
|
||||
candidates.sort(([a], [b]) => a - b);
|
||||
for (const [start, end] of candidates) {
|
||||
if (start < 0 || end <= start) continue;
|
||||
const slice = text.slice(start, end + 1);
|
||||
for (const candidate of iterateBalancedJsonCandidates(text)) {
|
||||
try {
|
||||
return JSON.parse(slice);
|
||||
return JSON.parse(candidate);
|
||||
} catch {
|
||||
// try the next candidate
|
||||
}
|
||||
@@ -1034,6 +1052,108 @@ function extractJSONFromText(text: string): unknown {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function *iterateBalancedJsonCandidates(text: string): Generator<string> {
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const char = text[index];
|
||||
if (char !== "{" && char !== "[") {
|
||||
continue;
|
||||
}
|
||||
const candidate = extractBalancedJson(text, index);
|
||||
if (candidate) {
|
||||
yield candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractBalancedJson(text: string, start: number): string | null {
|
||||
const opening = text[start];
|
||||
if (opening !== "{" && opening !== "[") {
|
||||
return null;
|
||||
}
|
||||
const closing = opening === "{" ? "}" : "]";
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let index = start; index < text.length; index++) {
|
||||
const char = text[index];
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === "\\") {
|
||||
escaped = true;
|
||||
} else if (char === "\"") {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === "\"") {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (char === opening) {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === closing) {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return text.slice(start, index + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractFencedJSON(text: string): string | null {
|
||||
const match = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
|
||||
return match?.[1]?.trim() ?? null;
|
||||
}
|
||||
|
||||
function findDisplayPayloadCandidate(value: unknown, depth: number): unknown {
|
||||
if (depth > 8) {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const objectItems = value.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
isRecord(item),
|
||||
);
|
||||
if (value.length === 0 || objectItems.length === value.length) {
|
||||
return { results: objectItems };
|
||||
}
|
||||
for (const item of value) {
|
||||
const nested = findDisplayPayloadCandidate(item, depth + 1);
|
||||
if (nested !== undefined) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
for (const key of ["results", "data", "items", "docs", "records", "rows", "list", "hits", "sources"]) {
|
||||
const nested = value[key];
|
||||
if (!Array.isArray(nested)) {
|
||||
continue;
|
||||
}
|
||||
const objectItems = nested.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
isRecord(item),
|
||||
);
|
||||
if (nested.length === 0 || objectItems.length === nested.length) {
|
||||
return { ...value, [key]: objectItems };
|
||||
}
|
||||
}
|
||||
for (const nested of Object.values(value)) {
|
||||
const candidate = findDisplayPayloadCandidate(nested, depth + 1);
|
||||
if (candidate !== undefined) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractSearchPayloadFromText(text: string): unknown {
|
||||
if (!text.includes('"results"') || !text.includes('"recUuid"')) {
|
||||
return undefined;
|
||||
|
||||
@@ -141,17 +141,39 @@ export async function getSkillDisplaySample(
|
||||
return response.json() as Promise<SkillDisplaySampleResponse>;
|
||||
}
|
||||
|
||||
export interface InstallSkillRequest {
|
||||
thread_id: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface InstallSkillResponse {
|
||||
success: boolean;
|
||||
skill_name: string;
|
||||
message: string;
|
||||
overwritten?: boolean;
|
||||
}
|
||||
export interface InstallSkillRequest {
|
||||
thread_id: string;
|
||||
path: string;
|
||||
skill_name?: string;
|
||||
}
|
||||
|
||||
export interface InstallSkillResponse {
|
||||
success: boolean;
|
||||
skill_name: string;
|
||||
message: string;
|
||||
overwritten?: boolean;
|
||||
}
|
||||
|
||||
export interface PreviewSkillInstallRequest {
|
||||
thread_id: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface PreviewSkillInstallResponse {
|
||||
ok: boolean;
|
||||
skill_name: string;
|
||||
description: string;
|
||||
license: string | null;
|
||||
allowed_tools: string[];
|
||||
can_install_as_is: boolean;
|
||||
owned_by_me: boolean;
|
||||
owned_by_other: boolean;
|
||||
builtin_conflict: boolean;
|
||||
owner_username?: string | null;
|
||||
owner_user_id?: string | null;
|
||||
message: string;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ValidateSkillUploadResponse {
|
||||
ok: boolean;
|
||||
@@ -164,9 +186,9 @@ export interface ValidateSkillUploadResponse {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export async function installSkill(
|
||||
request: InstallSkillRequest,
|
||||
): Promise<InstallSkillResponse> {
|
||||
export async function installSkill(
|
||||
request: InstallSkillRequest,
|
||||
): Promise<InstallSkillResponse> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/skills/install`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -187,8 +209,24 @@ export async function installSkill(
|
||||
};
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function previewSkillInstall(
|
||||
request: PreviewSkillInstallRequest,
|
||||
): Promise<PreviewSkillInstallResponse> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/skills/install-preview`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to preview skill install");
|
||||
}
|
||||
return response.json() as Promise<PreviewSkillInstallResponse>;
|
||||
}
|
||||
|
||||
async function parseDetailedError(response: Response, fallback: string): Promise<Error> {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
Reference in new issue
Block a user