Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions server/hocuspocus/src/extractPlainTextFromYXml.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import * as Y from "yjs";
import { buildContentPreview, extractTextFromYXml } from "./extractPlainTextFromYXml.js";

describe("extractTextFromYXml", () => {
it("does not insert a newline inside a paragraph between inline bold and following text", () => {
const doc = new Y.Doc();
doc.transact(() => {
const fragment = doc.getXmlFragment("default");
const paragraph = new Y.XmlElement("paragraph");
fragment.push([paragraph]);
const t1 = new Y.XmlText();
t1.insert(0, "Hello ");
paragraph.push([t1]);
const bold = new Y.XmlElement("bold");
paragraph.push([bold]);
const tBold = new Y.XmlText();
tBold.insert(0, "world");
bold.push([tBold]);
const t2 = new Y.XmlText();
t2.insert(0, "!");
paragraph.push([t2]);
});

const plain = extractTextFromYXml(doc.getXmlFragment("default")).trim();
expect(plain).not.toMatch(/world\s*\n\s*!/);
expect(plain.replace(/\s+/g, " ").trim()).toBe("Hello world !");
});

it("separates block-level paragraphs with newlines", () => {
const doc = new Y.Doc();
doc.transact(() => {
const fragment = doc.getXmlFragment("default");
const p1 = new Y.XmlElement("paragraph");
fragment.push([p1]);
const a = new Y.XmlText();
a.insert(0, "First");
p1.push([a]);
const p2 = new Y.XmlElement("paragraph");
fragment.push([p2]);
const b = new Y.XmlText();
b.insert(0, "Second");
p2.push([b]);
});

const plain = extractTextFromYXml(doc.getXmlFragment("default")).trim();
expect(plain).toMatch(/First/);
expect(plain).toMatch(/Second/);
expect(plain.includes("First") && plain.includes("Second")).toBe(true);
expect(/\n/.test(plain)).toBe(true);
});
});

describe("buildContentPreview", () => {
it("collapses whitespace and truncates", () => {
expect(buildContentPreview(" a \n b ")).toBe("a b");
const long = "x".repeat(200);
const prev = buildContentPreview(long);
expect(prev.endsWith("...")).toBe(true);
expect(prev.length).toBeLessThanOrEqual(124);
});
});
65 changes: 65 additions & 0 deletions server/hocuspocus/src/extractPlainTextFromYXml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import * as Y from "yjs";

/**
* TipTap / ProseMirror のマーク相当で、Y.Xml 上に子要素として現れるインライン名。
* 兄弟インライン間では改行ではなくスペースを挟み、プレビュー用テキストの不自然な改行を防ぐ。
*
* Mark-like XmlElement names in the Y.Xml tree; use a space (not a newline) between
* inline siblings so plain-text extraction does not split words (e.g. `Hello world!`).
*/
const INLINE_XML_ELEMENT_NAMES = new Set<string>([
"bold",
"italic",
"strike",
"code",
"link",
"underline",
"highlight",
"subscript",
"superscript",
"textStyle",
]);

/**
* インライン要素かどうかを nodeName で判定する。
* Determine whether an XmlElement is inline-only (no trailing newline after its subtree).
*/
function isInlineXmlElement(node: Y.XmlElement): boolean {
return INLINE_XML_ELEMENT_NAMES.has(node.nodeName);
}

/**
* Y.Doc の XmlFragment(または XmlElement 根)からプレーンテキストを再帰的に抽出する。
* Recursively extract plain text from a Y.XmlFragment or Y.XmlElement subtree.
*/
export function extractTextFromYXml(node: Y.XmlFragment | Y.XmlElement): string {
let text = "";

for (let i = 0; i < node.length; i++) {
const child = node.get(i);
if (child instanceof Y.XmlText) {
text += child.toString();
} else if (child instanceof Y.XmlElement) {
const inner = extractTextFromYXml(child);
const suffix = isInlineXmlElement(child) ? " " : "\n";
text += inner + suffix;
}
}
Comment on lines +38 to +47
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Yjsの共有型(XmlFragmentやXmlElement)において、node.lengthnode.get(i) を組み合わせたループは $O(N^2)$ の計算量になります。これは、lengthget の両方が内部の連結リストを走査するためです。ドキュメントが大きくなった際のパフォーマンス低下を避けるため、node.toArray() を使用して配列に変換してからループを回すか、firstChildnextSibling を使用したイテレーションを推奨します。

  for (const child of node.toArray()) {
    if (child instanceof Y.XmlText) {
      text += child.toString();
    } else if (child instanceof Y.XmlElement) {
      const inner = extractTextFromYXml(child);
      const suffix = isInlineXmlElement(child) ? " " : "\n";
      text += inner + suffix;
    }
  }

return text;
}

