@things-factory/ai-assistant 10.1.20 → 10.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/components/assistant-chat.ts +89 -18
- package/client/components/assistant-mode.test.ts +82 -0
- package/client/components/assistant-mode.ts +124 -0
- package/client/index.ts +1 -1
- package/dist-client/components/assistant-chat.d.ts +15 -4
- package/dist-client/components/assistant-chat.js +72 -18
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-mode.d.ts +91 -0
- package/dist-client/components/assistant-mode.js +30 -0
- package/dist-client/components/assistant-mode.js.map +1 -0
- package/dist-client/components/assistant-mode.test.d.ts +1 -0
- package/dist-client/components/assistant-mode.test.js +72 -0
- package/dist-client/components/assistant-mode.test.js.map +1 -0
- package/dist-client/index.d.ts +1 -1
- package/dist-client/index.js +1 -1
- package/dist-client/index.js.map +1 -1
- package/package.json +4 -5
- package/client/utils/board-edit-patch.ts +0 -374
- package/dist-client/tsconfig.tsbuildinfo +0 -1
- package/dist-client/utils/board-edit-patch.d.ts +0 -157
- package/dist-client/utils/board-edit-patch.js +0 -284
- package/dist-client/utils/board-edit-patch.js.map +0 -1
- package/dist-server/tsconfig.tsbuildinfo +0 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A mode is what a conversation *is*: who the assistant is, what it can do, what the talk is
|
|
3
|
+
* about, what it offers to start with, and when it is over.
|
|
4
|
+
*
|
|
5
|
+
* Those five used to be four separate properties on the chat element plus one global registry,
|
|
6
|
+
* and every host wired them one at a time. Separate means they can disagree, and they did: a
|
|
7
|
+
* screen whose `systemPrompt` said "you do not edit boards here" offered `/add-monitor` in the
|
|
8
|
+
* same panel, because the prompt was a property and the slash list was global.
|
|
9
|
+
*
|
|
10
|
+
* So a host declares one value and hands it over. What cannot be split cannot fall out of step.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* const SPACE_MODE = (spaceId: string): AssistantMode => ({
|
|
14
|
+
* id: `twin:space:${spaceId}`,
|
|
15
|
+
* systemPrompt: '...',
|
|
16
|
+
* toolCategories: ['twin'],
|
|
17
|
+
* hostContext: { spaceId },
|
|
18
|
+
* slashTemplates: TWIN_SLASH_TEMPLATES
|
|
19
|
+
* })
|
|
20
|
+
*
|
|
21
|
+
* html`<ox-assistant-chat .mode=${SPACE_MODE(this.spaceId)}></ox-assistant-chat>`
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export interface AssistantMode {
|
|
25
|
+
/**
|
|
26
|
+
* What this conversation is about, as a string the host can compare.
|
|
27
|
+
*
|
|
28
|
+
* **The conversation's lifetime hangs on this.** When it changes, the chat closes what is on
|
|
29
|
+
* screen and starts again — because a conversation about station 3 is not a conversation
|
|
30
|
+
* about station 7, and leaving the old one up means the user reads an answer about a screen
|
|
31
|
+
* they are no longer looking at.
|
|
32
|
+
*
|
|
33
|
+
* Hosts built this by hand before, each in their own way: plant kept a module-global registry
|
|
34
|
+
* with subscribers and re-checked the context on every send, twin closed its session when the
|
|
35
|
+
* anchor moved. Both were writing the same guard around the same absence.
|
|
36
|
+
*
|
|
37
|
+
* Include whatever makes the subject specific — `twin:space:S-12`, `plant:station:3`.
|
|
38
|
+
*/
|
|
39
|
+
id: string;
|
|
40
|
+
/** Who the assistant is here, and what it must not claim to do. */
|
|
41
|
+
systemPrompt?: string;
|
|
42
|
+
/** Which tool categories the server may expose for this conversation. */
|
|
43
|
+
toolCategories?: string[];
|
|
44
|
+
/** What the host knows about the subject; travels with each request. */
|
|
45
|
+
hostContext?: any;
|
|
46
|
+
/** Which server-side chat entry point answers. */
|
|
47
|
+
chatEndpoint?: 'boardAIChat' | 'assistantChat';
|
|
48
|
+
/** What `@` offers. */
|
|
49
|
+
catalogEntries?: any[];
|
|
50
|
+
/**
|
|
51
|
+
* What `/` offers.
|
|
52
|
+
*
|
|
53
|
+
* These are sentences a person could have typed, not tool names — "이 공간의 지금 상태를
|
|
54
|
+
* 요약해줘" cannot be generated from `summarizeStatus`. But **a mode must not offer work it
|
|
55
|
+
* cannot do**: everything here should be answerable by the tools `toolCategories` opens.
|
|
56
|
+
*/
|
|
57
|
+
slashTemplates?: any[];
|
|
58
|
+
/** The i18n key for the line under an empty conversation. */
|
|
59
|
+
footerNoticeKey?: string;
|
|
60
|
+
/** Follow-up chips for a proposal that arrived without its own. */
|
|
61
|
+
proposalChoices?: any[];
|
|
62
|
+
/** The i18n key for the line shown once the host reports a proposal staged. */
|
|
63
|
+
stagedNoticeKey?: string;
|
|
64
|
+
}
|
|
65
|
+
/** True when these two describe different conversations, so the old one should be closed. */
|
|
66
|
+
export declare function isDifferentConversation(a?: AssistantMode, b?: AssistantMode): boolean;
|
|
67
|
+
/** Everything a conversation needs, after the mode, the host's own properties and the
|
|
68
|
+
* registered defaults have been reconciled. */
|
|
69
|
+
export interface ConversationSettings {
|
|
70
|
+
systemPrompt?: string;
|
|
71
|
+
toolCategories?: string[];
|
|
72
|
+
hostContext?: any;
|
|
73
|
+
chatEndpoint: 'boardAIChat' | 'assistantChat';
|
|
74
|
+
catalogEntries: any[];
|
|
75
|
+
slashTemplates: any[];
|
|
76
|
+
footerNoticeKey?: string;
|
|
77
|
+
proposalChoices: any[];
|
|
78
|
+
stagedNoticeKey?: string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Reconcile the three places a setting can come from.
|
|
82
|
+
*
|
|
83
|
+
* **The mode answers first, as a whole.** Then the host's own properties, then whatever was
|
|
84
|
+
* registered globally. The order matters in one direction only: a host that declares a mode
|
|
85
|
+
* must not have half of its conversation decided somewhere else, which is the failure this
|
|
86
|
+
* type exists to remove.
|
|
87
|
+
*
|
|
88
|
+
* A host that declares no mode gets exactly what it got before modes existed — its own
|
|
89
|
+
* properties, falling back to the registered defaults.
|
|
90
|
+
*/
|
|
91
|
+
export declare function resolveConversation(mode: AssistantMode | undefined, own: Partial<ConversationSettings>, defaults: Partial<ConversationSettings>): ConversationSettings;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** True when these two describe different conversations, so the old one should be closed. */
|
|
2
|
+
export function isDifferentConversation(a, b) {
|
|
3
|
+
return (a?.id ?? '') !== (b?.id ?? '');
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Reconcile the three places a setting can come from.
|
|
7
|
+
*
|
|
8
|
+
* **The mode answers first, as a whole.** Then the host's own properties, then whatever was
|
|
9
|
+
* registered globally. The order matters in one direction only: a host that declares a mode
|
|
10
|
+
* must not have half of its conversation decided somewhere else, which is the failure this
|
|
11
|
+
* type exists to remove.
|
|
12
|
+
*
|
|
13
|
+
* A host that declares no mode gets exactly what it got before modes existed — its own
|
|
14
|
+
* properties, falling back to the registered defaults.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveConversation(mode, own, defaults) {
|
|
17
|
+
const pick = (key) => mode?.[key] ?? own[key] ?? defaults[key];
|
|
18
|
+
return {
|
|
19
|
+
systemPrompt: pick('systemPrompt'),
|
|
20
|
+
toolCategories: pick('toolCategories'),
|
|
21
|
+
hostContext: pick('hostContext'),
|
|
22
|
+
chatEndpoint: pick('chatEndpoint') ?? 'boardAIChat',
|
|
23
|
+
catalogEntries: pick('catalogEntries') ?? [],
|
|
24
|
+
slashTemplates: pick('slashTemplates') ?? [],
|
|
25
|
+
footerNoticeKey: pick('footerNoticeKey'),
|
|
26
|
+
proposalChoices: pick('proposalChoices') ?? [],
|
|
27
|
+
stagedNoticeKey: pick('stagedNoticeKey')
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=assistant-mode.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"assistant-mode.js","sourceRoot":"","sources":["../../client/components/assistant-mode.ts"],"names":[],"mappings":"AA0EA,6FAA6F;AAC7F,MAAM,UAAU,uBAAuB,CAAC,CAAiB,EAAE,CAAiB;IAC1E,OAAO,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,CAAC,CAAA;AACxC,CAAC;AAgBD;;;;;;;;;;GAUG;AACH,MAAM,UAAU,mBAAmB,CACjC,IAA+B,EAC/B,GAAkC,EAClC,QAAuC;IAEvC,MAAM,IAAI,GAAG,CAAuC,GAAM,EAA2B,EAAE,CACpF,IAAY,EAAE,CAAC,GAAG,CAAC,IAAK,GAAW,CAAC,GAAG,CAAC,IAAK,QAAgB,CAAC,GAAG,CAAC,CAAA;IAErE,OAAO;QACL,YAAY,EAAE,IAAI,CAAC,cAAc,CAAC;QAClC,cAAc,EAAE,IAAI,CAAC,gBAAgB,CAAC;QACtC,WAAW,EAAE,IAAI,CAAC,aAAa,CAAC;QAChC,YAAY,EAAE,IAAI,CAAC,cAAc,CAAC,IAAI,aAAa;QACnD,cAAc,EAAE,IAAI,CAAC,gBAAgB,CAAC,IAAI,EAAE;QAC5C,cAAc,EAAE,IAAI,CAAC,gBAAgB,CAAC,IAAI,EAAE;QAC5C,eAAe,EAAE,IAAI,CAAC,iBAAiB,CAAC;QACxC,eAAe,EAAE,IAAI,CAAC,iBAAiB,CAAC,IAAI,EAAE;QAC9C,eAAe,EAAE,IAAI,CAAC,iBAAiB,CAAC;KACzC,CAAA;AACH,CAAC","sourcesContent":["/**\n * A mode is what a conversation *is*: who the assistant is, what it can do, what the talk is\n * about, what it offers to start with, and when it is over.\n *\n * Those five used to be four separate properties on the chat element plus one global registry,\n * and every host wired them one at a time. Separate means they can disagree, and they did: a\n * screen whose `systemPrompt` said \"you do not edit boards here\" offered `/add-monitor` in the\n * same panel, because the prompt was a property and the slash list was global.\n *\n * So a host declares one value and hands it over. What cannot be split cannot fall out of step.\n *\n * ```ts\n * const SPACE_MODE = (spaceId: string): AssistantMode => ({\n * id: `twin:space:${spaceId}`,\n * systemPrompt: '...',\n * toolCategories: ['twin'],\n * hostContext: { spaceId },\n * slashTemplates: TWIN_SLASH_TEMPLATES\n * })\n *\n * html`<ox-assistant-chat .mode=${SPACE_MODE(this.spaceId)}></ox-assistant-chat>`\n * ```\n */\nexport interface AssistantMode {\n /**\n * What this conversation is about, as a string the host can compare.\n *\n * **The conversation's lifetime hangs on this.** When it changes, the chat closes what is on\n * screen and starts again — because a conversation about station 3 is not a conversation\n * about station 7, and leaving the old one up means the user reads an answer about a screen\n * they are no longer looking at.\n *\n * Hosts built this by hand before, each in their own way: plant kept a module-global registry\n * with subscribers and re-checked the context on every send, twin closed its session when the\n * anchor moved. Both were writing the same guard around the same absence.\n *\n * Include whatever makes the subject specific — `twin:space:S-12`, `plant:station:3`.\n */\n id: string\n\n /** Who the assistant is here, and what it must not claim to do. */\n systemPrompt?: string\n\n /** Which tool categories the server may expose for this conversation. */\n toolCategories?: string[]\n\n /** What the host knows about the subject; travels with each request. */\n hostContext?: any\n\n /** Which server-side chat entry point answers. */\n chatEndpoint?: 'boardAIChat' | 'assistantChat'\n\n /** What `@` offers. */\n catalogEntries?: any[]\n\n /**\n * What `/` offers.\n *\n * These are sentences a person could have typed, not tool names — \"이 공간의 지금 상태를\n * 요약해줘\" cannot be generated from `summarizeStatus`. But **a mode must not offer work it\n * cannot do**: everything here should be answerable by the tools `toolCategories` opens.\n */\n slashTemplates?: any[]\n\n /** The i18n key for the line under an empty conversation. */\n footerNoticeKey?: string\n\n /** Follow-up chips for a proposal that arrived without its own. */\n proposalChoices?: any[]\n\n /** The i18n key for the line shown once the host reports a proposal staged. */\n stagedNoticeKey?: string\n}\n\n/** True when these two describe different conversations, so the old one should be closed. */\nexport function isDifferentConversation(a?: AssistantMode, b?: AssistantMode): boolean {\n return (a?.id ?? '') !== (b?.id ?? '')\n}\n\n/** Everything a conversation needs, after the mode, the host's own properties and the\n * registered defaults have been reconciled. */\nexport interface ConversationSettings {\n systemPrompt?: string\n toolCategories?: string[]\n hostContext?: any\n chatEndpoint: 'boardAIChat' | 'assistantChat'\n catalogEntries: any[]\n slashTemplates: any[]\n footerNoticeKey?: string\n proposalChoices: any[]\n stagedNoticeKey?: string\n}\n\n/**\n * Reconcile the three places a setting can come from.\n *\n * **The mode answers first, as a whole.** Then the host's own properties, then whatever was\n * registered globally. The order matters in one direction only: a host that declares a mode\n * must not have half of its conversation decided somewhere else, which is the failure this\n * type exists to remove.\n *\n * A host that declares no mode gets exactly what it got before modes existed — its own\n * properties, falling back to the registered defaults.\n */\nexport function resolveConversation(\n mode: AssistantMode | undefined,\n own: Partial<ConversationSettings>,\n defaults: Partial<ConversationSettings>\n): ConversationSettings {\n const pick = <K extends keyof ConversationSettings>(key: K): ConversationSettings[K] =>\n (mode as any)?.[key] ?? (own as any)[key] ?? (defaults as any)[key]\n\n return {\n systemPrompt: pick('systemPrompt'),\n toolCategories: pick('toolCategories'),\n hostContext: pick('hostContext'),\n chatEndpoint: pick('chatEndpoint') ?? 'boardAIChat',\n catalogEntries: pick('catalogEntries') ?? [],\n slashTemplates: pick('slashTemplates') ?? [],\n footerNoticeKey: pick('footerNoticeKey'),\n proposalChoices: pick('proposalChoices') ?? [],\n stagedNoticeKey: pick('stagedNoticeKey')\n }\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A mode is one value, and the point of it is that the parts of a conversation cannot
|
|
3
|
+
* disagree with each other.
|
|
4
|
+
*
|
|
5
|
+
* The defect this guards against shipped: a screen whose systemPrompt said "you do not edit
|
|
6
|
+
* boards here" offered `/add-monitor` in the same panel, because the prompt was an element
|
|
7
|
+
* property and the slash list was a module global. Any resolution that lets a mode supply one
|
|
8
|
+
* of them while something else supplies the other brings it back.
|
|
9
|
+
*/
|
|
10
|
+
import { isDifferentConversation, resolveConversation } from './assistant-mode';
|
|
11
|
+
const MODE = {
|
|
12
|
+
id: 'twin:space:S-12',
|
|
13
|
+
systemPrompt: 'You are looking at one space. You do not edit boards here.',
|
|
14
|
+
toolCategories: ['twin'],
|
|
15
|
+
hostContext: { spaceId: 'S-12' },
|
|
16
|
+
slashTemplates: [{ name: 'status', template: '이 공간의 지금 상태를 요약해줘' }]
|
|
17
|
+
};
|
|
18
|
+
const HOST_PROPERTIES = {
|
|
19
|
+
systemPrompt: 'a prompt the host set the old way',
|
|
20
|
+
toolCategories: ['board-ai'],
|
|
21
|
+
hostContext: { boardId: 'B-1' },
|
|
22
|
+
chatEndpoint: 'boardAIChat'
|
|
23
|
+
};
|
|
24
|
+
const GLOBAL_DEFAULTS = {
|
|
25
|
+
catalogEntries: [{ type: 'rect' }],
|
|
26
|
+
slashTemplates: [{ name: 'add-monitor', template: '모니터를 추가해줘' }],
|
|
27
|
+
footerNoticeKey: 'text.previewed-on-the-board'
|
|
28
|
+
};
|
|
29
|
+
describe('resolveConversation', () => {
|
|
30
|
+
test('a mode answers before the host properties it overlaps', () => {
|
|
31
|
+
const c = resolveConversation(MODE, HOST_PROPERTIES, GLOBAL_DEFAULTS);
|
|
32
|
+
expect(c.systemPrompt).toBe(MODE.systemPrompt);
|
|
33
|
+
expect(c.toolCategories).toEqual(['twin']);
|
|
34
|
+
expect(c.hostContext).toEqual({ spaceId: 'S-12' });
|
|
35
|
+
});
|
|
36
|
+
test('a mode answers before the global defaults — the prompt and the slash list cannot disagree', () => {
|
|
37
|
+
const c = resolveConversation(MODE, HOST_PROPERTIES, GLOBAL_DEFAULTS);
|
|
38
|
+
expect(c.slashTemplates).toEqual(MODE.slashTemplates);
|
|
39
|
+
/* A mode that says it does not edit boards must not offer a board edit. */
|
|
40
|
+
expect(JSON.stringify(c.slashTemplates)).not.toContain('add-monitor');
|
|
41
|
+
});
|
|
42
|
+
test('what a mode leaves out still comes from the defaults', () => {
|
|
43
|
+
const c = resolveConversation(MODE, HOST_PROPERTIES, GLOBAL_DEFAULTS);
|
|
44
|
+
expect(c.catalogEntries).toEqual(GLOBAL_DEFAULTS.catalogEntries);
|
|
45
|
+
expect(c.footerNoticeKey).toBe(GLOBAL_DEFAULTS.footerNoticeKey);
|
|
46
|
+
});
|
|
47
|
+
test('a host with no mode gets exactly what it had before modes existed', () => {
|
|
48
|
+
const c = resolveConversation(undefined, HOST_PROPERTIES, GLOBAL_DEFAULTS);
|
|
49
|
+
expect(c.systemPrompt).toBe(HOST_PROPERTIES.systemPrompt);
|
|
50
|
+
expect(c.toolCategories).toEqual(HOST_PROPERTIES.toolCategories);
|
|
51
|
+
expect(c.slashTemplates).toEqual(GLOBAL_DEFAULTS.slashTemplates);
|
|
52
|
+
});
|
|
53
|
+
test('nothing anywhere resolves to empty lists and the board endpoint, never undefined', () => {
|
|
54
|
+
const c = resolveConversation(undefined, {}, {});
|
|
55
|
+
expect(c.catalogEntries).toEqual([]);
|
|
56
|
+
expect(c.slashTemplates).toEqual([]);
|
|
57
|
+
expect(c.proposalChoices).toEqual([]);
|
|
58
|
+
expect(c.chatEndpoint).toBe('boardAIChat');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('isDifferentConversation', () => {
|
|
62
|
+
const station3 = { id: 'plant:station:3' };
|
|
63
|
+
test('a different subject ends the conversation', () => {
|
|
64
|
+
expect(isDifferentConversation(station3, { id: 'plant:station:7' })).toBe(true);
|
|
65
|
+
expect(isDifferentConversation(undefined, station3)).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
test('rewording the same subject does not', () => {
|
|
68
|
+
expect(isDifferentConversation(station3, { ...station3, systemPrompt: 'reworded' })).toBe(false);
|
|
69
|
+
expect(isDifferentConversation(undefined, undefined)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
//# sourceMappingURL=assistant-mode.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"assistant-mode.test.js","sourceRoot":"","sources":["../../client/components/assistant-mode.test.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * A mode is one value, and the point of it is that the parts of a conversation cannot\n * disagree with each other.\n *\n * The defect this guards against shipped: a screen whose systemPrompt said \"you do not edit\n * boards here\" offered `/add-monitor` in the same panel, because the prompt was an element\n * property and the slash list was a module global. Any resolution that lets a mode supply one\n * of them while something else supplies the other brings it back.\n */\nimport { isDifferentConversation, resolveConversation, type AssistantMode } from './assistant-mode'\n\nconst MODE: AssistantMode = {\n id: 'twin:space:S-12',\n systemPrompt: 'You are looking at one space. You do not edit boards here.',\n toolCategories: ['twin'],\n hostContext: { spaceId: 'S-12' },\n slashTemplates: [{ name: 'status', template: '이 공간의 지금 상태를 요약해줘' }]\n}\n\nconst HOST_PROPERTIES = {\n systemPrompt: 'a prompt the host set the old way',\n toolCategories: ['board-ai'],\n hostContext: { boardId: 'B-1' },\n chatEndpoint: 'boardAIChat' as const\n}\n\nconst GLOBAL_DEFAULTS = {\n catalogEntries: [{ type: 'rect' }],\n slashTemplates: [{ name: 'add-monitor', template: '모니터를 추가해줘' }],\n footerNoticeKey: 'text.previewed-on-the-board'\n}\n\ndescribe('resolveConversation', () => {\n test('a mode answers before the host properties it overlaps', () => {\n const c = resolveConversation(MODE, HOST_PROPERTIES, GLOBAL_DEFAULTS)\n expect(c.systemPrompt).toBe(MODE.systemPrompt)\n expect(c.toolCategories).toEqual(['twin'])\n expect(c.hostContext).toEqual({ spaceId: 'S-12' })\n })\n\n test('a mode answers before the global defaults — the prompt and the slash list cannot disagree', () => {\n const c = resolveConversation(MODE, HOST_PROPERTIES, GLOBAL_DEFAULTS)\n expect(c.slashTemplates).toEqual(MODE.slashTemplates)\n /* A mode that says it does not edit boards must not offer a board edit. */\n expect(JSON.stringify(c.slashTemplates)).not.toContain('add-monitor')\n })\n\n test('what a mode leaves out still comes from the defaults', () => {\n const c = resolveConversation(MODE, HOST_PROPERTIES, GLOBAL_DEFAULTS)\n expect(c.catalogEntries).toEqual(GLOBAL_DEFAULTS.catalogEntries)\n expect(c.footerNoticeKey).toBe(GLOBAL_DEFAULTS.footerNoticeKey)\n })\n\n test('a host with no mode gets exactly what it had before modes existed', () => {\n const c = resolveConversation(undefined, HOST_PROPERTIES, GLOBAL_DEFAULTS)\n expect(c.systemPrompt).toBe(HOST_PROPERTIES.systemPrompt)\n expect(c.toolCategories).toEqual(HOST_PROPERTIES.toolCategories)\n expect(c.slashTemplates).toEqual(GLOBAL_DEFAULTS.slashTemplates)\n })\n\n test('nothing anywhere resolves to empty lists and the board endpoint, never undefined', () => {\n const c = resolveConversation(undefined, {}, {})\n expect(c.catalogEntries).toEqual([])\n expect(c.slashTemplates).toEqual([])\n expect(c.proposalChoices).toEqual([])\n expect(c.chatEndpoint).toBe('boardAIChat')\n })\n})\n\ndescribe('isDifferentConversation', () => {\n const station3: AssistantMode = { id: 'plant:station:3' }\n\n test('a different subject ends the conversation', () => {\n expect(isDifferentConversation(station3, { id: 'plant:station:7' })).toBe(true)\n expect(isDifferentConversation(undefined, station3)).toBe(true)\n })\n\n test('rewording the same subject does not', () => {\n expect(isDifferentConversation(station3, { ...station3, systemPrompt: 'reworded' })).toBe(false)\n expect(isDifferentConversation(undefined, undefined)).toBe(false)\n })\n})\n"]}
|
package/dist-client/index.d.ts
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export * from './components/assistant-chat.js';
|
|
7
7
|
export * from './components/chat-defaults.js';
|
|
8
|
+
export * from './components/assistant-mode.js';
|
|
8
9
|
export * from './components/markdown.js';
|
|
9
10
|
export * from './components/chat-echo-dedup.js';
|
|
10
11
|
export * from './components/chat-input-builder.js';
|
|
11
12
|
export * from './components/mention-popup.js';
|
|
12
13
|
export * from './components/mention-popup-helpers.js';
|
|
13
|
-
export * from './utils/board-edit-patch.js';
|
|
14
14
|
export * from './utils/assistant-session-controller.js';
|
|
15
15
|
export * from './utils/assistant-session-transport.js';
|
|
16
16
|
export * from './components/assistant-session-toolbar.js';
|
package/dist-client/index.js
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export * from './components/assistant-chat.js';
|
|
7
7
|
export * from './components/chat-defaults.js';
|
|
8
|
+
export * from './components/assistant-mode.js';
|
|
8
9
|
export * from './components/markdown.js';
|
|
9
10
|
export * from './components/chat-echo-dedup.js';
|
|
10
11
|
export * from './components/chat-input-builder.js';
|
|
11
12
|
export * from './components/mention-popup.js';
|
|
12
13
|
export * from './components/mention-popup-helpers.js';
|
|
13
|
-
export * from './utils/board-edit-patch.js';
|
|
14
14
|
export * from './utils/assistant-session-controller.js';
|
|
15
15
|
export * from './utils/assistant-session-transport.js';
|
|
16
16
|
export * from './components/assistant-session-toolbar.js';
|
package/dist-client/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../client/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,cAAc,gCAAgC,CAAA;AAC9C,cAAc,+BAA+B,CAAA;AAC7C,cAAc,0BAA0B,CAAA;AACxC,cAAc,iCAAiC,CAAA;AAC/C,cAAc,oCAAoC,CAAA;AAClD,cAAc,+BAA+B,CAAA;AAC7C,cAAc,uCAAuC,CAAA;AACrD,cAAc,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../client/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,cAAc,gCAAgC,CAAA;AAC9C,cAAc,+BAA+B,CAAA;AAC7C,cAAc,gCAAgC,CAAA;AAC9C,cAAc,0BAA0B,CAAA;AACxC,cAAc,iCAAiC,CAAA;AAC/C,cAAc,oCAAoC,CAAA;AAClD,cAAc,+BAA+B,CAAA;AAC7C,cAAc,uCAAuC,CAAA;AACrD,cAAc,yCAAyC,CAAA;AACvD,cAAc,wCAAwC,CAAA;AACtD,cAAc,2CAA2C,CAAA;AACzD,cAAc,sCAAsC,CAAA","sourcesContent":["/**\n * `@things-factory/ai-assistant` — 대화면.\n *\n * 표준 컴포넌트는 `<ox-assistant-chat>` 이다.\n */\nexport * from './components/assistant-chat.js'\nexport * from './components/chat-defaults.js'\nexport * from './components/assistant-mode.js'\nexport * from './components/markdown.js'\nexport * from './components/chat-echo-dedup.js'\nexport * from './components/chat-input-builder.js'\nexport * from './components/mention-popup.js'\nexport * from './components/mention-popup-helpers.js'\nexport * from './utils/assistant-session-controller.js'\nexport * from './utils/assistant-session-transport.js'\nexport * from './components/assistant-session-toolbar.js'\nexport * from './utils/assistant-request-context.js'\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/ai-assistant",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.25",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh",
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"@operato/graphql": "^10.0.0",
|
|
30
30
|
"@operato/i18n": "^10.0.0",
|
|
31
31
|
"@operato/styles": "^10.0.0",
|
|
32
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
33
|
-
"@things-factory/auth-base": "^10.1.
|
|
32
|
+
"@things-factory/ai-client-base": "^10.1.25",
|
|
33
|
+
"@things-factory/auth-base": "^10.1.24",
|
|
34
34
|
"@things-factory/env": "^10.1.20",
|
|
35
35
|
"@things-factory/shell": "^10.1.20",
|
|
36
36
|
"dompurify": "^3.0.0",
|
|
@@ -43,6 +43,5 @@
|
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"copyfiles": "^2.4.1",
|
|
45
45
|
"rimraf": "^5.0.0"
|
|
46
|
-
}
|
|
47
|
-
"gitHead": "6bd01660a4b28554bbe6ae4ee58f3ff844c5af69"
|
|
46
|
+
}
|
|
48
47
|
}
|
|
@@ -1,374 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Client-side BoardEditPatch types + applier.
|
|
3
|
-
*
|
|
4
|
-
* NOTE: 동일 정의가 server/service/types.ts + apply-patch.ts 에 있음.
|
|
5
|
-
* client 번들이 server 코드를 import 하지 않도록 분리. 변경 시 양쪽 동기화 필요.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* 컴포넌트 타깃팅은 항상 `refid` (things-scene universal numeric handle).
|
|
10
|
-
* `id` 는 데이터 바인딩 이름이며 unique 가 아니므로 targeting 에 사용하지 않는다.
|
|
11
|
-
*
|
|
12
|
-
* 보드는 최상위 부모 — 자체 속성 (fillStyle / width / height / name 등) 을 갖고,
|
|
13
|
-
* `modifyBoard` 로 변경. 자식 컴포넌트 변경 (`modify`) 와 분리.
|
|
14
|
-
*
|
|
15
|
-
* Phase 2 — Scene 조작 op (align/distribute/group/ungroup/zorder). model 차원
|
|
16
|
-
* 시뮬레이션은 noop 이고 things-scene API 가 정본.
|
|
17
|
-
*/
|
|
18
|
-
export type AlignDirection =
|
|
19
|
-
| 'left'
|
|
20
|
-
| 'right'
|
|
21
|
-
| 'center'
|
|
22
|
-
| 'top'
|
|
23
|
-
| 'middle'
|
|
24
|
-
| 'bottom'
|
|
25
|
-
|
|
26
|
-
export type DistributeAxis = 'horizontal' | 'vertical'
|
|
27
|
-
|
|
28
|
-
export type ZorderDirection = 'front' | 'back' | 'forward' | 'backward'
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Sugar layout — `arrange` op 의 layout 종류.
|
|
32
|
-
* Server-side types.ts 의 ArrangeLayout 와 일치 — drift test 가 검증.
|
|
33
|
-
*/
|
|
34
|
-
export type ArrangeLayout =
|
|
35
|
-
| { type: 'grid'; cols: number; gap?: number; anchor?: { left: number; top: number } }
|
|
36
|
-
| {
|
|
37
|
-
type: 'row'
|
|
38
|
-
gap?: number
|
|
39
|
-
anchor?: { left: number; top: number }
|
|
40
|
-
align?: 'start' | 'center' | 'end'
|
|
41
|
-
}
|
|
42
|
-
| {
|
|
43
|
-
type: 'column'
|
|
44
|
-
gap?: number
|
|
45
|
-
anchor?: { left: number; top: number }
|
|
46
|
-
align?: 'start' | 'center' | 'end'
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export type BoardEditOp =
|
|
50
|
-
| { op: 'add'; component: any }
|
|
51
|
-
| { op: 'remove'; refid: number }
|
|
52
|
-
| { op: 'modify'; refid: number; patch: any }
|
|
53
|
-
| { op: 'modifyBoard'; patch: any }
|
|
54
|
-
| { op: 'replace'; board: any }
|
|
55
|
-
| { op: 'align'; refids: number[]; direction: AlignDirection }
|
|
56
|
-
| { op: 'distribute'; refids: number[]; axis: DistributeAxis }
|
|
57
|
-
| { op: 'group'; refids: number[] }
|
|
58
|
-
| { op: 'ungroup'; refid: number }
|
|
59
|
-
| { op: 'zorder'; refid: number; direction: ZorderDirection }
|
|
60
|
-
| { op: 'arrange'; refids: number[]; layout: ArrangeLayout }
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* C-1 — Scene 조작 action (ephemeral). 모델 변경 X, undo 영향 X.
|
|
64
|
-
* 호스트가 board-action-execute 이벤트로 받아서 things-scene API 직접 호출.
|
|
65
|
-
*/
|
|
66
|
-
export type BoardActionOp =
|
|
67
|
-
| { action: 'selectComponents'; refids: number[] }
|
|
68
|
-
| { action: 'centerToComponent'; refid: number; animated?: boolean }
|
|
69
|
-
| { action: 'fitToView'; mode?: 'fit' | 'ratio' | 'width' | 'height' }
|
|
70
|
-
| { action: 'setSceneMode'; mode: 'edit' | 'view' }
|
|
71
|
-
| { action: 'highlightComponents'; refids: number[] }
|
|
72
|
-
|
|
73
|
-
export interface BoardEditPatch {
|
|
74
|
-
ops: BoardEditOp[]
|
|
75
|
-
summary: string
|
|
76
|
-
confidence: number
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export interface PatchApplyReport {
|
|
80
|
-
/** 패치 적용 후 보드. 모든 op 가 noop 이어도 입력 그대로 반환. */
|
|
81
|
-
board: any
|
|
82
|
-
/** 실제로 보드를 바꾼 op 들. */
|
|
83
|
-
applied: BoardEditOp[]
|
|
84
|
-
/** id 매칭 실패 등으로 noop 이 된 op 들 — 호출자가 사용자에게 알릴 단서. */
|
|
85
|
-
missed: BoardEditOp[]
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const EMPTY_BOARD = { width: 1000, height: 600, components: [] as any[] }
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Patch 를 BoardModel 에 적용 (pure, board 를 mutate 하지 않음).
|
|
92
|
-
*/
|
|
93
|
-
export function applyBoardEditPatch(board: any | undefined, patch: BoardEditPatch): any {
|
|
94
|
-
return applyBoardEditPatchVerbose(board, patch).board
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Verbose 변형 — 각 op 의 적용 여부를 보고.
|
|
99
|
-
*
|
|
100
|
-
* `modify` 와 `remove` 는 id 가 보드에 없으면 silent no-op 이 된다. LLM 이 잘못된
|
|
101
|
-
* id 를 만들어 보내면 사용자에게 "수정했습니다" 라고 답하지만 실제로는 아무 변화도
|
|
102
|
-
* 없는 상황이 발생 — 호스트가 missed 를 보고 사용자에게 경고할 수 있도록 별도
|
|
103
|
-
* entry point 제공.
|
|
104
|
-
*/
|
|
105
|
-
export function applyBoardEditPatchVerbose(
|
|
106
|
-
board: any | undefined,
|
|
107
|
-
patch: BoardEditPatch
|
|
108
|
-
): PatchApplyReport {
|
|
109
|
-
let result: any = board ?? EMPTY_BOARD
|
|
110
|
-
const applied: BoardEditOp[] = []
|
|
111
|
-
const missed: BoardEditOp[] = []
|
|
112
|
-
|
|
113
|
-
for (const op of patch.ops) {
|
|
114
|
-
if (SCENE_ONLY_OPS.has(op.op)) {
|
|
115
|
-
// scene-only — model 차원 noop, scene 차원 적용. applied 분류.
|
|
116
|
-
applied.push(op)
|
|
117
|
-
continue
|
|
118
|
-
}
|
|
119
|
-
const next = applyOp(result, op)
|
|
120
|
-
// componentsUnchanged 는 components 배열 동일 + width/height/fillStyle 동일 만 본다.
|
|
121
|
-
// modify / remove 의 silent no-op (ghost refid) 검출 전용 — modifyBoard 가 sky
|
|
122
|
-
// 등 다른 root 키만 바꿀 때 false negative 로 missed 분류되는 회귀 방지.
|
|
123
|
-
const componentMutating = op.op === 'modify' || op.op === 'remove'
|
|
124
|
-
if (next === result || (componentMutating && componentsUnchanged(result, next))) {
|
|
125
|
-
missed.push(op)
|
|
126
|
-
} else {
|
|
127
|
-
applied.push(op)
|
|
128
|
-
result = next
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
return { board: result, applied, missed }
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function componentsUnchanged(prev: any, next: any): boolean {
|
|
136
|
-
// applyOp 는 항상 새 객체를 만든다. 따라서 reference 비교가 안 되고 내용 비교 필요.
|
|
137
|
-
// components 는 map/filter 결과 reference 도 다를 수 있으므로 length + 요소 ref 비교.
|
|
138
|
-
const a = prev.components ?? []
|
|
139
|
-
const b = next.components ?? []
|
|
140
|
-
if (a.length !== b.length) return false
|
|
141
|
-
for (let i = 0; i < a.length; i++) {
|
|
142
|
-
if (a[i] !== b[i]) return false
|
|
143
|
-
}
|
|
144
|
-
return prev.width === next.width && prev.height === next.height && prev.fillStyle === next.fillStyle
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const SCENE_ONLY_OPS = new Set<BoardEditOp['op']>([
|
|
148
|
-
'align',
|
|
149
|
-
'distribute',
|
|
150
|
-
'group',
|
|
151
|
-
'ungroup',
|
|
152
|
-
'zorder',
|
|
153
|
-
'arrange'
|
|
154
|
-
])
|
|
155
|
-
|
|
156
|
-
function applyOp(board: any, op: BoardEditOp): any {
|
|
157
|
-
switch (op.op) {
|
|
158
|
-
case 'replace':
|
|
159
|
-
return op.board
|
|
160
|
-
case 'add':
|
|
161
|
-
return { ...board, components: [...(board.components || []), op.component] }
|
|
162
|
-
case 'remove':
|
|
163
|
-
// 자식 (group/container 안) refid 도 매칭 — deep search.
|
|
164
|
-
return {
|
|
165
|
-
...board,
|
|
166
|
-
components: removeComponentDeep(board.components || [], op.refid)
|
|
167
|
-
}
|
|
168
|
-
case 'modify':
|
|
169
|
-
// 자식 refid 도 매칭 — group/container 안의 컴포넌트 수정 가능.
|
|
170
|
-
return {
|
|
171
|
-
...board,
|
|
172
|
-
components: modifyComponentDeep(board.components || [], op.refid, op.patch)
|
|
173
|
-
}
|
|
174
|
-
case 'modifyBoard': {
|
|
175
|
-
const patch = { ...(op.patch || {}) }
|
|
176
|
-
delete patch.components // 자식 변경은 별도 op
|
|
177
|
-
return mergeComponent(board, patch) // 최상위 board 자체에 deep-merge
|
|
178
|
-
}
|
|
179
|
-
case 'align':
|
|
180
|
-
case 'distribute':
|
|
181
|
-
case 'group':
|
|
182
|
-
case 'ungroup':
|
|
183
|
-
case 'zorder':
|
|
184
|
-
case 'arrange':
|
|
185
|
-
// scene-only — model 차원 noop. things-scene API 가 정본.
|
|
186
|
-
return board
|
|
187
|
-
default:
|
|
188
|
-
return board
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* 주어진 board 상태에서 op 의 inverse 를 계산.
|
|
194
|
-
*
|
|
195
|
-
* Revert 기능 — patch 적용 직전 board + op 로 역연산을 만든다. 호스트가
|
|
196
|
-
* 누적해두면 나중에 역순 실행만으로 복원.
|
|
197
|
-
*
|
|
198
|
-
* add 의 inverse 는 새로 발급될 refid 를 알아야 → 모델 단계에서 계산 불가.
|
|
199
|
-
* 호스트가 scene.add 직후 refid 를 캡처해 직접 만들 것.
|
|
200
|
-
*/
|
|
201
|
-
export function computeInverseOp(board: any, op: BoardEditOp): BoardEditOp | null {
|
|
202
|
-
if (!board) return null
|
|
203
|
-
const components = board.components ?? []
|
|
204
|
-
|
|
205
|
-
switch (op.op) {
|
|
206
|
-
case 'add':
|
|
207
|
-
return null
|
|
208
|
-
|
|
209
|
-
case 'remove': {
|
|
210
|
-
// 자식 refid 도 매칭 (deep search)
|
|
211
|
-
const target = findComponentDeep(components, op.refid)
|
|
212
|
-
if (!target) return null
|
|
213
|
-
return { op: 'add', component: JSON.parse(JSON.stringify(target)) }
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
case 'modify': {
|
|
217
|
-
// 자식 refid 도 매칭 (deep search)
|
|
218
|
-
const target = findComponentDeep(components, op.refid)
|
|
219
|
-
if (!target) return null
|
|
220
|
-
const oldValues: any = {}
|
|
221
|
-
for (const k of Object.keys(op.patch || {})) {
|
|
222
|
-
const v = (target as any)[k]
|
|
223
|
-
oldValues[k] = v === undefined ? null : JSON.parse(JSON.stringify(v))
|
|
224
|
-
}
|
|
225
|
-
return { op: 'modify', refid: op.refid, patch: oldValues }
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
case 'modifyBoard': {
|
|
229
|
-
const oldValues: any = {}
|
|
230
|
-
const patch = op.patch || {}
|
|
231
|
-
for (const k of Object.keys(patch)) {
|
|
232
|
-
if (k === 'components') continue
|
|
233
|
-
const v = (board as any)[k]
|
|
234
|
-
oldValues[k] = v === undefined ? null : JSON.parse(JSON.stringify(v))
|
|
235
|
-
}
|
|
236
|
-
return { op: 'modifyBoard', patch: oldValues }
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
case 'replace':
|
|
240
|
-
return { op: 'replace', board: JSON.parse(JSON.stringify(board)) }
|
|
241
|
-
|
|
242
|
-
case 'zorder': {
|
|
243
|
-
// forward ↔ backward / front ↔ back. front/back 의 inverse 는 정확하지
|
|
244
|
-
// 않을 수 있음 (best-effort) — 정확 inverse 는 호스트가 zorder 직전 index
|
|
245
|
-
// 를 캡처해서 명시 modify 시퀀스로 만들어야.
|
|
246
|
-
const opp: Record<string, 'front' | 'back' | 'forward' | 'backward'> = {
|
|
247
|
-
forward: 'backward',
|
|
248
|
-
backward: 'forward',
|
|
249
|
-
front: 'back',
|
|
250
|
-
back: 'front'
|
|
251
|
-
}
|
|
252
|
-
const dir = opp[op.direction]
|
|
253
|
-
if (!dir) return null
|
|
254
|
-
return { op: 'zorder', refid: op.refid, direction: dir }
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
case 'align':
|
|
258
|
-
case 'distribute':
|
|
259
|
-
case 'group':
|
|
260
|
-
case 'ungroup':
|
|
261
|
-
case 'arrange':
|
|
262
|
-
// scene 호출 후에야 변경 결과 알 수 있음. 호스트가 직접 inverse 만든다.
|
|
263
|
-
return null
|
|
264
|
-
|
|
265
|
-
default:
|
|
266
|
-
return null
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* 컴포넌트에 부분 patch 를 적용 (deep merge).
|
|
272
|
-
* threeD 등 nested object 는 deep merge — 색만 바꾸려고 했을 때 geometry 까지 사라지지 않도록.
|
|
273
|
-
*
|
|
274
|
-
* patch 내 `null` 의 시맨틱 — **"이 키를 제거하라"**:
|
|
275
|
-
* - fillStyle/strokeStyle 처럼 `string | object | undefined` 인 필드는 null 로
|
|
276
|
-
* set 하면 typeof null === 'object' 함정에 빠져 다운스트림 (3D 텍스처 등) 이 크래시.
|
|
277
|
-
* - computeInverseOp 가 base 에 키가 없던 (`undefined`) 경우 inverse 를 null 로
|
|
278
|
-
* 발급하므로, 이 시맨틱과 정확히 일치 — inverse 가 자연스럽게 올바로 동작.
|
|
279
|
-
*
|
|
280
|
-
* host (board-modeller-page) 에서 things-scene 의 component.set(merged) 호출 전에 사용.
|
|
281
|
-
*/
|
|
282
|
-
export function mergeComponent(base: any, patch: any): any {
|
|
283
|
-
const out: any = { ...base }
|
|
284
|
-
for (const key of Object.keys(patch)) {
|
|
285
|
-
const patchVal = patch[key]
|
|
286
|
-
if (patchVal === null) {
|
|
287
|
-
delete out[key]
|
|
288
|
-
} else if (isPlainObject(base[key]) && isPlainObject(patchVal)) {
|
|
289
|
-
out[key] = deepMerge(base[key], patchVal)
|
|
290
|
-
} else {
|
|
291
|
-
out[key] = patchVal
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
return out
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function deepMerge(a: any, b: any): any {
|
|
298
|
-
const out: any = { ...a }
|
|
299
|
-
for (const key of Object.keys(b)) {
|
|
300
|
-
const bv = b[key]
|
|
301
|
-
if (bv === null) {
|
|
302
|
-
delete out[key]
|
|
303
|
-
} else if (isPlainObject(a[key]) && isPlainObject(bv)) {
|
|
304
|
-
out[key] = deepMerge(a[key], bv)
|
|
305
|
-
} else {
|
|
306
|
-
out[key] = bv
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
return out
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function isPlainObject(v: any): boolean {
|
|
313
|
-
return v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// ── 자식 컴포넌트 (group/container 안) 까지 매칭 — deep search ─
|
|
317
|
-
// server/service/apply-patch.ts 와 동일 로직. drift test 가 동등성 보장.
|
|
318
|
-
|
|
319
|
-
function findComponentDeep(components: any[], refid: number): any | undefined {
|
|
320
|
-
for (const c of components) {
|
|
321
|
-
if (!c) continue
|
|
322
|
-
if (c.refid === refid) return c
|
|
323
|
-
if (Array.isArray(c.components) && c.components.length > 0) {
|
|
324
|
-
const sub = findComponentDeep(c.components, refid)
|
|
325
|
-
if (sub) return sub
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
return undefined
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function modifyComponentDeep(components: any[], refid: number, patch: any): any[] {
|
|
332
|
-
let changed = false
|
|
333
|
-
const out = components.map(c => {
|
|
334
|
-
if (!c) return c
|
|
335
|
-
if (c.refid === refid) {
|
|
336
|
-
changed = true
|
|
337
|
-
return mergeComponent(c, patch)
|
|
338
|
-
}
|
|
339
|
-
if (Array.isArray(c.components) && c.components.length > 0) {
|
|
340
|
-
const updated = modifyComponentDeep(c.components, refid, patch)
|
|
341
|
-
if (updated !== c.components) {
|
|
342
|
-
changed = true
|
|
343
|
-
return { ...c, components: updated }
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
return c
|
|
347
|
-
})
|
|
348
|
-
return changed ? out : components
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
function removeComponentDeep(components: any[], refid: number): any[] {
|
|
352
|
-
let changed = false
|
|
353
|
-
const out: any[] = []
|
|
354
|
-
for (const c of components) {
|
|
355
|
-
if (!c) {
|
|
356
|
-
out.push(c)
|
|
357
|
-
continue
|
|
358
|
-
}
|
|
359
|
-
if (c.refid === refid) {
|
|
360
|
-
changed = true
|
|
361
|
-
continue
|
|
362
|
-
}
|
|
363
|
-
if (Array.isArray(c.components) && c.components.length > 0) {
|
|
364
|
-
const updated = removeComponentDeep(c.components, refid)
|
|
365
|
-
if (updated !== c.components) {
|
|
366
|
-
changed = true
|
|
367
|
-
out.push({ ...c, components: updated })
|
|
368
|
-
continue
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
out.push(c)
|
|
372
|
-
}
|
|
373
|
-
return changed ? out : components
|
|
374
|
-
}
|