/**
* プレビュー文字列の最大長(DB の content_preview と一致させる)。
* Max length for content preview (aligned with `pages.content_preview`).
*/
export const CONTENT_PREVIEW_MAX_LENGTH = 120;

/**
* プレーンテキストからコンテンツプレビュー(先頭120文字)を生成する。
* Generate content preview (first 120 chars) from plain text.
*/
export function buildContentPreview(text: string): string {
const trimmed = text.trim().replace(/\s+/g, " ");
if (trimmed.length <= CONTENT_PREVIEW_MAX_LENGTH) return trimmed;
return trimmed.slice(0, CONTENT_PREVIEW_MAX_LENGTH).trim() + "...";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

DBの content_preview カラムのサイズ制限が120文字である場合、現在の実装では slice(0, 120) の後に ... を付与しているため、合計で最大123文字になります。これにより保存時にデータ切り詰めやエラーが発生する可能性があります。制限が厳密に120文字である場合は、省略記号を含めて120文字に収まるように調整(例:slice(0, CONTENT_PREVIEW_MAX_LENGTH - 3))することを検討してください。

Suggested change
return trimmed.slice(0, CONTENT_PREVIEW_MAX_LENGTH).trim() + "...";
return trimmed.slice(0, CONTENT_PREVIEW_MAX_LENGTH - 3).trim() + "...";

}
33 changes: 2 additions & 31 deletions server/hocuspocus/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
isTruthyEnvFlag,
warnDevAuthBypassOnce,
} from "./dev-auth-bypass.js";
import { buildContentPreview, extractTextFromYXml } from "./extractPlainTextFromYXml.js";

const PORT = parseInt(process.env.PORT || "1234", 10);
const REDIS_URL = process.env.REDIS_URL;
Expand Down Expand Up @@ -193,39 +194,9 @@ async function loadDocumentFromDb(pageId: string): Promise<Y.Doc> {
}
}

/**
* Y.Doc の XmlFragment からプレーンテキストを再帰的に抽出するヘルパー。
* Recursively extract plain text from a Y.Doc XmlFragment.
*/
function extractTextFromFragment(node: Y.XmlFragment): string {
let text = "";

for (let i = 0; i < node.length; i++) {
const child = node.get(i);
if (child instanceof Y.XmlText) {
text += child.toString();
} else if (child instanceof Y.XmlElement) {
text += extractTextFromFragment(child) + "\n";
}
}
return text;
}

const CONTENT_PREVIEW_MAX_LENGTH = 120;

/**
* プレーンテキストからコンテンツプレビュー(先頭120文字)を生成する。
* Generate content preview (first 120 chars) from plain text.
*/
function buildContentPreview(text: string): string {
const trimmed = text.trim().replace(/\s+/g, " ");
if (trimmed.length <= CONTENT_PREVIEW_MAX_LENGTH) return trimmed;
return trimmed.slice(0, CONTENT_PREVIEW_MAX_LENGTH).trim() + "...";
}

async function saveDocumentToDb(pageId: string, document: Y.Doc): Promise<void> {
const encodedState = Buffer.from(Y.encodeStateAsUpdate(document));
const contentText = extractTextFromFragment(document.getXmlFragment("default"));
const contentText = extractTextFromYXml(document.getXmlFragment("default"));
const contentPreview = buildContentPreview(contentText);
const client = await getPool().connect();
try {
Expand Down
4 changes: 2 additions & 2 deletions src/components/ai-chat/AIChatWikiLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ export function AIChatWikiLink({ title }: AIChatWikiLinkProps) {

const [isOpen, setIsOpen] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const longPressTimerRef = useRef<number>();
const preventClickResetTimerRef = useRef<number>();
const longPressTimerRef = useRef<number | undefined>(undefined);
const preventClickResetTimerRef = useRef<number | undefined>(undefined);
const preventClickRef = useRef(false);

const handleTouchStart = useCallback(() => {
Expand Down
6 changes: 6 additions & 0 deletions src/lib/aiClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,5 +152,11 @@ describe("aiClient", () => {
expect(result.success).toBe(false);
expect(result.message).toContain("APIキー");
});

it("returns Claude Code guidance when API key is empty (no API key required)", async () => {
const result = await testConnection("claude-code", "");
expect(result.success).toBe(false);
expect(result.message).toContain("Claude Code");
});
});
});
5 changes: 3 additions & 2 deletions src/lib/aiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,8 +311,9 @@ export async function testConnection(
provider: AIProviderType,
apiKey: string,
): Promise<ConnectionTestResult> {
// APIキーが必要
if (!apiKey || apiKey.trim() === "") {
// Claude Code は API キー不要のため、空キーでも専用メッセージへ進める。
// Claude Code does not use an API key; allow reaching the provider-specific branch.
if (provider !== "claude-code" && (!apiKey || apiKey.trim() === "")) {
return {
success: false,
message: "APIキーを入力してください",
Expand Down
Loading