mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-09 02:53:45 +00:00
Compare commits
10 Commits
0.23.0-bet
...
v0.23.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e034185cf | ||
|
|
2be3f84196 | ||
|
|
f46d288b1b | ||
|
|
9529adf33e | ||
|
|
03aeb44dc9 | ||
|
|
c9aad0d55e | ||
|
|
29ae6afe71 | ||
|
|
32787bc88b | ||
|
|
bbafce2c40 | ||
|
|
f7f69c3bc4 |
1
.github/workflows/release-mobile.yml
vendored
1
.github/workflows/release-mobile.yml
vendored
@@ -132,6 +132,7 @@ jobs:
|
||||
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
fastlane beta
|
||||
env:
|
||||
BUILD_TARGET: distribution
|
||||
BUILD_PROVISION_PROFILE: ${{ secrets.BUILD_PROVISION_PROFILE }}
|
||||
PP_PATH: ${{ runner.temp }}/build_pp.mobileprovision
|
||||
APPLE_STORE_CONNECT_API_KEY_ID: ${{ secrets.APPLE_STORE_CONNECT_API_KEY_ID }}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { NoteBlockModel } from '@blocksuite/affine-model';
|
||||
import { DocModeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { isInsideEdgelessEditor } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
isInsideEdgelessEditor,
|
||||
matchModels,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { Constructor } from '@blocksuite/global/utils';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import {
|
||||
GfxBlockElementModel,
|
||||
GfxControllerIdentifier,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
import type { LitElement, TemplateResult } from 'lit';
|
||||
|
||||
@@ -72,6 +79,20 @@ export const Peekable =
|
||||
);
|
||||
|
||||
if (hitTarget && hitTarget !== model) {
|
||||
// Check if hitTarget is a GfxBlockElementModel (which extends BlockModel)
|
||||
// and if it's a NoteBlockModel, then check if current model is inside it
|
||||
if (
|
||||
hitTarget instanceof GfxBlockElementModel &&
|
||||
matchModels(hitTarget, [NoteBlockModel])
|
||||
) {
|
||||
let curModel: BlockModel | null = model;
|
||||
while (curModel) {
|
||||
if (curModel === hitTarget) {
|
||||
return true; // Model is inside the NoteBlockModel, allow peek
|
||||
}
|
||||
curModel = curModel.parent;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ai_sessions_metadata" ADD COLUMN "title" VARCHAR;
|
||||
@@ -122,15 +122,15 @@ model Workspace {
|
||||
avatarKey String? @map("avatar_key") @db.VarChar
|
||||
indexed Boolean @default(false)
|
||||
|
||||
features WorkspaceFeature[]
|
||||
docs WorkspaceDoc[]
|
||||
permissions WorkspaceUserRole[]
|
||||
docPermissions WorkspaceDocUserRole[]
|
||||
blobs Blob[]
|
||||
ignoredDocs AiWorkspaceIgnoredDocs[]
|
||||
embedFiles AiWorkspaceFiles[]
|
||||
comments Comment[]
|
||||
commentAttachments CommentAttachment[]
|
||||
features WorkspaceFeature[]
|
||||
docs WorkspaceDoc[]
|
||||
permissions WorkspaceUserRole[]
|
||||
docPermissions WorkspaceDocUserRole[]
|
||||
blobs Blob[]
|
||||
ignoredDocs AiWorkspaceIgnoredDocs[]
|
||||
embedFiles AiWorkspaceFiles[]
|
||||
comments Comment[]
|
||||
commentAttachments CommentAttachment[]
|
||||
|
||||
@@map("workspaces")
|
||||
}
|
||||
@@ -443,6 +443,7 @@ model AiSession {
|
||||
promptName String @map("prompt_name") @db.VarChar(32)
|
||||
promptAction String? @default("") @map("prompt_action") @db.VarChar(32)
|
||||
pinned Boolean @default(false)
|
||||
title String? @db.VarChar
|
||||
// the session id of the parent session if this session is a forked session
|
||||
parentSessionId String? @map("parent_session_id") @db.VarChar
|
||||
messageCost Int @default(0)
|
||||
@@ -900,8 +901,8 @@ model Reply {
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([commentId, sid])
|
||||
@@index([workspaceId, docId, updatedAt])
|
||||
@@ -911,19 +912,19 @@ model Reply {
|
||||
|
||||
model CommentAttachment {
|
||||
// NOTE: manually set this column type to identity in migration file
|
||||
sid Int @unique @default(autoincrement())
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
docId String @map("doc_id") @db.VarChar
|
||||
key String @db.VarChar
|
||||
size Int @db.Integer
|
||||
mime String @db.VarChar
|
||||
name String @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
createdBy String? @map("created_by") @db.VarChar
|
||||
sid Int @unique @default(autoincrement())
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
docId String @map("doc_id") @db.VarChar
|
||||
key String @db.VarChar
|
||||
size Int @db.Integer
|
||||
mime String @db.VarChar
|
||||
name String @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
createdBy String? @map("created_by") @db.VarChar
|
||||
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
// will delete creator record if creator's account is deleted
|
||||
createdByUser User? @relation(name: "createdCommentAttachments", fields: [createdBy], references: [id], onDelete: SetNull)
|
||||
createdByUser User? @relation(name: "createdCommentAttachments", fields: [createdBy], references: [id], onDelete: SetNull)
|
||||
|
||||
@@id([workspaceId, docId, key])
|
||||
@@map("comment_attachments")
|
||||
|
||||
@@ -330,3 +330,45 @@ Generated by [AVA](https://avajs.dev).
|
||||
],
|
||||
{},
|
||||
]
|
||||
|
||||
## should handle generateSessionTitle correctly under various conditions
|
||||
|
||||
> should generate title when conditions are met
|
||||
|
||||
{
|
||||
chatWithPromptCalled: undefined,
|
||||
exists: true,
|
||||
title: 'What is Machine Learning?',
|
||||
}
|
||||
|
||||
> should not generate title when session already has title
|
||||
|
||||
{
|
||||
chatWithPromptCalled: false,
|
||||
exists: true,
|
||||
title: 'Existing Title',
|
||||
}
|
||||
|
||||
> should not generate title when no user messages exist
|
||||
|
||||
{
|
||||
chatWithPromptCalled: false,
|
||||
exists: true,
|
||||
title: null,
|
||||
}
|
||||
|
||||
> should not generate title when no assistant messages exist
|
||||
|
||||
{
|
||||
chatWithPromptCalled: false,
|
||||
exists: true,
|
||||
title: null,
|
||||
}
|
||||
|
||||
> should use correct prompt for title generation
|
||||
|
||||
{
|
||||
content: `[user]: Explain quantum computing briefly␊
|
||||
[assistant]: Quantum computing uses quantum mechanics principles.`,
|
||||
promptName: 'Summary as title',
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -11,7 +11,11 @@ import { EventBus, JobQueue } from '../base';
|
||||
import { ConfigModule } from '../base/config';
|
||||
import { AuthService } from '../core/auth';
|
||||
import { QuotaModule } from '../core/quota';
|
||||
import { ContextCategories, WorkspaceModel } from '../models';
|
||||
import {
|
||||
ContextCategories,
|
||||
CopilotSessionModel,
|
||||
WorkspaceModel,
|
||||
} from '../models';
|
||||
import { CopilotModule } from '../plugins/copilot';
|
||||
import { CopilotContextService } from '../plugins/copilot/context';
|
||||
import {
|
||||
@@ -57,12 +61,13 @@ import { MockCopilotProvider } from './mocks';
|
||||
import { createTestingModule, TestingModule } from './utils';
|
||||
import { WorkflowTestCases } from './utils/copilot';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
type Context = {
|
||||
auth: AuthService;
|
||||
module: TestingModule;
|
||||
db: PrismaClient;
|
||||
event: EventBus;
|
||||
workspace: WorkspaceModel;
|
||||
copilotSession: CopilotSessionModel;
|
||||
context: CopilotContextService;
|
||||
prompt: PromptService;
|
||||
transcript: CopilotTranscriptionService;
|
||||
@@ -78,7 +83,8 @@ const test = ava as TestFn<{
|
||||
html: CopilotCheckHtmlExecutor;
|
||||
json: CopilotCheckJsonExecutor;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
const test = ava as TestFn<Context>;
|
||||
let userId: string;
|
||||
|
||||
test.before(async t => {
|
||||
@@ -119,6 +125,7 @@ test.before(async t => {
|
||||
const db = module.get(PrismaClient);
|
||||
const event = module.get(EventBus);
|
||||
const workspace = module.get(WorkspaceModel);
|
||||
const copilotSession = module.get(CopilotSessionModel);
|
||||
const prompt = module.get(PromptService);
|
||||
const factory = module.get(CopilotProviderFactory);
|
||||
|
||||
@@ -136,6 +143,7 @@ test.before(async t => {
|
||||
t.context.db = db;
|
||||
t.context.event = event;
|
||||
t.context.workspace = workspace;
|
||||
t.context.copilotSession = copilotSession;
|
||||
t.context.prompt = prompt;
|
||||
t.context.factory = factory;
|
||||
t.context.session = session;
|
||||
@@ -1752,3 +1760,168 @@ test('should be able to manage workspace embedding', async t => {
|
||||
t.is(ret2.length, 0, 'should not match workspace context');
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle generateSessionTitle correctly under various conditions', async t => {
|
||||
const { prompt, session, workspace, copilotSession } = t.context;
|
||||
|
||||
await prompt.set('test', 'model', [{ role: 'user', content: '{{content}}' }]);
|
||||
const createSession = async (
|
||||
options: {
|
||||
userMessage?: string;
|
||||
assistantMessage?: string;
|
||||
existingTitle?: string;
|
||||
} = {}
|
||||
) => {
|
||||
const ws = await workspace.create(userId);
|
||||
const sessionId = await session.create({
|
||||
docId: 'test-doc',
|
||||
workspaceId: ws.id,
|
||||
userId,
|
||||
promptName: 'test',
|
||||
pinned: false,
|
||||
});
|
||||
|
||||
if (options.existingTitle) {
|
||||
await copilotSession.update({
|
||||
userId,
|
||||
sessionId,
|
||||
title: options.existingTitle,
|
||||
});
|
||||
}
|
||||
|
||||
const chatSession = await session.get(sessionId);
|
||||
if (chatSession) {
|
||||
if (options.userMessage) {
|
||||
chatSession.push({
|
||||
role: 'user',
|
||||
content: options.userMessage,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
}
|
||||
if (options.assistantMessage) {
|
||||
chatSession.push({
|
||||
role: 'assistant',
|
||||
content: options.assistantMessage,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
}
|
||||
await chatSession.save();
|
||||
}
|
||||
|
||||
return sessionId;
|
||||
};
|
||||
|
||||
const testCases = [
|
||||
{
|
||||
name: 'should generate title when conditions are met',
|
||||
setup: () =>
|
||||
createSession({
|
||||
userMessage: 'What is machine learning?',
|
||||
assistantMessage:
|
||||
'Machine learning is a subset of artificial intelligence.',
|
||||
}),
|
||||
mockFn: () => 'What is Machine Learning?',
|
||||
expectSnapshot: true,
|
||||
},
|
||||
{
|
||||
name: 'should not generate title when session already has title',
|
||||
setup: () =>
|
||||
createSession({
|
||||
userMessage: 'Test message',
|
||||
assistantMessage: 'Test response',
|
||||
existingTitle: 'Existing Title',
|
||||
}),
|
||||
mockFn: () => 'New Title',
|
||||
expectSnapshot: true,
|
||||
expectNotCalled: true,
|
||||
},
|
||||
{
|
||||
name: 'should not generate title when no user messages exist',
|
||||
setup: () =>
|
||||
createSession({ assistantMessage: 'Hello! How can I help you?' }),
|
||||
mockFn: () => 'New Title',
|
||||
expectSnapshot: true,
|
||||
expectNotCalled: true,
|
||||
},
|
||||
{
|
||||
name: 'should not generate title when no assistant messages exist',
|
||||
setup: () => createSession({ userMessage: 'What is AI?' }),
|
||||
mockFn: () => 'New Title',
|
||||
expectSnapshot: true,
|
||||
expectNotCalled: true,
|
||||
},
|
||||
{
|
||||
name: 'should handle errors gracefully',
|
||||
setup: () =>
|
||||
createSession({
|
||||
userMessage: 'Test question',
|
||||
assistantMessage: 'Test answer',
|
||||
}),
|
||||
mockFn: () => {
|
||||
throw new Error('Mock error for testing');
|
||||
},
|
||||
expectError: 'Mock error for testing',
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const sessionId = await testCase.setup();
|
||||
let chatWithPromptCalled = false;
|
||||
|
||||
const mockStub = Sinon.stub(session, 'chatWithPrompt').callsFake(
|
||||
async () => {
|
||||
chatWithPromptCalled = true;
|
||||
return testCase.mockFn();
|
||||
}
|
||||
);
|
||||
|
||||
if (testCase.expectError) {
|
||||
await t.throwsAsync(
|
||||
() => session.generateSessionTitle({ sessionId }),
|
||||
{ message: testCase.expectError },
|
||||
testCase.name
|
||||
);
|
||||
} else {
|
||||
await session.generateSessionTitle({ sessionId });
|
||||
|
||||
if (testCase.expectSnapshot) {
|
||||
const sessionState = await session.getSession(sessionId);
|
||||
t.snapshot(
|
||||
{
|
||||
chatWithPromptCalled: testCase.expectNotCalled
|
||||
? chatWithPromptCalled
|
||||
: undefined,
|
||||
title: sessionState?.title,
|
||||
exists: !!sessionState,
|
||||
},
|
||||
testCase.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mockStub.restore();
|
||||
}
|
||||
|
||||
{
|
||||
const sessionId = await createSession({
|
||||
userMessage: 'Explain quantum computing briefly',
|
||||
assistantMessage: 'Quantum computing uses quantum mechanics principles.',
|
||||
});
|
||||
|
||||
let capturedArgs: any[] = [];
|
||||
Sinon.stub(session, 'chatWithPrompt').callsFake(async (...args) => {
|
||||
capturedArgs = args;
|
||||
return 'Quantum Computing Explained';
|
||||
});
|
||||
|
||||
await session.generateSessionTitle({ sessionId });
|
||||
|
||||
t.snapshot(
|
||||
{
|
||||
promptName: capturedArgs[0],
|
||||
content: capturedArgs[1]?.content,
|
||||
},
|
||||
'should use correct prompt for title generation'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -58,6 +58,7 @@ test.beforeEach(async t => {
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: user.id,
|
||||
title: null,
|
||||
promptName: 'prompt-name',
|
||||
promptAction: null,
|
||||
});
|
||||
|
||||
@@ -82,6 +82,7 @@ const createTestSession = async (
|
||||
workspaceId: workspace.id,
|
||||
docId: null,
|
||||
pinned: false,
|
||||
title: null,
|
||||
promptName: TEST_PROMPTS.NORMAL,
|
||||
promptAction: null,
|
||||
...overrides,
|
||||
@@ -297,6 +298,7 @@ test('should pin and unpin sessions', async t => {
|
||||
promptName: 'test-prompt',
|
||||
promptAction: null,
|
||||
pinned: true,
|
||||
title: null,
|
||||
});
|
||||
|
||||
const firstSession = await copilotSession.get(firstSessionId);
|
||||
@@ -312,6 +314,7 @@ test('should pin and unpin sessions', async t => {
|
||||
promptName: 'test-prompt',
|
||||
promptAction: null,
|
||||
pinned: true,
|
||||
title: null,
|
||||
});
|
||||
|
||||
const sessionStatesAfterSecondPin = await getSessionStates(db, [
|
||||
@@ -796,6 +799,7 @@ test('should handle fork and session attachment operations', async t => {
|
||||
workspaceId: workspace.id,
|
||||
docId: forkConfig.docId,
|
||||
pinned: forkConfig.pinned,
|
||||
title: null,
|
||||
parentSessionId,
|
||||
prompt: { name: TEST_PROMPTS.NORMAL, action: null, model: 'gpt-4.1' },
|
||||
messages: [
|
||||
|
||||
@@ -50,6 +50,7 @@ type PureChatSession = {
|
||||
workspaceId: string;
|
||||
docId?: string | null;
|
||||
pinned?: boolean;
|
||||
title: string | null;
|
||||
messages?: ChatMessage[];
|
||||
// connect ids
|
||||
userId: string;
|
||||
@@ -82,7 +83,7 @@ type UpdateChatSessionMessage = ChatSessionBaseState & {
|
||||
};
|
||||
|
||||
export type UpdateChatSessionOptions = ChatSessionBaseState &
|
||||
Pick<Partial<ChatSession>, 'docId' | 'pinned' | 'promptName'>;
|
||||
Pick<Partial<ChatSession>, 'docId' | 'pinned' | 'promptName' | 'title'>;
|
||||
|
||||
export type UpdateChatSession = ChatSessionBaseState & UpdateChatSessionOptions;
|
||||
|
||||
@@ -254,7 +255,7 @@ export class CopilotSessionModel extends BaseModel {
|
||||
return (await this.db.aiSession.findUnique({
|
||||
where: { ...where, id: sessionId, deletedAt: null },
|
||||
select,
|
||||
})) as Prisma.AiSessionGetPayload<{ select: Select }>;
|
||||
})) as Prisma.AiSessionGetPayload<{ select: Select }> | null;
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
@@ -266,6 +267,7 @@ export class CopilotSessionModel extends BaseModel {
|
||||
docId: true,
|
||||
pinned: true,
|
||||
parentSessionId: true,
|
||||
title: true,
|
||||
messages: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -331,6 +333,7 @@ export class CopilotSessionModel extends BaseModel {
|
||||
docId: true,
|
||||
parentSessionId: true,
|
||||
pinned: true,
|
||||
title: true,
|
||||
promptName: true,
|
||||
tokenCost: true,
|
||||
createdAt: true,
|
||||
@@ -373,7 +376,7 @@ export class CopilotSessionModel extends BaseModel {
|
||||
|
||||
@Transactional()
|
||||
async update(options: UpdateChatSessionOptions): Promise<string> {
|
||||
const { userId, sessionId, docId, promptName, pinned } = options;
|
||||
const { userId, sessionId, docId, promptName, pinned, title } = options;
|
||||
const session = await this.getExists(
|
||||
sessionId,
|
||||
{
|
||||
@@ -419,7 +422,7 @@ export class CopilotSessionModel extends BaseModel {
|
||||
|
||||
await this.db.aiSession.update({
|
||||
where: { id: sessionId },
|
||||
data: { docId, promptName, pinned },
|
||||
data: { docId, promptName, pinned, title },
|
||||
});
|
||||
|
||||
return sessionId;
|
||||
@@ -522,17 +525,29 @@ export class CopilotSessionModel extends BaseModel {
|
||||
if (!id) {
|
||||
throw new CopilotSessionNotFound();
|
||||
}
|
||||
const ids = await this.getMessages(id, { id: true, role: true }).then(
|
||||
roles =>
|
||||
roles
|
||||
.slice(
|
||||
roles.findLastIndex(({ role }) => role === AiPromptRole.user) +
|
||||
(removeLatestUserMessage ? 0 : 1)
|
||||
)
|
||||
.map(({ id }) => id)
|
||||
);
|
||||
const messages = await this.getMessages(id, { id: true, role: true });
|
||||
const ids = messages
|
||||
.slice(
|
||||
messages.findLastIndex(({ role }) => role === AiPromptRole.user) +
|
||||
(removeLatestUserMessage ? 0 : 1)
|
||||
)
|
||||
.map(({ id }) => id);
|
||||
|
||||
if (ids.length) {
|
||||
await this.db.aiSessionMessage.deleteMany({ where: { id: { in: ids } } });
|
||||
|
||||
// clear the title if there only one round of conversation left
|
||||
const remainingMessages = await this.getMessages(id, { role: true });
|
||||
const userMessageCount = remainingMessages.filter(
|
||||
m => m.role === AiPromptRole.user
|
||||
).length;
|
||||
|
||||
if (userMessageCount <= 1) {
|
||||
await this.db.aiSession.update({
|
||||
where: { id },
|
||||
data: { title: null },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,9 @@ class CreateChatSessionInput {
|
||||
}
|
||||
|
||||
@InputType()
|
||||
class UpdateChatSessionInput implements Omit<UpdateChatSession, 'userId'> {
|
||||
class UpdateChatSessionInput
|
||||
implements Omit<UpdateChatSession, 'userId' | 'title'>
|
||||
{
|
||||
@Field(() => String)
|
||||
sessionId!: string;
|
||||
|
||||
@@ -336,6 +338,9 @@ export class CopilotSessionType {
|
||||
@Field(() => Boolean)
|
||||
pinned!: boolean;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
title!: string | null;
|
||||
|
||||
@Field(() => ID, { nullable: true })
|
||||
parentSessionId!: string | null;
|
||||
|
||||
@@ -653,6 +658,7 @@ export class CopilotResolver {
|
||||
parentSessionId: session.parentSessionId,
|
||||
docId: session.docId,
|
||||
pinned: session.pinned,
|
||||
title: session.title,
|
||||
promptName: session.prompt.name,
|
||||
model: session.prompt.model,
|
||||
optionalModels: session.prompt.optionalModels,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { Transactional } from '@nestjs-cls/transactional';
|
||||
import { AiPromptRole } from '@prisma/client';
|
||||
|
||||
@@ -11,6 +12,9 @@ import {
|
||||
CopilotQuotaExceeded,
|
||||
CopilotSessionInvalidInput,
|
||||
CopilotSessionNotFound,
|
||||
JobQueue,
|
||||
NoCopilotProviderAvailable,
|
||||
OnJob,
|
||||
} from '../../base';
|
||||
import { QuotaService } from '../../core/quota';
|
||||
import {
|
||||
@@ -22,7 +26,12 @@ import {
|
||||
} from '../../models';
|
||||
import { ChatMessageCache } from './message';
|
||||
import { PromptService } from './prompt';
|
||||
import { PromptMessage, PromptParams } from './providers';
|
||||
import {
|
||||
CopilotProviderFactory,
|
||||
ModelOutputType,
|
||||
PromptMessage,
|
||||
PromptParams,
|
||||
} from './providers';
|
||||
import {
|
||||
type ChatHistory,
|
||||
type ChatMessage,
|
||||
@@ -33,6 +42,14 @@ import {
|
||||
type SubmittedMessage,
|
||||
} from './types';
|
||||
|
||||
declare global {
|
||||
interface Jobs {
|
||||
'copilot.session.generateTitle': {
|
||||
sessionId: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ChatSession implements AsyncDisposable {
|
||||
private stashMessageCount = 0;
|
||||
constructor(
|
||||
@@ -224,10 +241,12 @@ export class ChatSessionService {
|
||||
private readonly logger = new Logger(ChatSessionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly moduleRef: ModuleRef,
|
||||
private readonly models: Models,
|
||||
private readonly jobs: JobQueue,
|
||||
private readonly quota: QuotaService,
|
||||
private readonly messageCache: ChatMessageCache,
|
||||
private readonly prompt: PromptService,
|
||||
private readonly models: Models
|
||||
private readonly prompt: PromptService
|
||||
) {}
|
||||
|
||||
async getSession(sessionId: string): Promise<ChatSessionState | undefined> {
|
||||
@@ -244,6 +263,7 @@ export class ChatSessionService {
|
||||
workspaceId: session.workspaceId,
|
||||
docId: session.docId,
|
||||
pinned: session.pinned,
|
||||
title: session.title,
|
||||
parentSessionId: session.parentSessionId,
|
||||
prompt,
|
||||
messages: messages.success ? messages.data : [],
|
||||
@@ -282,6 +302,7 @@ export class ChatSessionService {
|
||||
workspaceId: session.workspaceId,
|
||||
docId: session.docId,
|
||||
pinned: session.pinned,
|
||||
title: session.title,
|
||||
parentSessionId: session.parentSessionId,
|
||||
prompt,
|
||||
};
|
||||
@@ -303,6 +324,7 @@ export class ChatSessionService {
|
||||
workspaceId,
|
||||
docId,
|
||||
pinned,
|
||||
title,
|
||||
promptName,
|
||||
tokenCost,
|
||||
messages,
|
||||
@@ -347,6 +369,7 @@ export class ChatSessionService {
|
||||
workspaceId,
|
||||
docId,
|
||||
pinned,
|
||||
title,
|
||||
action: prompt.action || null,
|
||||
tokens: tokenCost,
|
||||
createdAt,
|
||||
@@ -418,6 +441,7 @@ export class ChatSessionService {
|
||||
...options,
|
||||
sessionId,
|
||||
prompt,
|
||||
title: null,
|
||||
messages: [],
|
||||
// when client create chat session, we always find root session
|
||||
parentSessionId: null,
|
||||
@@ -520,8 +544,78 @@ export class ChatSessionService {
|
||||
if (state) {
|
||||
return new ChatSession(this.messageCache, state, async state => {
|
||||
await this.models.copilotSession.updateMessages(state);
|
||||
if (!state.prompt.action) {
|
||||
await this.jobs.add('copilot.session.generateTitle', { sessionId });
|
||||
}
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// public for test mock
|
||||
async chatWithPrompt(
|
||||
promptName: string,
|
||||
message: Partial<PromptMessage>
|
||||
): Promise<string> {
|
||||
const prompt = await this.prompt.get(promptName);
|
||||
if (!prompt) {
|
||||
throw new CopilotPromptNotFound({ name: promptName });
|
||||
}
|
||||
|
||||
const cond = { modelId: prompt.model };
|
||||
const msg = { role: 'user' as const, content: '', ...message };
|
||||
const config = Object.assign({}, prompt.config);
|
||||
|
||||
const provider = await this.moduleRef
|
||||
.get(CopilotProviderFactory)
|
||||
.getProvider({
|
||||
outputType: ModelOutputType.Text,
|
||||
modelId: prompt.model,
|
||||
});
|
||||
|
||||
if (!provider) {
|
||||
throw new NoCopilotProviderAvailable();
|
||||
}
|
||||
|
||||
return provider.text(cond, [...prompt.finish({}), msg], config);
|
||||
}
|
||||
|
||||
@OnJob('copilot.session.generateTitle')
|
||||
async generateSessionTitle(job: Jobs['copilot.session.generateTitle']) {
|
||||
const { sessionId } = job;
|
||||
|
||||
try {
|
||||
const session = await this.models.copilotSession.get(sessionId);
|
||||
if (!session) {
|
||||
this.logger.warn(
|
||||
`Session ${sessionId} not found when generating title`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { userId, title, messages } = session;
|
||||
if (
|
||||
title ||
|
||||
!messages.length ||
|
||||
messages.filter(m => m.role === 'user').length === 0 ||
|
||||
messages.filter(m => m.role === 'assistant').length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
const title = await this.chatWithPrompt('Summary as title', {
|
||||
content: session.messages
|
||||
.map(m => `[${m.role}]: ${m.content}`)
|
||||
.join('\n'),
|
||||
});
|
||||
await this.models.copilotSession.update({ userId, sessionId, title });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to generate title for session ${sessionId}:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export const ChatHistorySchema = z
|
||||
workspaceId: z.string(),
|
||||
docId: z.string().nullable(),
|
||||
pinned: z.boolean(),
|
||||
title: z.string().nullable(),
|
||||
action: z.string().nullable(),
|
||||
tokens: z.number(),
|
||||
messages: z.array(ChatMessageSchema),
|
||||
@@ -85,6 +86,7 @@ export interface ChatSessionForkOptions
|
||||
|
||||
export interface ChatSessionState
|
||||
extends Omit<ChatSessionOptions, 'promptName'> {
|
||||
title: string | null;
|
||||
// connect ids
|
||||
sessionId: string;
|
||||
parentSessionId: string | null;
|
||||
|
||||
@@ -324,6 +324,7 @@ type CopilotSessionType {
|
||||
parentSessionId: ID
|
||||
pinned: Boolean!
|
||||
promptName: String!
|
||||
title: String
|
||||
}
|
||||
|
||||
type CopilotWorkspaceConfig {
|
||||
|
||||
@@ -9,6 +9,7 @@ query getCopilotSession(
|
||||
parentSessionId
|
||||
docId
|
||||
pinned
|
||||
title
|
||||
promptName
|
||||
model
|
||||
optionalModels
|
||||
|
||||
@@ -10,6 +10,7 @@ query getCopilotSessions(
|
||||
parentSessionId
|
||||
docId
|
||||
pinned
|
||||
title
|
||||
promptName
|
||||
model
|
||||
optionalModels
|
||||
|
||||
@@ -799,6 +799,7 @@ export const getCopilotSessionQuery = {
|
||||
parentSessionId
|
||||
docId
|
||||
pinned
|
||||
title
|
||||
promptName
|
||||
model
|
||||
optionalModels
|
||||
@@ -848,6 +849,7 @@ export const getCopilotSessionsQuery = {
|
||||
parentSessionId
|
||||
docId
|
||||
pinned
|
||||
title
|
||||
promptName
|
||||
model
|
||||
optionalModels
|
||||
|
||||
@@ -419,6 +419,7 @@ export interface CopilotSessionType {
|
||||
parentSessionId: Maybe<Scalars['ID']['output']>;
|
||||
pinned: Scalars['Boolean']['output'];
|
||||
promptName: Scalars['String']['output'];
|
||||
title: Maybe<Scalars['String']['output']>;
|
||||
}
|
||||
|
||||
export interface CopilotWorkspaceConfig {
|
||||
@@ -3619,6 +3620,7 @@ export type GetCopilotSessionQuery = {
|
||||
parentSessionId: string | null;
|
||||
docId: string | null;
|
||||
pinned: boolean;
|
||||
title: string | null;
|
||||
promptName: string;
|
||||
model: string;
|
||||
optionalModels: Array<string>;
|
||||
@@ -3680,6 +3682,7 @@ export type GetCopilotSessionsQuery = {
|
||||
parentSessionId: string | null;
|
||||
docId: string | null;
|
||||
pinned: boolean;
|
||||
title: string | null;
|
||||
promptName: string;
|
||||
model: string;
|
||||
optionalModels: Array<string>;
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
2E0DD47B57B994A104B25EED /* Pods_AFFiNE.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF48636D7DB5BEE00770FD9A /* Pods_AFFiNE.framework */; };
|
||||
5075136A2D1924C600AD60C0 /* RootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507513692D1924C600AD60C0 /* RootViewController.swift */; };
|
||||
5075136E2D1925BC00AD60C0 /* IntelligentsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5075136D2D1925BC00AD60C0 /* IntelligentsPlugin.swift */; };
|
||||
50802D612D112F8700694021 /* Intelligents in Frameworks */ = {isa = PBXBuildFile; productRef = 50802D602D112F8700694021 /* Intelligents */; settings = {ATTRIBUTES = (Required, ); }; };
|
||||
50A285D72D112A5E000D5A6D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D62D112A5E000D5A6D /* Localizable.xcstrings */; };
|
||||
50A285D82D112A5E000D5A6D /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D52D112A5E000D5A6D /* InfoPlist.xcstrings */; };
|
||||
@@ -56,7 +55,6 @@
|
||||
5039CC962D1D42C700874F32 /* AffineGraphQL */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AffineGraphQL; sourceTree = "<group>"; };
|
||||
504EC3041FED79650016851F /* AFFiNE.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AFFiNE.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
507513692D1924C600AD60C0 /* RootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewController.swift; sourceTree = "<group>"; };
|
||||
5075136D2D1925BC00AD60C0 /* IntelligentsPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntelligentsPlugin.swift; sourceTree = "<group>"; };
|
||||
50802D5E2D112F7D00694021 /* Intelligents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Intelligents; sourceTree = "<group>"; };
|
||||
50A285D52D112A5E000D5A6D /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; };
|
||||
50A285D62D112A5E000D5A6D /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
@@ -92,8 +90,6 @@
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
C45499AB2D140B5000E21978 /* NBStore */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = NBStore;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -179,7 +175,6 @@
|
||||
9D90BE172CCB9876006677DB /* CookieManager.swift */,
|
||||
9D90BE182CCB9876006677DB /* CookiePlugin.swift */,
|
||||
9D6A85322CCF6DA700DAB35F /* HashcashPlugin.swift */,
|
||||
5075136D2D1925BC00AD60C0 /* IntelligentsPlugin.swift */,
|
||||
);
|
||||
path = Cookie;
|
||||
sourceTree = "<group>";
|
||||
@@ -342,9 +337,13 @@
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n";
|
||||
@@ -376,7 +375,6 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
9D52FC432D26CDBF00105D0A /* JSValueContainerExt.swift in Sources */,
|
||||
5075136E2D1925BC00AD60C0 /* IntelligentsPlugin.swift in Sources */,
|
||||
5075136A2D1924C600AD60C0 /* RootViewController.swift in Sources */,
|
||||
C4C97C7C2D030BE000BC2AD1 /* affine_mobile_native.swift in Sources */,
|
||||
9DAE9BD92D8D1AB0000C1D5A /* AppConfigManager.swift in Sources */,
|
||||
|
||||
@@ -9,6 +9,15 @@
|
||||
"version" : "1.22.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "eventsource",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/loopwork-ai/eventsource.git",
|
||||
"state" : {
|
||||
"revision" : "07957602bb99a5355c810187e66e6ce378a1057d",
|
||||
"version" : "1.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "snapkit",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -10,24 +10,26 @@ import UIKit
|
||||
|
||||
extension AFFiNEViewController: IntelligentsButtonDelegate {
|
||||
func onIntelligentsButtonTapped(_ button: IntelligentsButton) {
|
||||
IntelligentContext.shared.webView = webView!
|
||||
button.beginProgress()
|
||||
|
||||
IntelligentContext.shared.preparePresent { result in
|
||||
button.stopProgress()
|
||||
switch result {
|
||||
case .success:
|
||||
let controller = IntelligentsController()
|
||||
self.present(controller, animated: true)
|
||||
case let .failure(failure):
|
||||
let alert = UIAlertController(
|
||||
title: "Error",
|
||||
message: failure.localizedDescription,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||||
self.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
// if it shows up then we are ready to go
|
||||
let controller = IntelligentsController()
|
||||
self.present(controller, animated: true)
|
||||
// IntelligentContext.shared.webView = webView
|
||||
// button.beginProgress()
|
||||
// IntelligentContext.shared.preparePresent { result in
|
||||
// DispatchQueue.main.async {
|
||||
// button.stopProgress()
|
||||
// switch result {
|
||||
// case .success:
|
||||
// case let .failure(failure):
|
||||
// let alert = UIAlertController(
|
||||
// title: "Error",
|
||||
// message: failure.localizedDescription,
|
||||
// preferredStyle: .alert
|
||||
// )
|
||||
// alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||||
// self.present(alert, animated: true)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import Intelligents
|
||||
import UIKit
|
||||
|
||||
class AFFiNEViewController: CAPBridgeViewController {
|
||||
var intelligentsButton: IntelligentsButton?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
webView?.allowsBackForwardNavigationGestures = true
|
||||
@@ -11,6 +13,7 @@ class AFFiNEViewController: CAPBridgeViewController {
|
||||
edgesForExtendedLayout = []
|
||||
let intelligentsButton = installIntelligentsButton()
|
||||
intelligentsButton.delegate = self
|
||||
self.intelligentsButton = intelligentsButton
|
||||
dismissIntelligentsButton()
|
||||
}
|
||||
|
||||
@@ -35,11 +38,45 @@ class AFFiNEViewController: CAPBridgeViewController {
|
||||
plugins.forEach { bridge?.registerPluginInstance($0) }
|
||||
}
|
||||
|
||||
private var intelligentsButtonTimer: Timer?
|
||||
private var isCheckingIntelligentEligibility = false
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
IntelligentContext.shared.webView = webView
|
||||
navigationController?.setNavigationBarHidden(false, animated: animated)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
self.presentIntelligentsButton()
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { [weak self] _ in
|
||||
self?.checkEligibilityOfIntelligent()
|
||||
}
|
||||
intelligentsButtonTimer = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
|
||||
private func checkEligibilityOfIntelligent() {
|
||||
guard !isCheckingIntelligentEligibility else { return }
|
||||
assert(intelligentsButton != nil)
|
||||
guard intelligentsButton?.isHidden ?? false else { return } // already eligible
|
||||
isCheckingIntelligentEligibility = true
|
||||
IntelligentContext.shared.webView = webView
|
||||
IntelligentContext.shared.preparePresent { [self] result in
|
||||
DispatchQueue.main.async {
|
||||
defer { self.isCheckingIntelligentEligibility = false }
|
||||
switch result {
|
||||
case .failure: break
|
||||
case .success:
|
||||
#if DEBUG
|
||||
// only show the button in debug mode before we get done
|
||||
self.presentIntelligentsButton()
|
||||
#else
|
||||
break
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
intelligentsButtonTimer?.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import Capacitor
|
||||
import Foundation
|
||||
|
||||
// @objc(IntelligentsPlugin)
|
||||
// public class IntelligentsPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
// public let identifier = "IntelligentsPlugin"
|
||||
// public let jsName = "Intelligents"
|
||||
// public let pluginMethods: [CAPPluginMethod] = [
|
||||
// CAPPluginMethod(name: "presentIntelligentsButton", returnType: CAPPluginReturnPromise),
|
||||
// CAPPluginMethod(name: "dismissIntelligentsButton", returnType: CAPPluginReturnPromise),
|
||||
// ]
|
||||
// public private(set) weak var representController: UIViewController?
|
||||
//
|
||||
// init(representController: UIViewController) {
|
||||
// self.representController = representController
|
||||
// super.init()
|
||||
// }
|
||||
//
|
||||
// deinit {
|
||||
// representController = nil
|
||||
// }
|
||||
//
|
||||
// @objc public func presentIntelligentsButton(_ call: CAPPluginCall) {
|
||||
// DispatchQueue.main.async {
|
||||
// self.representController?.presentIntelligentsButton()
|
||||
// call.resolve()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// @objc public func dismissIntelligentsButton(_ call: CAPPluginCall) {
|
||||
// DispatchQueue.main.async {
|
||||
// self.representController?.dismissIntelligentsButton()
|
||||
// call.resolve()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,141 @@
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
@_exported import ApolloAPI
|
||||
|
||||
public class GetCopilotLatestDocSessionQuery: GraphQLQuery {
|
||||
public static let operationName: String = "getCopilotLatestDocSession"
|
||||
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
||||
definition: .init(
|
||||
#"query getCopilotLatestDocSession($workspaceId: String!, $docId: String!) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename histories( docId: $docId options: { limit: 1, sessionOrder: desc, action: false, fork: false } ) { __typename sessionId workspaceId docId pinned action tokens createdAt updatedAt messages { __typename id role content attachments params createdAt } } } } }"#
|
||||
))
|
||||
|
||||
public var workspaceId: String
|
||||
public var docId: String
|
||||
|
||||
public init(
|
||||
workspaceId: String,
|
||||
docId: String
|
||||
) {
|
||||
self.workspaceId = workspaceId
|
||||
self.docId = docId
|
||||
}
|
||||
|
||||
public var __variables: Variables? { [
|
||||
"workspaceId": workspaceId,
|
||||
"docId": docId
|
||||
] }
|
||||
|
||||
public struct Data: AffineGraphQL.SelectionSet {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("currentUser", CurrentUser?.self),
|
||||
] }
|
||||
|
||||
/// Get current user
|
||||
public var currentUser: CurrentUser? { __data["currentUser"] }
|
||||
|
||||
/// CurrentUser
|
||||
///
|
||||
/// Parent Type: `UserType`
|
||||
public struct CurrentUser: AffineGraphQL.SelectionSet {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserType }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("__typename", String.self),
|
||||
.field("copilot", Copilot.self, arguments: ["workspaceId": .variable("workspaceId")]),
|
||||
] }
|
||||
|
||||
public var copilot: Copilot { __data["copilot"] }
|
||||
|
||||
/// CurrentUser.Copilot
|
||||
///
|
||||
/// Parent Type: `Copilot`
|
||||
public struct Copilot: AffineGraphQL.SelectionSet {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Copilot }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("__typename", String.self),
|
||||
.field("histories", [History].self, arguments: [
|
||||
"docId": .variable("docId"),
|
||||
"options": [
|
||||
"limit": 1,
|
||||
"sessionOrder": "desc",
|
||||
"action": false,
|
||||
"fork": false
|
||||
]
|
||||
]),
|
||||
] }
|
||||
|
||||
public var histories: [History] { __data["histories"] }
|
||||
|
||||
/// CurrentUser.Copilot.History
|
||||
///
|
||||
/// Parent Type: `CopilotHistories`
|
||||
public struct History: AffineGraphQL.SelectionSet {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CopilotHistories }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("__typename", String.self),
|
||||
.field("sessionId", String.self),
|
||||
.field("workspaceId", String.self),
|
||||
.field("docId", String?.self),
|
||||
.field("pinned", Bool.self),
|
||||
.field("action", String?.self),
|
||||
.field("tokens", Int.self),
|
||||
.field("createdAt", AffineGraphQL.DateTime.self),
|
||||
.field("updatedAt", AffineGraphQL.DateTime.self),
|
||||
.field("messages", [Message].self),
|
||||
] }
|
||||
|
||||
public var sessionId: String { __data["sessionId"] }
|
||||
public var workspaceId: String { __data["workspaceId"] }
|
||||
public var docId: String? { __data["docId"] }
|
||||
public var pinned: Bool { __data["pinned"] }
|
||||
/// An mark identifying which view to use to display the session
|
||||
public var action: String? { __data["action"] }
|
||||
/// The number of tokens used in the session
|
||||
public var tokens: Int { __data["tokens"] }
|
||||
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
|
||||
public var updatedAt: AffineGraphQL.DateTime { __data["updatedAt"] }
|
||||
public var messages: [Message] { __data["messages"] }
|
||||
|
||||
/// CurrentUser.Copilot.History.Message
|
||||
///
|
||||
/// Parent Type: `ChatMessage`
|
||||
public struct Message: AffineGraphQL.SelectionSet {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.ChatMessage }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("__typename", String.self),
|
||||
.field("id", AffineGraphQL.ID?.self),
|
||||
.field("role", String.self),
|
||||
.field("content", String.self),
|
||||
.field("attachments", [String]?.self),
|
||||
.field("params", AffineGraphQL.JSON?.self),
|
||||
.field("createdAt", AffineGraphQL.DateTime.self),
|
||||
] }
|
||||
|
||||
public var id: AffineGraphQL.ID? { __data["id"] }
|
||||
public var role: String { __data["role"] }
|
||||
public var content: String { __data["content"] }
|
||||
public var attachments: [String]? { __data["attachments"] }
|
||||
public var params: AffineGraphQL.JSON? { __data["params"] }
|
||||
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
@_exported import ApolloAPI
|
||||
|
||||
public class GetCopilotRecentSessionsQuery: GraphQLQuery {
|
||||
public static let operationName: String = "getCopilotRecentSessions"
|
||||
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
||||
definition: .init(
|
||||
#"query getCopilotRecentSessions($workspaceId: String!, $limit: Int = 10) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename histories(options: { limit: $limit, sessionOrder: desc }) { __typename sessionId workspaceId docId pinned action tokens createdAt updatedAt } } } }"#
|
||||
))
|
||||
|
||||
public var workspaceId: String
|
||||
public var limit: GraphQLNullable<Int>
|
||||
|
||||
public init(
|
||||
workspaceId: String,
|
||||
limit: GraphQLNullable<Int> = 10
|
||||
) {
|
||||
self.workspaceId = workspaceId
|
||||
self.limit = limit
|
||||
}
|
||||
|
||||
public var __variables: Variables? { [
|
||||
"workspaceId": workspaceId,
|
||||
"limit": limit
|
||||
] }
|
||||
|
||||
public struct Data: AffineGraphQL.SelectionSet {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("currentUser", CurrentUser?.self),
|
||||
] }
|
||||
|
||||
/// Get current user
|
||||
public var currentUser: CurrentUser? { __data["currentUser"] }
|
||||
|
||||
/// CurrentUser
|
||||
///
|
||||
/// Parent Type: `UserType`
|
||||
public struct CurrentUser: AffineGraphQL.SelectionSet {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserType }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("__typename", String.self),
|
||||
.field("copilot", Copilot.self, arguments: ["workspaceId": .variable("workspaceId")]),
|
||||
] }
|
||||
|
||||
public var copilot: Copilot { __data["copilot"] }
|
||||
|
||||
/// CurrentUser.Copilot
|
||||
///
|
||||
/// Parent Type: `Copilot`
|
||||
public struct Copilot: AffineGraphQL.SelectionSet {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Copilot }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("__typename", String.self),
|
||||
.field("histories", [History].self, arguments: ["options": [
|
||||
"limit": .variable("limit"),
|
||||
"sessionOrder": "desc"
|
||||
]]),
|
||||
] }
|
||||
|
||||
public var histories: [History] { __data["histories"] }
|
||||
|
||||
/// CurrentUser.Copilot.History
|
||||
///
|
||||
/// Parent Type: `CopilotHistories`
|
||||
public struct History: AffineGraphQL.SelectionSet {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CopilotHistories }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("__typename", String.self),
|
||||
.field("sessionId", String.self),
|
||||
.field("workspaceId", String.self),
|
||||
.field("docId", String?.self),
|
||||
.field("pinned", Bool.self),
|
||||
.field("action", String?.self),
|
||||
.field("tokens", Int.self),
|
||||
.field("createdAt", AffineGraphQL.DateTime.self),
|
||||
.field("updatedAt", AffineGraphQL.DateTime.self),
|
||||
] }
|
||||
|
||||
public var sessionId: String { __data["sessionId"] }
|
||||
public var workspaceId: String { __data["workspaceId"] }
|
||||
public var docId: String? { __data["docId"] }
|
||||
public var pinned: Bool { __data["pinned"] }
|
||||
/// An mark identifying which view to use to display the session
|
||||
public var action: String? { __data["action"] }
|
||||
/// The number of tokens used in the session
|
||||
public var tokens: Int { __data["tokens"] }
|
||||
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
|
||||
public var updatedAt: AffineGraphQL.DateTime { __data["updatedAt"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ let package = Package(
|
||||
.package(url: "https://github.com/devxoul/Then", from: "3.0.0"),
|
||||
.package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.7.1"),
|
||||
.package(url: "https://github.com/SwifterSwift/SwifterSwift.git", from: "6.0.0"),
|
||||
.package(url: "https://github.com/loopwork-ai/eventsource.git", from: "1.1.1"),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "Intelligents", dependencies: [
|
||||
@@ -28,6 +29,7 @@ let package = Package(
|
||||
"SwifterSwift",
|
||||
.product(name: "Apollo", package: "apollo-ios"),
|
||||
.product(name: "OrderedCollections", package: "swift-collections"),
|
||||
.product(name: "EventSource", package: "eventsource"),
|
||||
], resources: [
|
||||
.process("Interface/View/InputBox/InputBox.xcassets"),
|
||||
.process("Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets"),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// ChatError.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/30/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum ChatError: LocalizedError {
|
||||
case invalidServerConfiguration
|
||||
case invalidStreamURL
|
||||
case invalidResponse
|
||||
case networkError(Error)
|
||||
case unknownError
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidServerConfiguration:
|
||||
"Invalid server configuration"
|
||||
case .invalidStreamURL:
|
||||
"Invalid stream URL"
|
||||
case .invalidResponse:
|
||||
"Invalid response from server"
|
||||
case let .networkError(error):
|
||||
"Network error: \(error.localizedDescription)"
|
||||
case .unknownError:
|
||||
"An unknown error occurred"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//
|
||||
// ChatManager+Closable.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/30/25.
|
||||
//
|
||||
|
||||
import EventSource
|
||||
import Foundation
|
||||
|
||||
protocol Closable { func close() }
|
||||
|
||||
extension EventSource: @preconcurrency Closable {}
|
||||
@@ -1,125 +0,0 @@
|
||||
//
|
||||
// ChatManager+ContextModels.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
//
|
||||
|
||||
import AffineGraphQL
|
||||
import Foundation
|
||||
|
||||
// MARK: - ChatManager Context Models Extension
|
||||
|
||||
extension ChatManager {
|
||||
// MARK: - Context Models
|
||||
|
||||
struct ContextReference: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var fileId: String?
|
||||
var docId: String?
|
||||
var chunk: Int
|
||||
var content: String
|
||||
var distance: Double
|
||||
var highlightedContent: String?
|
||||
|
||||
init(fileId: String? = nil, docId: String? = nil, chunk: Int, content: String, distance: Double, highlightedContent: String? = nil) {
|
||||
id = UUID().uuidString
|
||||
self.fileId = fileId
|
||||
self.docId = docId
|
||||
self.chunk = chunk
|
||||
self.content = content
|
||||
self.distance = distance
|
||||
self.highlightedContent = highlightedContent
|
||||
}
|
||||
}
|
||||
|
||||
struct CopilotContext: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var sessionId: String
|
||||
var workspaceId: String
|
||||
var files: [ContextFile]
|
||||
var docs: [ContextDoc]
|
||||
var categories: [ContextCategory]
|
||||
|
||||
init(id: String, sessionId: String, workspaceId: String, files: [ContextFile] = [], docs: [ContextDoc] = [], categories: [ContextCategory] = []) {
|
||||
self.id = id
|
||||
self.sessionId = sessionId
|
||||
self.workspaceId = workspaceId
|
||||
self.files = files
|
||||
self.docs = docs
|
||||
self.categories = categories
|
||||
}
|
||||
}
|
||||
|
||||
struct ContextFile: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var contextId: String
|
||||
var blobId: String
|
||||
var fileName: String?
|
||||
var fileSize: Int?
|
||||
var mimeType: String?
|
||||
var embeddingStatus: ContextEmbedStatus?
|
||||
var createdAt: DateTime?
|
||||
|
||||
var createdDate: Date? {
|
||||
createdAt?.decoded
|
||||
}
|
||||
}
|
||||
|
||||
struct ContextDoc: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var contextId: String
|
||||
var docId: String
|
||||
var title: String?
|
||||
var embeddingStatus: ContextEmbedStatus?
|
||||
var createdAt: DateTime?
|
||||
|
||||
var createdDate: Date? {
|
||||
createdAt?.decoded
|
||||
}
|
||||
}
|
||||
|
||||
struct ContextCategory: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var contextId: String
|
||||
var type: ContextCategoryType
|
||||
var docs: [String]
|
||||
var name: String?
|
||||
var createdAt: DateTime?
|
||||
|
||||
var createdDate: Date? {
|
||||
createdAt?.decoded
|
||||
}
|
||||
}
|
||||
|
||||
enum ContextEmbedStatus: String, Codable, CaseIterable {
|
||||
case pending = "Pending"
|
||||
case failed = "Failed"
|
||||
case completed = "Completed"
|
||||
}
|
||||
|
||||
enum ContextCategoryType: String, Codable, CaseIterable {
|
||||
case tag = "TAG"
|
||||
case collection = "COLLECTION"
|
||||
}
|
||||
|
||||
struct MatchContextResult: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var fileId: String?
|
||||
var docId: String?
|
||||
var chunk: Int
|
||||
var content: String
|
||||
var distance: Double
|
||||
var highlightedContent: String?
|
||||
|
||||
init(fileId: String? = nil, docId: String? = nil, chunk: Int, content: String, distance: Double, highlightedContent: String? = nil) {
|
||||
id = UUID().uuidString
|
||||
self.fileId = fileId
|
||||
self.docId = docId
|
||||
self.chunk = chunk
|
||||
self.content = content
|
||||
self.distance = distance
|
||||
self.highlightedContent = highlightedContent
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
//
|
||||
// ChatManager+InputModels.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - ChatManager Input Models Extension
|
||||
|
||||
extension ChatManager {
|
||||
// MARK: - Input Models
|
||||
|
||||
struct AddContextFileInput: Codable, Equatable, Hashable {
|
||||
var contextId: String
|
||||
var blobId: String
|
||||
}
|
||||
|
||||
struct RemoveContextFileInput: Codable, Equatable, Hashable {
|
||||
var contextId: String
|
||||
var fileId: String
|
||||
}
|
||||
|
||||
struct AddContextDocInput: Codable, Equatable, Hashable {
|
||||
var contextId: String
|
||||
var docId: String
|
||||
}
|
||||
|
||||
struct RemoveContextDocInput: Codable, Equatable, Hashable {
|
||||
var contextId: String
|
||||
var docId: String
|
||||
}
|
||||
|
||||
struct AddContextCategoryInput: Codable, Equatable, Hashable {
|
||||
var contextId: String
|
||||
var docs: [String]
|
||||
}
|
||||
|
||||
struct RemoveContextCategoryInput: Codable, Equatable, Hashable {
|
||||
var contextId: String
|
||||
var categoryId: String
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//
|
||||
// ChatManager+Stream.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/30/25.
|
||||
//
|
||||
|
||||
import AffineGraphQL
|
||||
import Apollo
|
||||
import ApolloAPI
|
||||
import EventSource
|
||||
import Foundation
|
||||
|
||||
extension ChatManager {
|
||||
public func startUserRequest(
|
||||
content: String,
|
||||
inputBoxData: InputBoxData,
|
||||
sessionId: String
|
||||
) {
|
||||
append(sessionId: sessionId, UserMessageCellViewModel(
|
||||
id: .init(),
|
||||
content: inputBoxData.text,
|
||||
timestamp: .init(),
|
||||
attachments: []
|
||||
))
|
||||
|
||||
let messageParameters: [String: AnyHashable] = [
|
||||
// packages/frontend/core/src/blocksuite/ai/provider/setup-provider.tsx
|
||||
"docs": inputBoxData.documentAttachments.map(\.documentID), // affine doc
|
||||
"files": [String](), // attachment in context, keep nil for now
|
||||
"searchMode": inputBoxData.isSearchEnabled ? "MUST" : "AUTO",
|
||||
]
|
||||
let uploadableAttachments: [GraphQLFile] = [
|
||||
inputBoxData.fileAttachments.map { file -> GraphQLFile in
|
||||
.init(
|
||||
fieldName: file.name,
|
||||
originalName: file.name,
|
||||
data: file.data ?? .init()
|
||||
)
|
||||
},
|
||||
inputBoxData.imageAttachments.map { image -> GraphQLFile in
|
||||
.init(
|
||||
fieldName: image.hashValue.description,
|
||||
originalName: "image.jpg",
|
||||
data: image.imageData
|
||||
)
|
||||
},
|
||||
].flatMap(\.self)
|
||||
assert(uploadableAttachments.allSatisfy { !($0.data?.isEmpty ?? true) })
|
||||
guard let input = try? CreateChatMessageInput(
|
||||
content: .some(content),
|
||||
params: .some(AffineGraphQL.JSON(_jsonValue: messageParameters)),
|
||||
sessionId: sessionId
|
||||
) else {
|
||||
assertionFailure() // very unlikely to happen
|
||||
return
|
||||
}
|
||||
let mutation = CreateCopilotMessageMutation(options: input)
|
||||
QLService.shared.client.upload(operation: mutation, files: uploadableAttachments) { result in
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case let .success(graphQLResult):
|
||||
guard let messageIdentifier = graphQLResult.data?.createCopilotMessage else {
|
||||
self.report(sessionId, ChatError.invalidResponse)
|
||||
return
|
||||
}
|
||||
let viewModelId = self.append(sessionId: sessionId, AssistantMessageCellViewModel(
|
||||
id: .init(),
|
||||
content: .init(),
|
||||
timestamp: .init()
|
||||
))
|
||||
self.startStreamingResponse(
|
||||
sessionId: sessionId,
|
||||
messageId: messageIdentifier,
|
||||
applyingTo: viewModelId
|
||||
)
|
||||
case let .failure(error):
|
||||
self.report(sessionId, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startStreamingResponse(sessionId: String, messageId: String, applyingTo vmId: UUID) {
|
||||
let base = IntelligentContext.shared.webViewMetadata[.currentServerBaseUrl] as? String
|
||||
guard let base, let url = URL(string: base) else {
|
||||
report(sessionId, ChatError.invalidServerConfiguration)
|
||||
return
|
||||
}
|
||||
let streamUrl = url
|
||||
.appendingPathComponent("api")
|
||||
.appendingPathComponent("copilot")
|
||||
.appendingPathComponent("chat")
|
||||
.appendingPathComponent(sessionId)
|
||||
.appendingPathComponent("stream")
|
||||
var comps = URLComponents(url: streamUrl, resolvingAgainstBaseURL: false)
|
||||
comps?.queryItems = [
|
||||
.init(name: "messageId", value: messageId),
|
||||
.init(name: "retry", value: "false"), // TODO: IMPL FROM UI
|
||||
]
|
||||
guard let finalUrl = comps?.url else {
|
||||
report(sessionId, ChatError.invalidStreamURL)
|
||||
return
|
||||
}
|
||||
let eventSource = EventSource(
|
||||
request: .init(
|
||||
url: finalUrl,
|
||||
cachePolicy: .reloadIgnoringLocalAndRemoteCacheData,
|
||||
timeoutInterval: 10
|
||||
),
|
||||
configuration: .default
|
||||
)
|
||||
eventSource.onOpen = {
|
||||
print("[*] \(messageId): connection established")
|
||||
}
|
||||
eventSource.onError = {
|
||||
self.report(sessionId, $0 ?? ChatError.unknownError)
|
||||
}
|
||||
|
||||
var document = ""
|
||||
let queue = DispatchQueue(label: "com.affine.chat.stream.\(sessionId)")
|
||||
eventSource.onMessage = { event in
|
||||
queue.async {
|
||||
print("[*] \(messageId): \(event.event ?? "?") received message: \(event.data)")
|
||||
switch event.event {
|
||||
case "message":
|
||||
document += event.data
|
||||
self.with(sessionId: sessionId, vmId: vmId) { (viewModel: inout AssistantMessageCellViewModel) in
|
||||
viewModel.content = document
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
closable.append(eventSource)
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
//
|
||||
// ChatManager+WorkflowModels.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
//
|
||||
|
||||
import AffineGraphQL
|
||||
import Foundation
|
||||
|
||||
// MARK: - ChatManager Workflow Models Extension
|
||||
|
||||
extension ChatManager {
|
||||
// MARK: - Workflow Models
|
||||
|
||||
struct WorkflowEventData: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var status: String
|
||||
var type: String
|
||||
var progress: Double?
|
||||
var message: String?
|
||||
|
||||
init(status: String, type: String, progress: Double? = nil, message: String? = nil) {
|
||||
id = UUID().uuidString
|
||||
self.status = status
|
||||
self.type = type
|
||||
self.progress = progress
|
||||
self.message = message
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkspaceEmbeddingStatus: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var workspaceId: String
|
||||
var total: Int
|
||||
var embedded: Int
|
||||
|
||||
var progress: Double {
|
||||
total > 0 ? Double(embedded) / Double(total) : 0.0
|
||||
}
|
||||
|
||||
init(workspaceId: String, total: Int, embedded: Int) {
|
||||
id = workspaceId
|
||||
self.workspaceId = workspaceId
|
||||
self.total = total
|
||||
self.embedded = embedded
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatEvent: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var type: ChatEventType
|
||||
var data: String
|
||||
var timestamp: DateTime?
|
||||
|
||||
var timestampDate: Date? {
|
||||
timestamp?.decoded
|
||||
}
|
||||
|
||||
init(type: ChatEventType, data: String, timestamp: DateTime? = nil) {
|
||||
id = UUID().uuidString
|
||||
self.type = type
|
||||
self.data = data
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
enum ChatEventType: String, Codable, CaseIterable {
|
||||
case message
|
||||
case attachment
|
||||
case event
|
||||
case ping
|
||||
}
|
||||
}
|
||||
@@ -7,221 +7,76 @@
|
||||
|
||||
import AffineGraphQL
|
||||
import Apollo
|
||||
import ApolloAPI
|
||||
import Combine
|
||||
import EventSource
|
||||
import Foundation
|
||||
import OrderedCollections
|
||||
|
||||
// MARK: - ChatManager
|
||||
|
||||
public class ChatManager: ObservableObject {
|
||||
public class ChatManager: ObservableObject, @unchecked Sendable {
|
||||
public static let shared = ChatManager()
|
||||
|
||||
// MARK: - Properties
|
||||
public typealias SessionID = String
|
||||
public typealias MessageID = UUID // ChatCellViewModel ID
|
||||
@Published public private(set) var viewModels: OrderedDictionary<
|
||||
SessionID,
|
||||
OrderedDictionary<MessageID, any ChatCellViewModel>
|
||||
> = [:]
|
||||
|
||||
@Published public private(set) var sessions: [SessionViewModel] = []
|
||||
@Published public private(set) var currentSession: SessionViewModel?
|
||||
@Published public private(set) var messages: [String: [ChatMessage]] = [:]
|
||||
@Published public private(set) var isLoading = false
|
||||
@Published public private(set) var error: Error?
|
||||
var closable: [Closable] = []
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private let apolloClient: ApolloClient
|
||||
private init() {}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
private init(apolloClient: ApolloClient = QLService.shared.client) {
|
||||
self.apolloClient = apolloClient
|
||||
public func closeAll() {
|
||||
closable.forEach { $0.close() }
|
||||
closable.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
public func with(sessionId: String, _ action: (inout OrderedDictionary<MessageID, any ChatCellViewModel>) -> Void) {
|
||||
if Thread.isMainThread {
|
||||
if var sessionViewModels = viewModels[sessionId] {
|
||||
action(&sessionViewModels)
|
||||
viewModels[sessionId] = sessionViewModels
|
||||
} else {
|
||||
var sessionViewModels = OrderedDictionary<MessageID, any ChatCellViewModel>()
|
||||
action(&sessionViewModels)
|
||||
viewModels[sessionId] = sessionViewModels
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.asyncAndWait {
|
||||
self.with(sessionId: sessionId, action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func createSession(
|
||||
workspaceId: String,
|
||||
promptName: String = "",
|
||||
docId: String? = nil,
|
||||
pinned: Bool = false
|
||||
) async throws -> SessionViewModel {
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
do {
|
||||
let input = CreateChatSessionInput(
|
||||
docId: docId.map { .some($0) } ?? .null,
|
||||
pinned: .some(pinned),
|
||||
promptName: promptName,
|
||||
workspaceId: workspaceId
|
||||
)
|
||||
|
||||
let mutation = CreateCopilotSessionMutation(options: input)
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
apolloClient.perform(mutation: mutation) { result in
|
||||
switch result {
|
||||
case let .success(graphQLResult):
|
||||
guard let sessionId = graphQLResult.data?.createCopilotSession else {
|
||||
continuation.resume(throwing: ChatError.invalidResponse)
|
||||
return
|
||||
}
|
||||
|
||||
let session = SessionViewModel(
|
||||
id: sessionId,
|
||||
workspaceId: workspaceId,
|
||||
docId: docId,
|
||||
promptName: promptName,
|
||||
model: nil,
|
||||
pinned: pinned,
|
||||
tokens: 0,
|
||||
createdAt: DateTime(date: Date()),
|
||||
updatedAt: DateTime(date: Date()),
|
||||
parentSessionId: nil
|
||||
)
|
||||
|
||||
Task { @MainActor in
|
||||
self.sessions.append(session)
|
||||
self.currentSession = session
|
||||
self.messages[sessionId] = []
|
||||
self.isLoading = false
|
||||
}
|
||||
|
||||
continuation.resume(returning: session)
|
||||
|
||||
case let .failure(error):
|
||||
Task { @MainActor in
|
||||
self.error = error
|
||||
self.isLoading = false
|
||||
}
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
public func with<T>(sessionId: String, vmId: UUID, _ action: (inout T) -> Void) {
|
||||
with(sessionId: sessionId) { sessionViewModels in
|
||||
if let read = sessionViewModels[vmId], var convert = read as? T {
|
||||
action(&convert)
|
||||
guard let vm = convert as? any ChatCellViewModel else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
sessionViewModels[vmId] = vm
|
||||
} else {
|
||||
assertionFailure()
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.error = error
|
||||
self.isLoading = false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public func sendMessage(
|
||||
content: String,
|
||||
attachments: [String] = [],
|
||||
sessionId: String? = nil
|
||||
) async throws {
|
||||
guard let targetSessionId = sessionId ?? currentSession?.id else {
|
||||
throw ChatError.noActiveSession
|
||||
}
|
||||
@discardableResult
|
||||
public func append(sessionId: String, _ viewModel: any ChatCellViewModel) -> UUID {
|
||||
with(sessionId: sessionId) { $0.updateValue(viewModel, forKey: viewModel.id) }
|
||||
return viewModel.id
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
// Add user message immediately
|
||||
let userMessage = ChatMessage(
|
||||
id: UUID().uuidString,
|
||||
role: .user,
|
||||
content: content,
|
||||
attachments: attachments.isEmpty ? nil : attachments,
|
||||
params: nil,
|
||||
createdAt: DateTime(date: Date())
|
||||
@discardableResult
|
||||
public func report(_ sessionID: String, _ error: Error) -> UUID {
|
||||
let model = ErrorCellViewModel(
|
||||
id: .init(),
|
||||
errorMessage: error.localizedDescription
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
var sessionMessages = self.messages[targetSessionId] ?? []
|
||||
sessionMessages.append(userMessage)
|
||||
self.messages[targetSessionId] = sessionMessages
|
||||
}
|
||||
|
||||
do {
|
||||
let input = CreateChatMessageInput(
|
||||
attachments: attachments.isEmpty ? .null : .some(attachments),
|
||||
blobs: .null,
|
||||
content: .some(content),
|
||||
params: .null,
|
||||
sessionId: targetSessionId
|
||||
)
|
||||
|
||||
let mutation = CreateCopilotMessageMutation(options: input)
|
||||
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
apolloClient.perform(mutation: mutation) { result in
|
||||
switch result {
|
||||
case let .success(graphQLResult):
|
||||
guard let messageId = graphQLResult.data?.createCopilotMessage else {
|
||||
continuation.resume(throwing: ChatError.invalidResponse)
|
||||
return
|
||||
}
|
||||
|
||||
// Add assistant message placeholder
|
||||
let assistantMessage = ChatMessage(
|
||||
id: messageId,
|
||||
role: .assistant,
|
||||
content: "Thinking...",
|
||||
attachments: nil,
|
||||
params: nil,
|
||||
createdAt: DateTime(date: Date())
|
||||
)
|
||||
|
||||
Task { @MainActor in
|
||||
var sessionMessages = self.messages[targetSessionId] ?? []
|
||||
sessionMessages.append(assistantMessage)
|
||||
self.messages[targetSessionId] = sessionMessages
|
||||
self.isLoading = false
|
||||
}
|
||||
|
||||
continuation.resume()
|
||||
|
||||
// TODO: Implement streaming response handling
|
||||
|
||||
case let .failure(error):
|
||||
Task { @MainActor in
|
||||
self.error = error
|
||||
self.isLoading = false
|
||||
}
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.error = error
|
||||
self.isLoading = false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public func switchToSession(_ session: SessionViewModel) {
|
||||
currentSession = session
|
||||
}
|
||||
|
||||
public func deleteSession(sessionId: String) {
|
||||
sessions.removeAll { $0.id == sessionId }
|
||||
messages.removeValue(forKey: sessionId)
|
||||
|
||||
if currentSession?.id == sessionId {
|
||||
currentSession = sessions.first
|
||||
}
|
||||
}
|
||||
|
||||
public func clearError() {
|
||||
error = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ChatError
|
||||
|
||||
public enum ChatError: LocalizedError {
|
||||
case noActiveSession
|
||||
case invalidResponse
|
||||
case networkError(Error)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .noActiveSession:
|
||||
"No active chat session"
|
||||
case .invalidResponse:
|
||||
"Invalid response from server"
|
||||
case let .networkError(error):
|
||||
"Network error: \(error.localizedDescription)"
|
||||
}
|
||||
append(sessionId: sessionID, model)
|
||||
return model.id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//
|
||||
// ChatMessage.swift
|
||||
// ChatSessionObject.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
@@ -8,48 +8,7 @@
|
||||
import AffineGraphQL
|
||||
import Foundation
|
||||
|
||||
public struct ChatMessage: Codable, Identifiable, Equatable, Hashable {
|
||||
public var id: String?
|
||||
public var role: MessageRole
|
||||
public var content: String
|
||||
public var attachments: [String]?
|
||||
public var params: [String: String]?
|
||||
public var createdAt: DateTime?
|
||||
|
||||
public var createdDate: Date? {
|
||||
createdAt?.decoded
|
||||
}
|
||||
|
||||
public var messageId: String {
|
||||
id ?? UUID().uuidString
|
||||
}
|
||||
|
||||
public init(
|
||||
id: String? = nil,
|
||||
role: MessageRole,
|
||||
content: String,
|
||||
attachments: [String]? = nil,
|
||||
params: [String: String]? = nil,
|
||||
createdAt: DateTime? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.role = role
|
||||
self.content = content
|
||||
self.attachments = attachments
|
||||
self.params = params
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
|
||||
public extension ChatMessage {
|
||||
enum MessageRole: String, Codable, CaseIterable {
|
||||
case user
|
||||
case assistant
|
||||
case system
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionViewModel: Codable, Identifiable, Equatable, Hashable {
|
||||
public struct ChatSessionObject: Codable, Identifiable, Equatable, Hashable {
|
||||
public var id: String
|
||||
public var workspaceId: String
|
||||
public var docId: String?
|
||||
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// PromptName.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/30/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum PromptName: String, Codable {
|
||||
case summary = "Summary"
|
||||
case summaryAsTitle = "Summary as title"
|
||||
case explainThis = "Explain this"
|
||||
case writeAnArticleAboutThis = "Write an article about this"
|
||||
case writeATwitterAboutThis = "Write a twitter about this"
|
||||
case writeAPoemAboutThis = "Write a poem about this"
|
||||
case writeABlogPostAboutThis = "Write a blog post about this"
|
||||
case writeOutline = "Write outline"
|
||||
case changeToneTo = "Change tone to"
|
||||
case improveWritingForIt = "Improve writing for it"
|
||||
case improveGrammarForIt = "Improve grammar for it"
|
||||
case fixSpellingForIt = "Fix spelling for it"
|
||||
case createHeadings = "Create headings"
|
||||
case makeItLonger = "Make it longer"
|
||||
case makeItShorter = "Make it shorter"
|
||||
case continueWriting = "Continue writing"
|
||||
case chatWithAffineAI = "Chat With AFFiNE AI"
|
||||
case searchWithAffineAI = "Search With AFFiNE AI"
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//
|
||||
// IntelligentContext+CreateSession.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
//
|
||||
|
||||
import AffineGraphQL
|
||||
import Apollo
|
||||
import ApolloAPI
|
||||
import Foundation
|
||||
|
||||
public extension IntelligentContext {
|
||||
func createSession(
|
||||
workspaceId: String,
|
||||
promptName: PromptName = .chatWithAffineAI,
|
||||
docId: String? = nil,
|
||||
pinned: Bool = false,
|
||||
completion: @escaping (Result<ChatSessionObject, Error>) -> Void
|
||||
) {
|
||||
let input = CreateChatSessionInput(
|
||||
docId: docId.map { .some($0) } ?? .null,
|
||||
pinned: .some(pinned),
|
||||
promptName: promptName.rawValue,
|
||||
workspaceId: workspaceId
|
||||
)
|
||||
|
||||
let mutation = CreateCopilotSessionMutation(options: input)
|
||||
|
||||
QLService.shared.client.perform(mutation: mutation) { result in
|
||||
switch result {
|
||||
case let .success(graphQLResult):
|
||||
guard let sessionId = graphQLResult.data?.createCopilotSession else {
|
||||
completion(.failure(IntelligentError.sessionCreationFailed("No session ID returned.")))
|
||||
return
|
||||
}
|
||||
|
||||
let session = ChatSessionObject(
|
||||
id: sessionId,
|
||||
workspaceId: workspaceId,
|
||||
docId: docId,
|
||||
promptName: promptName.rawValue,
|
||||
model: nil,
|
||||
pinned: pinned,
|
||||
tokens: 0,
|
||||
createdAt: DateTime(date: Date()),
|
||||
updatedAt: DateTime(date: Date()),
|
||||
parentSessionId: nil
|
||||
)
|
||||
completion(.success(session))
|
||||
|
||||
case let .failure(error):
|
||||
completion(.failure(IntelligentError.sessionCreationFailed(error.localizedDescription)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,9 @@ public class IntelligentContext {
|
||||
case currentI18nLocale
|
||||
}
|
||||
|
||||
public private(set) var currentSession: ChatSessionObject?
|
||||
public private(set) var currentWorkspaceId: String?
|
||||
|
||||
public lazy var temporaryDirectory: URL = {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
return tempDir.appendingPathComponent("IntelligentContext")
|
||||
@@ -49,11 +52,14 @@ public class IntelligentContext {
|
||||
|
||||
public enum IntelligentError: Error, LocalizedError {
|
||||
case loginRequired(String)
|
||||
case sessionCreationFailed(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case let .loginRequired(reason):
|
||||
"Login required: \(reason)"
|
||||
case let .sessionCreationFailed(reason):
|
||||
"Session creation failed: \(reason)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +67,7 @@ public class IntelligentContext {
|
||||
private init() {}
|
||||
|
||||
public func preparePresent(_ completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
assert(webView != nil)
|
||||
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
||||
prepareTemporaryDirectory()
|
||||
|
||||
@@ -79,21 +86,18 @@ public class IntelligentContext {
|
||||
!baseUrlString.isEmpty,
|
||||
let url = URL(string: baseUrlString)
|
||||
else {
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(IntelligentError.loginRequired("Missing server base URL")))
|
||||
}
|
||||
completion(.failure(IntelligentError.loginRequired("Missing server base URL")))
|
||||
return
|
||||
}
|
||||
|
||||
guard let workspaceId = webViewMetadataResult[.currentWorkspaceId] as? String,
|
||||
!workspaceId.isEmpty
|
||||
else {
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(IntelligentError.loginRequired("Missing workspace ID")))
|
||||
}
|
||||
completion(.failure(IntelligentError.loginRequired("Missing workspace ID")))
|
||||
return
|
||||
}
|
||||
|
||||
currentWorkspaceId = workspaceId
|
||||
QLService.shared.setEndpoint(base: url)
|
||||
|
||||
let gqlGroup = DispatchGroup()
|
||||
@@ -106,20 +110,28 @@ public class IntelligentContext {
|
||||
gqlGroup.wait()
|
||||
qlMetadata = gqlMetadataResult
|
||||
|
||||
// Check required QL metadata
|
||||
guard let userIdentifier = gqlMetadataResult[.userIdentifierKey] as? String,
|
||||
!userIdentifier.isEmpty
|
||||
else {
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(IntelligentError.loginRequired("Missing user identifier")))
|
||||
}
|
||||
completion(.failure(IntelligentError.loginRequired("Missing user identifier")))
|
||||
return
|
||||
}
|
||||
|
||||
let currentDocumentId: String? = webViewMetadata[.currentDocId] as? String
|
||||
|
||||
dumpMetadataContents()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(()))
|
||||
createSession(
|
||||
workspaceId: workspaceId,
|
||||
docId: currentDocumentId
|
||||
) { result in
|
||||
switch result {
|
||||
case let .success(session):
|
||||
self.currentSession = session
|
||||
completion(.success(()))
|
||||
case let .failure(error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ private class _AttachmentManagementController: UIViewController {
|
||||
Section,
|
||||
Item
|
||||
> = .init(tableView: tableView) { [weak self] tableView, indexPath, item in
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "AttachmentCell", for: indexPath) as! AttachmentCell
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "AttachmentManagementCell", for: indexPath) as! AttachmentManagementCell
|
||||
cell.configure(with: item)
|
||||
cell.onDelete = { [weak self] in
|
||||
guard let delegateController = self?.delegateController else { return }
|
||||
@@ -119,7 +119,7 @@ private class _AttachmentManagementController: UIViewController {
|
||||
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.separatorStyle = .none
|
||||
tableView.register(AttachmentCell.self, forCellReuseIdentifier: "AttachmentCell")
|
||||
tableView.register(AttachmentManagementCell.self, forCellReuseIdentifier: "AttachmentManagementCell")
|
||||
tableView.clipsToBounds = true
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
view.addSubview(tableView)
|
||||
@@ -156,7 +156,7 @@ private class _AttachmentManagementController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
private class AttachmentCell: UITableViewCell {
|
||||
private class AttachmentManagementCell: UITableViewCell {
|
||||
let container = UIView().then {
|
||||
$0.layer.cornerRadius = 4
|
||||
$0.layer.borderWidth = 0.5
|
||||
|
||||
@@ -76,41 +76,20 @@ extension MainViewController: InputBoxDelegate {
|
||||
|
||||
func inputBoxDidSend(_ inputBox: InputBox) {
|
||||
let inputData = inputBox.inputBoxData
|
||||
inputBox.text = ""
|
||||
inputBox.viewModel.clearAllAttachments()
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let chatManager = ChatManager.shared
|
||||
|
||||
if let currentSession = chatManager.currentSession {
|
||||
try await chatManager.sendMessage(
|
||||
content: inputData.text,
|
||||
attachments: [], // TODO: Handle attachments
|
||||
sessionId: currentSession.id
|
||||
)
|
||||
} else {
|
||||
guard let workspaceId = IntelligentContext.shared.webViewMetadata[.currentWorkspaceId] as? String,
|
||||
!workspaceId.isEmpty
|
||||
else {
|
||||
showAlert(title: "Error", message: "No workspace available")
|
||||
return
|
||||
}
|
||||
|
||||
let session = try await chatManager.createSession(workspaceId: workspaceId)
|
||||
|
||||
try await chatManager.sendMessage(
|
||||
content: inputData.text,
|
||||
attachments: [], // TODO: Handle attachments
|
||||
sessionId: session.id
|
||||
)
|
||||
}
|
||||
|
||||
inputBox.text = ""
|
||||
inputBox.viewModel.clearAllAttachments()
|
||||
|
||||
} catch {
|
||||
showAlert(title: "Error", message: error.localizedDescription)
|
||||
}
|
||||
guard let currentSession = IntelligentContext.shared.currentSession else {
|
||||
showAlert(title: "Error", message: "No active session available")
|
||||
return
|
||||
}
|
||||
|
||||
ChatManager.shared.closeAll()
|
||||
ChatManager.shared.startUserRequest(
|
||||
content: inputData.text,
|
||||
inputBoxData: inputData,
|
||||
sessionId: currentSession.id
|
||||
)
|
||||
}
|
||||
|
||||
private func showAlert(title: String, message: String) {
|
||||
|
||||
@@ -10,25 +10,8 @@ class MainViewController: UIViewController {
|
||||
$0.delegate = self
|
||||
}
|
||||
|
||||
lazy var tableView = UITableView().then {
|
||||
$0.backgroundColor = .clear
|
||||
$0.separatorStyle = .none
|
||||
lazy var chatTableView = ChatTableView().then {
|
||||
$0.delegate = self
|
||||
$0.dataSource = self
|
||||
$0.register(ChatCell.self, forCellReuseIdentifier: "ChatCell")
|
||||
$0.keyboardDismissMode = .interactive
|
||||
$0.contentInsetAdjustmentBehavior = .never
|
||||
}
|
||||
|
||||
lazy var emptyStateView = UIView().then {
|
||||
$0.isHidden = true
|
||||
}
|
||||
|
||||
lazy var emptyStateLabel = UILabel().then {
|
||||
$0.text = "Start a conversation..."
|
||||
$0.font = .systemFont(ofSize: 18, weight: .medium)
|
||||
$0.textColor = .systemGray
|
||||
$0.textAlignment = .center
|
||||
}
|
||||
|
||||
lazy var inputBox = InputBox().then {
|
||||
@@ -49,10 +32,9 @@ class MainViewController: UIViewController {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var messages: [ChatMessage] = []
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private let intelligentContext = IntelligentContext.shared
|
||||
private let chatManager = ChatManager.shared
|
||||
var cancellables = Set<AnyCancellable>()
|
||||
let intelligentContext = IntelligentContext.shared
|
||||
let chatManager = ChatManager.shared
|
||||
var terminateEditGesture: UITapGestureRecognizer!
|
||||
|
||||
// MARK: - Lifecycle
|
||||
@@ -62,7 +44,6 @@ class MainViewController: UIViewController {
|
||||
view.backgroundColor = .affineLayerBackgroundPrimary
|
||||
|
||||
setupUI()
|
||||
setupBindings()
|
||||
|
||||
view.isUserInteractionEnabled = true
|
||||
terminateEditGesture = UITapGestureRecognizer(target: self, action: #selector(terminateEditing))
|
||||
@@ -73,32 +54,20 @@ class MainViewController: UIViewController {
|
||||
|
||||
private func setupUI() {
|
||||
view.addSubview(headerView)
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(emptyStateView)
|
||||
view.addSubview(chatTableView)
|
||||
view.addSubview(inputBox)
|
||||
view.addSubview(documentPickerHideDetector)
|
||||
view.addSubview(documentPickerView)
|
||||
|
||||
emptyStateView.addSubview(emptyStateLabel)
|
||||
|
||||
headerView.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
}
|
||||
|
||||
tableView.snp.makeConstraints { make in
|
||||
chatTableView.snp.makeConstraints { make in
|
||||
make.top.equalTo(headerView.snp.bottom)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(inputBox.snp.top)
|
||||
}
|
||||
|
||||
emptyStateView.snp.makeConstraints { make in
|
||||
make.center.equalTo(tableView)
|
||||
make.width.lessThanOrEqualTo(tableView).inset(32)
|
||||
}
|
||||
|
||||
emptyStateLabel.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
make.left.right.equalToSuperview()
|
||||
make.bottom.equalToSuperview()
|
||||
}
|
||||
|
||||
inputBox.snp.makeConstraints { make in
|
||||
@@ -117,24 +86,6 @@ class MainViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
private func setupBindings() {
|
||||
chatManager.$currentSession
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] session in
|
||||
self?.updateMessages(for: session?.id)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatManager.$messages
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
if let sessionId = self?.chatManager.currentSession?.id {
|
||||
self?.updateMessages(for: sessionId)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
navigationController!.setNavigationBarHidden(true, animated: animated)
|
||||
@@ -154,64 +105,12 @@ class MainViewController: UIViewController {
|
||||
}
|
||||
|
||||
// MARK: - Chat Methods
|
||||
|
||||
private func updateMessages(for sessionId: String?) {
|
||||
guard let sessionId else {
|
||||
messages = []
|
||||
updateEmptyState()
|
||||
tableView.reloadData()
|
||||
return
|
||||
}
|
||||
|
||||
messages = chatManager.messages[sessionId] ?? []
|
||||
updateEmptyState()
|
||||
tableView.reloadData()
|
||||
|
||||
if !messages.isEmpty {
|
||||
let indexPath = IndexPath(row: messages.count - 1, section: 0)
|
||||
tableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateEmptyState() {
|
||||
emptyStateView.isHidden = !messages.isEmpty
|
||||
tableView.isHidden = messages.isEmpty
|
||||
}
|
||||
|
||||
// MARK: - Internal Methods for Preview/Testing
|
||||
|
||||
#if DEBUG
|
||||
func setMessagesForPreview(_ previewMessages: [ChatMessage]) {
|
||||
messages = previewMessages
|
||||
updateEmptyState()
|
||||
tableView.reloadData()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
// MARK: - ChatTableViewDelegate
|
||||
|
||||
extension MainViewController: UITableViewDataSource {
|
||||
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
|
||||
messages.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "ChatCell", for: indexPath) as! ChatCell
|
||||
let message = messages[indexPath.row]
|
||||
cell.configure(with: message)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
|
||||
extension MainViewController: UITableViewDelegate {
|
||||
func tableView(_: UITableView, heightForRowAt _: IndexPath) -> CGFloat {
|
||||
UITableView.automaticDimension
|
||||
}
|
||||
|
||||
func tableView(_: UITableView, estimatedHeightForRowAt _: IndexPath) -> CGFloat {
|
||||
60
|
||||
extension MainViewController: ChatTableViewDelegate {
|
||||
func chatTableView(_: ChatTableView, didSelectRowAt _: IndexPath) {
|
||||
// Handle cell selection if needed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//
|
||||
// AssistantMessageCell.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
class AssistantMessageCell: ChatBaseCell {
|
||||
// MARK: - UI Components
|
||||
|
||||
private lazy var messageLabel = UILabel().then {
|
||||
$0.numberOfLines = 0
|
||||
$0.font = .systemFont(ofSize: 16)
|
||||
$0.textColor = .label
|
||||
}
|
||||
|
||||
private lazy var metadataStackView = UIStackView().then {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 8
|
||||
$0.alignment = .center
|
||||
}
|
||||
|
||||
private lazy var modelLabel = UILabel().then {
|
||||
$0.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
$0.textColor = .secondaryLabel
|
||||
}
|
||||
|
||||
private lazy var tokensLabel = UILabel().then {
|
||||
$0.font = .systemFont(ofSize: 12)
|
||||
$0.textColor = .secondaryLabel
|
||||
}
|
||||
|
||||
private lazy var timestampLabel = UILabel().then {
|
||||
$0.font = .systemFont(ofSize: 12)
|
||||
$0.textColor = .secondaryLabel
|
||||
}
|
||||
|
||||
private lazy var streamingIndicator = UIActivityIndicatorView().then {
|
||||
$0.style = .medium
|
||||
$0.hidesWhenStopped = true
|
||||
}
|
||||
|
||||
private lazy var retryButton = UIButton(type: .system).then {
|
||||
$0.setTitle("重试", for: .normal)
|
||||
$0.titleLabel?.font = .systemFont(ofSize: 12)
|
||||
$0.setTitleColor(.systemBlue, for: .normal)
|
||||
}
|
||||
|
||||
private lazy var mainStackView = UIStackView().then {
|
||||
$0.axis = .vertical
|
||||
$0.spacing = 8
|
||||
$0.alignment = .fill
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var viewModel: AssistantMessageCellViewModel?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
override func setupContentView() {
|
||||
containerView.addSubview(mainStackView)
|
||||
|
||||
mainStackView.addArrangedSubview(messageLabel)
|
||||
mainStackView.addArrangedSubview(metadataStackView)
|
||||
|
||||
metadataStackView.addArrangedSubview(modelLabel)
|
||||
metadataStackView.addArrangedSubview(tokensLabel)
|
||||
metadataStackView.addArrangedSubview(UIView()) // Spacer
|
||||
metadataStackView.addArrangedSubview(streamingIndicator)
|
||||
metadataStackView.addArrangedSubview(retryButton)
|
||||
metadataStackView.addArrangedSubview(timestampLabel)
|
||||
|
||||
mainStackView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(contentInsets)
|
||||
}
|
||||
|
||||
retryButton.addTarget(self, action: #selector(retryButtonTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
override func configure(with viewModel: any ChatCellViewModel) {
|
||||
guard let assistantViewModel = viewModel as? AssistantMessageCellViewModel else { return }
|
||||
self.viewModel = assistantViewModel
|
||||
|
||||
messageLabel.text = assistantViewModel.content
|
||||
configureContainer(backgroundColor: backgroundColor(for: assistantViewModel.cellType))
|
||||
|
||||
// 配置模型信息
|
||||
if let model = assistantViewModel.model {
|
||||
modelLabel.text = model
|
||||
modelLabel.isHidden = false
|
||||
} else {
|
||||
modelLabel.isHidden = true
|
||||
}
|
||||
|
||||
// 配置 tokens 信息
|
||||
if let tokens = assistantViewModel.tokens {
|
||||
tokensLabel.text = "\(tokens) tokens"
|
||||
tokensLabel.isHidden = false
|
||||
} else {
|
||||
tokensLabel.isHidden = true
|
||||
}
|
||||
|
||||
// 配置时间戳
|
||||
let timestamp = assistantViewModel.timestamp
|
||||
timestampLabel.text = formatTimestamp(timestamp)
|
||||
timestampLabel.isHidden = false
|
||||
|
||||
// 配置流式状态
|
||||
if assistantViewModel.isStreaming {
|
||||
streamingIndicator.startAnimating()
|
||||
} else {
|
||||
streamingIndicator.stopAnimating()
|
||||
}
|
||||
|
||||
// 配置重试按钮
|
||||
retryButton.isHidden = !assistantViewModel.canRetry
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@objc private func retryButtonTapped() {
|
||||
// TODO: 实现重试逻辑
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func formatTimestamp(_ timestamp: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: timestamp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// ChatBaseCell.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
class ChatBaseCell: UITableViewCell {
|
||||
// MARK: - UI Components
|
||||
|
||||
/// 主容器视图,负责管理内边距和统一行为
|
||||
lazy var containerView = UIView().then {
|
||||
$0.layer.cornerRadius = 8
|
||||
$0.layer.cornerCurve = .continuous
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// 容器视图的内边距,子类可以重写
|
||||
var containerInsets: UIEdgeInsets {
|
||||
UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)
|
||||
}
|
||||
|
||||
/// 容器视图内部的内边距,子类可以重写
|
||||
var contentInsets: UIEdgeInsets {
|
||||
UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
setupBaseUI()
|
||||
setupContentView()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupBaseUI() {
|
||||
backgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
|
||||
contentView.addSubview(containerView)
|
||||
containerView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(containerInsets)
|
||||
}
|
||||
}
|
||||
|
||||
/// 子类重写此方法来设置具体的内容视图
|
||||
func setupContentView() {
|
||||
// 子类实现
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
/// 配置容器视图的外观
|
||||
func configureContainer(backgroundColor: UIColor?, borderColor: UIColor? = nil, borderWidth: CGFloat = 0) {
|
||||
containerView.backgroundColor = backgroundColor
|
||||
|
||||
if let borderColor {
|
||||
containerView.layer.borderColor = borderColor.cgColor
|
||||
containerView.layer.borderWidth = borderWidth
|
||||
} else {
|
||||
containerView.layer.borderColor = nil
|
||||
containerView.layer.borderWidth = 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置 ViewModel,子类需要重写
|
||||
func configure(with _: any ChatCellViewModel) {
|
||||
// 子类实现
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// 获取适当的文本颜色
|
||||
func textColor(for cellType: CellType) -> UIColor {
|
||||
switch cellType {
|
||||
case .userMessage, .assistantMessage, .systemMessage:
|
||||
.label
|
||||
case .error:
|
||||
.systemRed
|
||||
case .loading:
|
||||
.secondaryLabel
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取适当的背景颜色
|
||||
func backgroundColor(for cellType: CellType) -> UIColor? {
|
||||
switch cellType {
|
||||
case .userMessage, .assistantMessage:
|
||||
.clear
|
||||
case .systemMessage:
|
||||
.systemYellow.withAlphaComponent(0.2)
|
||||
case .error:
|
||||
.systemRed.withAlphaComponent(0.1)
|
||||
case .loading:
|
||||
.systemGray6
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
//
|
||||
// ChatCell.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
class ChatCell: UITableViewCell {
|
||||
// MARK: - UI Components
|
||||
|
||||
private lazy var avatarImageView = UIImageView().then {
|
||||
$0.contentMode = .scaleAspectFit
|
||||
$0.layer.cornerRadius = 16
|
||||
$0.layer.cornerCurve = .continuous
|
||||
$0.clipsToBounds = true
|
||||
$0.backgroundColor = .systemGray5
|
||||
}
|
||||
|
||||
private lazy var messageContainerView = UIView().then {
|
||||
$0.layer.cornerRadius = 12
|
||||
$0.layer.cornerCurve = .continuous
|
||||
}
|
||||
|
||||
private lazy var messageLabel = UILabel().then {
|
||||
$0.numberOfLines = 0
|
||||
$0.font = .systemFont(ofSize: 16)
|
||||
$0.textColor = .label
|
||||
}
|
||||
|
||||
private lazy var timestampLabel = UILabel().then {
|
||||
$0.font = .systemFont(ofSize: 12)
|
||||
$0.textColor = .systemGray
|
||||
$0.textAlignment = .right
|
||||
}
|
||||
|
||||
private lazy var stackView = UIStackView().then {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 12
|
||||
$0.alignment = .top
|
||||
}
|
||||
|
||||
private lazy var messageStackView = UIStackView().then {
|
||||
$0.axis = .vertical
|
||||
$0.spacing = 4
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var message: ChatMessage?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupUI() {
|
||||
backgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
|
||||
contentView.addSubview(stackView)
|
||||
|
||||
messageStackView.addArrangedSubview(messageContainerView)
|
||||
messageStackView.addArrangedSubview(timestampLabel)
|
||||
|
||||
messageContainerView.addSubview(messageLabel)
|
||||
|
||||
stackView.addArrangedSubview(avatarImageView)
|
||||
stackView.addArrangedSubview(messageStackView)
|
||||
|
||||
stackView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
}
|
||||
|
||||
avatarImageView.snp.makeConstraints { make in
|
||||
make.size.equalTo(32)
|
||||
}
|
||||
|
||||
messageLabel.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(12)
|
||||
}
|
||||
|
||||
messageStackView.snp.makeConstraints { make in
|
||||
make.width.lessThanOrEqualTo(250)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
func configure(with message: ChatMessage) {
|
||||
self.message = message
|
||||
|
||||
messageLabel.text = message.content
|
||||
|
||||
if let createdDate = message.createdDate {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .none
|
||||
formatter.timeStyle = .short
|
||||
timestampLabel.text = formatter.string(from: createdDate)
|
||||
} else {
|
||||
timestampLabel.text = ""
|
||||
}
|
||||
|
||||
switch message.role {
|
||||
case .user:
|
||||
configureUserMessage()
|
||||
case .assistant:
|
||||
configureAssistantMessage()
|
||||
case .system:
|
||||
configureSystemMessage()
|
||||
}
|
||||
}
|
||||
|
||||
private func configureUserMessage() {
|
||||
// User message - align to right
|
||||
stackView.semanticContentAttribute = .forceRightToLeft
|
||||
messageContainerView.backgroundColor = .systemBlue
|
||||
messageLabel.textColor = .white
|
||||
avatarImageView.image = UIImage(systemName: "person.circle.fill")
|
||||
avatarImageView.tintColor = .systemBlue
|
||||
timestampLabel.textAlignment = .left
|
||||
}
|
||||
|
||||
private func configureAssistantMessage() {
|
||||
// Assistant message - align to left
|
||||
stackView.semanticContentAttribute = .forceLeftToRight
|
||||
messageContainerView.backgroundColor = .systemGray6
|
||||
messageLabel.textColor = .label
|
||||
avatarImageView.image = UIImage(systemName: "brain.head.profile")
|
||||
avatarImageView.tintColor = .systemPurple
|
||||
timestampLabel.textAlignment = .right
|
||||
}
|
||||
|
||||
private func configureSystemMessage() {
|
||||
// System message - center aligned
|
||||
stackView.semanticContentAttribute = .forceLeftToRight
|
||||
messageContainerView.backgroundColor = .systemYellow.withAlphaComponent(0.3)
|
||||
messageLabel.textColor = .label
|
||||
avatarImageView.image = UIImage(systemName: "gear")
|
||||
avatarImageView.tintColor = .systemOrange
|
||||
timestampLabel.textAlignment = .center
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//
|
||||
// ChatCellFactory.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class ChatCellFactory {
|
||||
// MARK: - Cell Registration
|
||||
|
||||
static func registerCells(for tableView: UITableView) {
|
||||
tableView.register(UserMessageCell.self, forCellReuseIdentifier: CellType.userMessage.rawValue)
|
||||
tableView.register(AssistantMessageCell.self, forCellReuseIdentifier: CellType.assistantMessage.rawValue)
|
||||
tableView.register(SystemMessageCell.self, forCellReuseIdentifier: CellType.systemMessage.rawValue)
|
||||
tableView.register(LoadingCell.self, forCellReuseIdentifier: CellType.loading.rawValue)
|
||||
tableView.register(ErrorCell.self, forCellReuseIdentifier: CellType.error.rawValue)
|
||||
}
|
||||
|
||||
// MARK: - Cell Creation
|
||||
|
||||
static func dequeueCell(
|
||||
for tableView: UITableView,
|
||||
at indexPath: IndexPath,
|
||||
with viewModel: ChatCellViewModel
|
||||
) -> ChatBaseCell {
|
||||
let identifier = viewModel.cellType.rawValue
|
||||
|
||||
guard let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: identifier,
|
||||
for: indexPath
|
||||
) as? ChatBaseCell else {
|
||||
// 如果无法获取指定类型的cell,使用系统消息cell作为fallback
|
||||
let fallbackCell = tableView.dequeueReusableCell(
|
||||
withIdentifier: CellType.systemMessage.rawValue,
|
||||
for: indexPath
|
||||
) as! SystemMessageCell
|
||||
|
||||
// 创建一个fallback的ViewModel
|
||||
let fallbackViewModel = SystemMessageCellViewModel(
|
||||
id: viewModel.id,
|
||||
content: "不支持的消息类型: \\(viewModel.cellType.rawValue)",
|
||||
timestamp: Date()
|
||||
)
|
||||
fallbackCell.configure(with: fallbackViewModel)
|
||||
return fallbackCell
|
||||
}
|
||||
|
||||
cell.configure(with: viewModel)
|
||||
return cell
|
||||
}
|
||||
|
||||
// MARK: - Height Estimation
|
||||
|
||||
static func estimatedHeight(for viewModel: any ChatCellViewModel) -> CGFloat {
|
||||
switch viewModel.cellType {
|
||||
case .userMessage,
|
||||
.assistantMessage:
|
||||
80
|
||||
case .systemMessage:
|
||||
60
|
||||
case .loading:
|
||||
100
|
||||
case .error:
|
||||
120
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//
|
||||
// ErrorCell.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
class ErrorCell: ChatBaseCell {
|
||||
// MARK: - UI Components
|
||||
|
||||
private lazy var iconView = UIImageView().then {
|
||||
$0.image = UIImage(systemName: "exclamationmark.triangle.fill")
|
||||
$0.tintColor = .systemRed
|
||||
$0.contentMode = .scaleAspectFit
|
||||
}
|
||||
|
||||
private lazy var errorLabel = UILabel().then {
|
||||
$0.numberOfLines = 0
|
||||
$0.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
$0.textColor = .systemRed
|
||||
}
|
||||
|
||||
private lazy var retryButton = UIButton(type: .system).then {
|
||||
$0.setTitle("Retry", for: .normal)
|
||||
$0.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
$0.setTitleColor(.systemBlue, for: .normal)
|
||||
$0.backgroundColor = .systemBlue.withAlphaComponent(0.1)
|
||||
$0.layer.cornerRadius = 8
|
||||
$0.layer.cornerCurve = .continuous
|
||||
$0.contentEdgeInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)
|
||||
}
|
||||
|
||||
private lazy var contentStackView = UIStackView().then {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 12
|
||||
$0.alignment = .top
|
||||
}
|
||||
|
||||
private lazy var textStackView = UIStackView().then {
|
||||
$0.axis = .vertical
|
||||
$0.spacing = 12
|
||||
$0.alignment = .fill
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var viewModel: ErrorCellViewModel?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
override func setupContentView() {
|
||||
containerView.addSubview(contentStackView)
|
||||
|
||||
contentStackView.addArrangedSubview(iconView)
|
||||
contentStackView.addArrangedSubview(textStackView)
|
||||
|
||||
textStackView.addArrangedSubview(errorLabel)
|
||||
textStackView.addArrangedSubview(retryButton)
|
||||
|
||||
contentStackView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(contentInsets)
|
||||
}
|
||||
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(24)
|
||||
}
|
||||
|
||||
retryButton.addTarget(self, action: #selector(retryButtonTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
override func configure(with viewModel: any ChatCellViewModel) {
|
||||
guard let errorViewModel = viewModel as? ErrorCellViewModel else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
self.viewModel = errorViewModel
|
||||
|
||||
errorLabel.text = errorViewModel.errorMessage
|
||||
configureContainer(
|
||||
backgroundColor: backgroundColor(for: errorViewModel.cellType),
|
||||
borderColor: .systemRed.withAlphaComponent(0.3),
|
||||
borderWidth: 1
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@objc private func retryButtonTapped() {
|
||||
// TODO: 实现重试逻辑
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// LoadingCell.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
class LoadingCell: ChatBaseCell {
|
||||
// MARK: - UI Components
|
||||
|
||||
private lazy var activityIndicator = UIActivityIndicatorView().then {
|
||||
$0.style = .medium
|
||||
$0.hidesWhenStopped = false
|
||||
}
|
||||
|
||||
private lazy var messageLabel = UILabel().then {
|
||||
$0.numberOfLines = 0
|
||||
$0.font = .systemFont(ofSize: 14)
|
||||
$0.textColor = .secondaryLabel
|
||||
$0.textAlignment = .center
|
||||
}
|
||||
|
||||
private lazy var progressView = UIProgressView().then {
|
||||
$0.progressViewStyle = .default
|
||||
$0.trackTintColor = .systemGray5
|
||||
$0.progressTintColor = .systemBlue
|
||||
}
|
||||
|
||||
private lazy var stackView = UIStackView().then {
|
||||
$0.axis = .vertical
|
||||
$0.spacing = 12
|
||||
$0.alignment = .center
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var viewModel: LoadingCellViewModel?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
override func setupContentView() {
|
||||
containerView.addSubview(stackView)
|
||||
|
||||
stackView.addArrangedSubview(activityIndicator)
|
||||
stackView.addArrangedSubview(messageLabel)
|
||||
stackView.addArrangedSubview(progressView)
|
||||
|
||||
stackView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(contentInsets)
|
||||
}
|
||||
|
||||
progressView.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(4)
|
||||
}
|
||||
|
||||
activityIndicator.startAnimating()
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
override func configure(with viewModel: any ChatCellViewModel) {
|
||||
guard let loadingViewModel = viewModel as? LoadingCellViewModel else { return }
|
||||
self.viewModel = loadingViewModel
|
||||
|
||||
configureContainer(backgroundColor: backgroundColor(for: loadingViewModel.cellType))
|
||||
|
||||
// 配置消息
|
||||
if let message = loadingViewModel.message {
|
||||
messageLabel.text = message
|
||||
messageLabel.isHidden = false
|
||||
} else {
|
||||
messageLabel.text = "Processing..."
|
||||
messageLabel.isHidden = false
|
||||
}
|
||||
|
||||
// 配置进度
|
||||
if let progress = loadingViewModel.progress {
|
||||
progressView.progress = Float(progress)
|
||||
progressView.isHidden = false
|
||||
} else {
|
||||
progressView.isHidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// SystemMessageCell.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
class SystemMessageCell: ChatBaseCell {
|
||||
// MARK: - UI Components
|
||||
|
||||
private lazy var iconView = UIImageView().then {
|
||||
$0.image = UIImage(systemName: "info.circle.fill")
|
||||
$0.tintColor = .systemOrange
|
||||
$0.contentMode = .scaleAspectFit
|
||||
}
|
||||
|
||||
private lazy var messageLabel = UILabel().then {
|
||||
$0.numberOfLines = 0
|
||||
$0.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
$0.textColor = .label
|
||||
}
|
||||
|
||||
private lazy var timestampLabel = UILabel().then {
|
||||
$0.font = .systemFont(ofSize: 12)
|
||||
$0.textColor = .secondaryLabel
|
||||
$0.textAlignment = .right
|
||||
}
|
||||
|
||||
private lazy var contentStackView = UIStackView().then {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 12
|
||||
$0.alignment = .top
|
||||
}
|
||||
|
||||
private lazy var textStackView = UIStackView().then {
|
||||
$0.axis = .vertical
|
||||
$0.spacing = 4
|
||||
$0.alignment = .fill
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var viewModel: SystemMessageCellViewModel?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
override func setupContentView() {
|
||||
containerView.addSubview(contentStackView)
|
||||
|
||||
contentStackView.addArrangedSubview(iconView)
|
||||
contentStackView.addArrangedSubview(textStackView)
|
||||
|
||||
textStackView.addArrangedSubview(messageLabel)
|
||||
textStackView.addArrangedSubview(timestampLabel)
|
||||
|
||||
contentStackView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(contentInsets)
|
||||
}
|
||||
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(20)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
override func configure(with viewModel: any ChatCellViewModel) {
|
||||
guard let systemViewModel = viewModel as? SystemMessageCellViewModel else { return }
|
||||
self.viewModel = systemViewModel
|
||||
|
||||
messageLabel.text = systemViewModel.content
|
||||
configureContainer(backgroundColor: backgroundColor(for: systemViewModel.cellType))
|
||||
|
||||
// 配置时间戳
|
||||
if let timestamp = systemViewModel.timestamp {
|
||||
timestampLabel.text = formatTimestamp(timestamp)
|
||||
timestampLabel.isHidden = false
|
||||
} else {
|
||||
timestampLabel.isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func formatTimestamp(_ timestamp: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: timestamp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// UserMessageCell.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
class UserMessageCell: ChatBaseCell {
|
||||
// MARK: - UI Components
|
||||
|
||||
private lazy var messageLabel = UILabel().then {
|
||||
$0.numberOfLines = 0
|
||||
$0.font = .systemFont(ofSize: 16)
|
||||
$0.textColor = .label
|
||||
}
|
||||
|
||||
private lazy var timestampLabel = UILabel().then {
|
||||
$0.font = .systemFont(ofSize: 12)
|
||||
$0.textColor = .secondaryLabel
|
||||
$0.textAlignment = .right
|
||||
}
|
||||
|
||||
private lazy var retryIndicator = UIActivityIndicatorView().then {
|
||||
$0.style = .medium
|
||||
$0.hidesWhenStopped = true
|
||||
}
|
||||
|
||||
private lazy var stackView = UIStackView().then {
|
||||
$0.axis = .vertical
|
||||
$0.spacing = 8
|
||||
$0.alignment = .fill
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var viewModel: UserMessageCellViewModel?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
override func setupContentView() {
|
||||
containerView.addSubview(stackView)
|
||||
stackView.addArrangedSubview(messageLabel)
|
||||
|
||||
let bottomContainer = UIView()
|
||||
stackView.addArrangedSubview(bottomContainer)
|
||||
|
||||
bottomContainer.addSubview(retryIndicator)
|
||||
bottomContainer.addSubview(timestampLabel)
|
||||
|
||||
stackView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(contentInsets)
|
||||
}
|
||||
|
||||
retryIndicator.snp.makeConstraints { make in
|
||||
make.leading.centerY.equalToSuperview()
|
||||
make.width.height.equalTo(16)
|
||||
}
|
||||
|
||||
timestampLabel.snp.makeConstraints { make in
|
||||
make.trailing.top.bottom.equalToSuperview()
|
||||
make.leading.greaterThanOrEqualTo(retryIndicator.snp.trailing).offset(8)
|
||||
}
|
||||
|
||||
bottomContainer.snp.makeConstraints { make in
|
||||
make.height.equalTo(16)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
override func configure(with viewModel: any ChatCellViewModel) {
|
||||
guard let userViewModel = viewModel as? UserMessageCellViewModel else { return }
|
||||
self.viewModel = userViewModel
|
||||
|
||||
messageLabel.text = userViewModel.content
|
||||
configureContainer(backgroundColor: backgroundColor(for: userViewModel.cellType))
|
||||
|
||||
let timestamp = userViewModel.timestamp
|
||||
timestampLabel.text = formatTimestamp(timestamp)
|
||||
timestampLabel.isHidden = false
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func formatTimestamp(_ timestamp: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: timestamp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// AssistantMessageCellViewModel.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct AssistantMessageCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .assistantMessage
|
||||
var id: UUID
|
||||
var content: String
|
||||
var timestamp: Date
|
||||
var isStreaming: Bool = false
|
||||
var model: String?
|
||||
var tokens: Int?
|
||||
var canRetry: Bool = false
|
||||
var citations: [CitationViewModel]?
|
||||
var actions: [MessageActionViewModel]?
|
||||
}
|
||||
|
||||
struct CitationViewModel: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var title: String
|
||||
var url: String?
|
||||
var snippet: String?
|
||||
}
|
||||
|
||||
struct MessageActionViewModel: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var title: String
|
||||
var actionType: ActionType
|
||||
var data: [String: String]?
|
||||
|
||||
enum ActionType: String, Codable {
|
||||
case copy
|
||||
case regenerate
|
||||
case like
|
||||
case dislike
|
||||
case share
|
||||
case edit
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
//
|
||||
// AttachmentCellViewModel.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct AttachmentCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .attachment
|
||||
var id: String
|
||||
var attachments: [AttachmentViewModel]
|
||||
var parentMessageId: String
|
||||
}
|
||||
|
||||
struct AttachmentViewModel: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var url: String
|
||||
var mimeType: String?
|
||||
var fileName: String?
|
||||
var size: Int64?
|
||||
}
|
||||
@@ -7,14 +7,10 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum CellType: String, Codable, CaseIterable {
|
||||
public enum CellType: String, Codable, CaseIterable {
|
||||
case userMessage
|
||||
case assistantMessage
|
||||
case systemMessage
|
||||
case attachment
|
||||
case contextReference
|
||||
case workflowStatus
|
||||
case transcription
|
||||
case loading
|
||||
case error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
//
|
||||
// ChatCellViewModel.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol ChatCellViewModel: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: UUID { get }
|
||||
var cellType: CellType { get }
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
//
|
||||
// ChatCellViewModels.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol ChatCellViewModel: Codable, Identifiable, Equatable, Hashable {
|
||||
var cellType: CellType { get }
|
||||
var id: String { get }
|
||||
}
|
||||
|
||||
struct UserMessageCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .userMessage
|
||||
var id: String
|
||||
var content: String
|
||||
var attachments: [AttachmentViewModel]
|
||||
var timestamp: Date?
|
||||
var isRetrying: Bool
|
||||
}
|
||||
|
||||
struct AssistantMessageCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .assistantMessage
|
||||
var id: String
|
||||
var content: String
|
||||
var attachments: [AttachmentViewModel]
|
||||
var timestamp: Date?
|
||||
var isStreaming: Bool
|
||||
var model: String?
|
||||
var tokens: Int?
|
||||
var canRetry: Bool
|
||||
}
|
||||
|
||||
struct SystemMessageCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .systemMessage
|
||||
var id: String
|
||||
var content: String
|
||||
var timestamp: Date?
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
//
|
||||
// ContextReferenceCellViewModel.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct ContextReferenceCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .contextReference
|
||||
var id: String
|
||||
var references: [ChatManager.ContextReference]
|
||||
var parentMessageId: String
|
||||
}
|
||||
@@ -9,8 +9,6 @@ import Foundation
|
||||
|
||||
struct ErrorCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .error
|
||||
var id: String
|
||||
var id: UUID
|
||||
var errorMessage: String
|
||||
var canRetry: Bool
|
||||
var retryAction: String?
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import Foundation
|
||||
|
||||
struct LoadingCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .loading
|
||||
var id: String
|
||||
var id: UUID
|
||||
var message: String?
|
||||
var progress: Double?
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// SystemMessageCellViewModel.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct SystemMessageCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .systemMessage
|
||||
var id: UUID
|
||||
var content: String
|
||||
var timestamp: Date?
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// UserMessageCellViewModel.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct UserMessageCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .userMessage
|
||||
var id: UUID
|
||||
var content: String
|
||||
var timestamp: Date
|
||||
var attachments: [String] = []
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
//
|
||||
// WorkflowStatusCellViewModel.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct WorkflowStatusCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .workflowStatus
|
||||
var id: String
|
||||
var workflow: ChatManager.WorkflowEventData
|
||||
var parentMessageId: String
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import Combine
|
||||
import OrderedCollections
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
protocol ChatTableViewDelegate: AnyObject {
|
||||
func chatTableView(_ tableView: ChatTableView, didSelectRowAt indexPath: IndexPath)
|
||||
}
|
||||
|
||||
class ChatTableView: UIView {
|
||||
// MARK: - UI Components
|
||||
|
||||
lazy var tableView = UITableView().then {
|
||||
$0.backgroundColor = .clear
|
||||
$0.separatorStyle = .none
|
||||
$0.delegate = self
|
||||
$0.dataSource = self
|
||||
$0.keyboardDismissMode = .interactive
|
||||
$0.contentInsetAdjustmentBehavior = .never
|
||||
$0.tableFooterView = UIView(frame: .init(x: 0, y: 0, width: 100, height: 500))
|
||||
}
|
||||
|
||||
lazy var emptyStateView = UIView().then {
|
||||
$0.isHidden = true
|
||||
}
|
||||
|
||||
lazy var emptyStateLabel = UILabel().then {
|
||||
$0.text = "Start a conversation..."
|
||||
$0.font = .systemFont(ofSize: 18, weight: .medium)
|
||||
$0.textColor = .systemGray
|
||||
$0.textAlignment = .center
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
weak var delegate: ChatTableViewDelegate?
|
||||
var sessionId: String? {
|
||||
didSet {
|
||||
if let sessionId {
|
||||
bindToSession(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
var cellViewModels: OrderedDictionary<UUID, any ChatCellViewModel> = [:] {
|
||||
didSet {
|
||||
updateEmptyState()
|
||||
tableView.reloadData()
|
||||
|
||||
if !cellViewModels.isEmpty {
|
||||
let indexPath = IndexPath(row: cellViewModels.count - 1, section: 0)
|
||||
tableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupUI() {
|
||||
// 注册所有 cell 类型
|
||||
ChatCellFactory.registerCells(for: tableView)
|
||||
|
||||
addSubview(tableView)
|
||||
addSubview(emptyStateView)
|
||||
|
||||
emptyStateView.addSubview(emptyStateLabel)
|
||||
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
emptyStateView.snp.makeConstraints { make in
|
||||
make.center.equalTo(tableView)
|
||||
make.width.lessThanOrEqualTo(tableView).inset(32)
|
||||
}
|
||||
|
||||
emptyStateLabel.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
func scrollToBottom(animated: Bool = true) {
|
||||
guard !cellViewModels.isEmpty else { return }
|
||||
let indexPath = IndexPath(row: cellViewModels.count - 1, section: 0)
|
||||
tableView.scrollToRow(at: indexPath, at: .bottom, animated: animated)
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func bindToSession(_ sessionId: String) {
|
||||
cancellables.removeAll()
|
||||
|
||||
ChatManager.shared.$viewModels
|
||||
.map { $0[sessionId] ?? [:] }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] viewModels in
|
||||
self?.cellViewModels = viewModels
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func updateEmptyState() {
|
||||
emptyStateView.isHidden = !cellViewModels.isEmpty
|
||||
tableView.isHidden = cellViewModels.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
|
||||
extension ChatTableView: UITableViewDataSource {
|
||||
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
|
||||
cellViewModels.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let viewModel = cellViewModels.elements[indexPath.row].value
|
||||
return ChatCellFactory.dequeueCell(for: tableView, at: indexPath, with: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
|
||||
extension ChatTableView: UITableViewDelegate {
|
||||
func tableView(_: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
let viewModel = cellViewModels.elements[indexPath.row].value
|
||||
return ChatCellFactory.estimatedHeight(for: viewModel)
|
||||
}
|
||||
|
||||
func tableView(_: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
let viewModel = cellViewModels.elements[indexPath.row].value
|
||||
return ChatCellFactory.estimatedHeight(for: viewModel)
|
||||
}
|
||||
|
||||
func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
delegate?.chatTableView(self, didSelectRowAt: indexPath)
|
||||
}
|
||||
}
|
||||
@@ -85,4 +85,20 @@ extension InputBox: UITextViewDelegate {
|
||||
updatePlaceholderVisibility()
|
||||
updateTextViewHeight()
|
||||
}
|
||||
|
||||
func textView(_: UITextView, shouldChangeTextIn _: NSRange, replacementText text: String) -> Bool {
|
||||
if text == "\n" {
|
||||
delegate?.inputBoxDidSend(self)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func textView(_ textView: UITextView, editMenuForTextIn _: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
|
||||
let insertNewLineAction = UIAction(title: "Insert New Line") { _ in
|
||||
textView.insertText("\n")
|
||||
}
|
||||
|
||||
return UIMenu(children: suggestedActions + [insertNewLineAction])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ class InputBox: UIView {
|
||||
$0.textContainerInset = .zero
|
||||
$0.delegate = self
|
||||
$0.text = ""
|
||||
$0.returnKeyType = .send
|
||||
}
|
||||
|
||||
lazy var placeholderLabel = UILabel().then {
|
||||
@@ -147,7 +148,7 @@ class InputBox: UIView {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
viewModel.$isNetworkEnabled
|
||||
viewModel.$isSearchEnabled
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] enabled in
|
||||
self?.functionBar.updateNetworkState(isEnabled: enabled)
|
||||
|
||||
@@ -5,6 +5,16 @@ import UIKit
|
||||
private let unselectedColor: UIColor = .affineIconPrimary
|
||||
private let selectedColor: UIColor = .affineIconActivated
|
||||
|
||||
private let configurableOptions: [ConfigurableOptions] = [
|
||||
.networking,
|
||||
.reasoning,
|
||||
]
|
||||
enum ConfigurableOptions {
|
||||
case tool
|
||||
case networking
|
||||
case reasoning
|
||||
}
|
||||
|
||||
class InputBoxFunctionBar: UIView {
|
||||
weak var delegate: InputBoxFunctionBarDelegate?
|
||||
|
||||
@@ -23,6 +33,7 @@ class InputBoxFunctionBar: UIView {
|
||||
$0.tintColor = unselectedColor
|
||||
$0.imageView?.contentMode = .scaleAspectFit
|
||||
$0.addTarget(self, action: #selector(toolButtonTapped), for: .touchUpInside)
|
||||
$0.isHidden = !configurableOptions.contains(.tool)
|
||||
}
|
||||
|
||||
lazy var networkButton = UIButton(type: .system).then {
|
||||
@@ -30,6 +41,7 @@ class InputBoxFunctionBar: UIView {
|
||||
$0.tintColor = unselectedColor
|
||||
$0.imageView?.contentMode = .scaleAspectFit
|
||||
$0.addTarget(self, action: #selector(networkButtonTapped), for: .touchUpInside)
|
||||
$0.isHidden = !configurableOptions.contains(.networking)
|
||||
}
|
||||
|
||||
lazy var deepThinkingButton = UIButton(type: .system).then {
|
||||
@@ -37,6 +49,7 @@ class InputBoxFunctionBar: UIView {
|
||||
$0.tintColor = unselectedColor
|
||||
$0.imageView?.contentMode = .scaleAspectFit
|
||||
$0.addTarget(self, action: #selector(deepThinkingButtonTapped), for: .touchUpInside)
|
||||
$0.isHidden = !configurableOptions.contains(.reasoning)
|
||||
}
|
||||
|
||||
lazy var sendButton = UIButton(type: .system).then {
|
||||
|
||||
@@ -16,16 +16,16 @@ public struct InputBoxData {
|
||||
public var fileAttachments: [FileAttachment] = []
|
||||
public var documentAttachments: [DocumentAttachment] = []
|
||||
public var isToolEnabled: Bool
|
||||
public var isNetworkEnabled: Bool
|
||||
public var isSearchEnabled: Bool
|
||||
public var isDeepThinkingEnabled: Bool
|
||||
|
||||
public init(text: String, imageAttachments: [ImageAttachment], fileAttachments: [FileAttachment], documentAttachments: [DocumentAttachment], isToolEnabled: Bool, isNetworkEnabled: Bool, isDeepThinkingEnabled: Bool) {
|
||||
public init(text: String, imageAttachments: [ImageAttachment], fileAttachments: [FileAttachment], documentAttachments: [DocumentAttachment], isToolEnabled: Bool, isSearchEnabled: Bool, isDeepThinkingEnabled: Bool) {
|
||||
self.text = text
|
||||
self.imageAttachments = imageAttachments
|
||||
self.fileAttachments = fileAttachments
|
||||
self.documentAttachments = documentAttachments
|
||||
self.isToolEnabled = isToolEnabled
|
||||
self.isNetworkEnabled = isNetworkEnabled
|
||||
self.isSearchEnabled = isSearchEnabled
|
||||
self.isDeepThinkingEnabled = isDeepThinkingEnabled
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ public class InputBoxViewModel: ObservableObject {
|
||||
|
||||
@Published public var inputText: String = ""
|
||||
@Published public var isToolEnabled: Bool = false
|
||||
@Published public var isNetworkEnabled: Bool = false
|
||||
@Published public var isSearchEnabled: Bool = false
|
||||
@Published public var isDeepThinkingEnabled: Bool = false
|
||||
@Published public var imageAttachments: [ImageAttachment] = []
|
||||
@Published public var fileAttachments: [FileAttachment] = []
|
||||
@@ -90,7 +90,7 @@ public extension InputBoxViewModel {
|
||||
}
|
||||
|
||||
func toggleNetwork() {
|
||||
isNetworkEnabled.toggle()
|
||||
isSearchEnabled.toggle()
|
||||
}
|
||||
|
||||
func toggleDeepThinking() {
|
||||
@@ -148,7 +148,7 @@ public extension InputBoxViewModel {
|
||||
fileAttachments: fileAttachments,
|
||||
documentAttachments: documentAttachments,
|
||||
isToolEnabled: isToolEnabled,
|
||||
isNetworkEnabled: isNetworkEnabled,
|
||||
isSearchEnabled: isSearchEnabled,
|
||||
isDeepThinkingEnabled: isDeepThinkingEnabled
|
||||
)
|
||||
}
|
||||
@@ -159,7 +159,7 @@ public extension InputBoxViewModel {
|
||||
fileAttachments.removeAll()
|
||||
documentAttachments.removeAll()
|
||||
isToolEnabled = false
|
||||
isNetworkEnabled = false
|
||||
isSearchEnabled = false
|
||||
isDeepThinkingEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import './chat-panel-messages';
|
||||
|
||||
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import type { ContextEmbedStatus, CopilotSessionType } from '@affine/graphql';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
@@ -241,6 +242,9 @@ export class ChatPanel extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor affineFeatureFlagService!: FeatureFlagService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor affineWorkspaceDialogService!: WorkspaceDialogService;
|
||||
|
||||
@state()
|
||||
accessor isLoading = false;
|
||||
|
||||
@@ -481,6 +485,7 @@ export class ChatPanel extends SignalWatcher(
|
||||
.playgroundConfig=${this.playgroundConfig}
|
||||
.docDisplayConfig=${this.docDisplayConfig}
|
||||
.searchMenuConfig=${this.searchMenuConfig}
|
||||
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
|
||||
.trackOptions=${{
|
||||
where: 'chat-panel',
|
||||
control: 'chat-send',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import './ai-chat-composer-tip';
|
||||
|
||||
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import type {
|
||||
ContextEmbedStatus,
|
||||
ContextWorkspaceEmbeddingStatus,
|
||||
@@ -104,11 +105,11 @@ export class AIChatComposer extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor panelWidth: Signal<number | undefined> = signal(undefined);
|
||||
|
||||
@state()
|
||||
accessor chips: ChatChip[] = [];
|
||||
@property({ attribute: false })
|
||||
accessor affineWorkspaceDialogService!: WorkspaceDialogService;
|
||||
|
||||
@state()
|
||||
accessor embeddingProgressText = 'Loading embedding status...';
|
||||
accessor chips: ChatChip[] = [];
|
||||
|
||||
@state()
|
||||
accessor embeddingCompleted = false;
|
||||
@@ -160,7 +161,8 @@ export class AIChatComposer extends SignalWatcher(
|
||||
this.embeddingCompleted
|
||||
? null
|
||||
: html`<ai-chat-embedding-status-tooltip
|
||||
.progressText=${this.embeddingProgressText}
|
||||
.affineWorkspaceDialogService=${this
|
||||
.affineWorkspaceDialogService}
|
||||
/>`,
|
||||
].filter(Boolean)}
|
||||
.loop=${false}
|
||||
@@ -348,24 +350,20 @@ export class AIChatComposer extends SignalWatcher(
|
||||
this.host.std.workspace.id,
|
||||
(status: ContextWorkspaceEmbeddingStatus) => {
|
||||
if (!status) {
|
||||
this.embeddingProgressText = 'Loading embedding status...';
|
||||
this.embeddingCompleted = false;
|
||||
return;
|
||||
}
|
||||
const completed = status.embedded === status.total;
|
||||
this.embeddingCompleted = completed;
|
||||
if (completed) {
|
||||
this.embeddingProgressText =
|
||||
'Embedding finished. You are getting the best results!';
|
||||
this.embeddingCompleted = true;
|
||||
} else {
|
||||
this.embeddingProgressText =
|
||||
'File not embedded yet. Results will improve after embedding.';
|
||||
this.embeddingCompleted = false;
|
||||
}
|
||||
},
|
||||
signal
|
||||
);
|
||||
} catch {
|
||||
this.embeddingProgressText = 'Failed to load embedding status...';
|
||||
this.embeddingCompleted = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -50,7 +50,8 @@ export class AIChatInput extends SignalWatcher(
|
||||
0px 0px 0px 0px rgba(28, 158, 228, 0),
|
||||
0px 0px 0px 2px transparent;
|
||||
}
|
||||
[data-theme='light'] .chat-panel-input {
|
||||
[data-theme='light'] .chat-panel-input,
|
||||
.chat-panel-input {
|
||||
box-shadow:
|
||||
var(--border-shadow),
|
||||
0px 0px 0px 3px transparent,
|
||||
@@ -252,6 +253,9 @@ export class AIChatInput extends SignalWatcher(
|
||||
font-size: 20px;
|
||||
background: var(--affine-v2-icon-activated);
|
||||
color: var(--affine-v2-layer-pureWhite);
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chat-panel-send[aria-disabled='true'] {
|
||||
cursor: not-allowed;
|
||||
@@ -268,6 +272,9 @@ export class AIChatInput extends SignalWatcher(
|
||||
border-radius: 50%;
|
||||
font-size: 24px;
|
||||
color: var(--affine-v2-icon-activated);
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.chat-input-footer-spacer {
|
||||
flex: 1;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import { SignalWatcher } from '@blocksuite/affine/global/lit';
|
||||
import { unsafeCSSVar } from '@blocksuite/affine/shared/theme';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { debounce } from 'lodash-es';
|
||||
|
||||
export class AIChatEmbeddingStatusTooltip extends SignalWatcher(LitElement) {
|
||||
static override styles = css`
|
||||
@@ -34,7 +36,21 @@ export class AIChatEmbeddingStatusTooltip extends SignalWatcher(LitElement) {
|
||||
`;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor progressText = 'Loading embedding status...';
|
||||
accessor affineWorkspaceDialogService!: WorkspaceDialogService;
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
private readonly _handleCheckStatusClick = debounce(
|
||||
() => {
|
||||
this.affineWorkspaceDialogService.open('setting', {
|
||||
activeTab: 'workspace:embedding',
|
||||
});
|
||||
},
|
||||
1000,
|
||||
{ leading: true }
|
||||
);
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
@@ -48,11 +64,9 @@ export class AIChatEmbeddingStatusTooltip extends SignalWatcher(LitElement) {
|
||||
<div
|
||||
class="check-status"
|
||||
data-testid="ai-chat-embedding-status-tooltip-check"
|
||||
@click=${this._handleCheckStatusClick}
|
||||
>
|
||||
Check status
|
||||
<affine-tooltip tip-position="top-start"
|
||||
>${this.progressText}</affine-tooltip
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -26,6 +26,9 @@ export class ChatInputPreference extends SignalWatcher(
|
||||
color: var(--affine-v2-icon-primary);
|
||||
transition: all 0.23s ease;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chat-input-preference-trigger:hover {
|
||||
background-color: var(--affine-v2-layer-background-hoverOverlay);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { scrollbarStyle } from '@blocksuite/affine/shared/styles';
|
||||
import { CloseIcon } from '@blocksuite/icons/lit';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
export class ImagePreviewGrid extends LitElement {
|
||||
static override styles = css`
|
||||
@@ -11,6 +12,10 @@ export class ImagePreviewGrid extends LitElement {
|
||||
overflow-y: hidden;
|
||||
max-height: 80px;
|
||||
white-space: nowrap;
|
||||
|
||||
/* to prevent the close button from being clipped */
|
||||
padding-top: 8px;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
${scrollbarStyle('.image-preview-wrapper')}
|
||||
@@ -28,42 +33,42 @@ export class ImagePreviewGrid extends LitElement {
|
||||
height: 68px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.image-container img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border: 1px solid var(--affine-v2-layer-insideBorder-border);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.close-wrapper {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--affine-border-color);
|
||||
border: 0.5px solid var(--affine-v2-layer-insideBorder-border);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: none;
|
||||
position: absolute;
|
||||
background-color: var(--affine-white);
|
||||
background-color: var(--affine-v2-layer-background-primary);
|
||||
color: var(--affine-v2-icon-primary);
|
||||
z-index: 1;
|
||||
cursor: pointer;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
}
|
||||
|
||||
.image-container:hover .close-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.close-wrapper:hover {
|
||||
background-color: var(--affine-background-error-color);
|
||||
border: 1px solid var(--affine-error-color);
|
||||
}
|
||||
|
||||
.close-wrapper:hover svg path {
|
||||
fill: var(--affine-error-color);
|
||||
background-color: var(--affine-v2-layer-background-error);
|
||||
border: 0.5px solid var(--affine-v2-button-error);
|
||||
color: var(--affine-v2-button-error);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -129,37 +134,11 @@ export class ImagePreviewGrid extends LitElement {
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _handleMouseEnter = (evt: MouseEvent, index: number) => {
|
||||
const ele = evt.target as HTMLImageElement;
|
||||
const rect = ele.getBoundingClientRect();
|
||||
if (!ele.parentElement) return;
|
||||
const parentRect = ele.parentElement.getBoundingClientRect();
|
||||
const left = Math.abs(rect.right - parentRect.left);
|
||||
const top = Math.abs(parentRect.top - rect.top);
|
||||
this.currentIndex = index;
|
||||
if (!this.closeWrapper) return;
|
||||
this.closeWrapper.style.display = 'flex';
|
||||
this.closeWrapper.style.left = left + 'px';
|
||||
this.closeWrapper.style.top = top + 'px';
|
||||
};
|
||||
|
||||
private readonly _handleMouseLeave = () => {
|
||||
if (!this.closeWrapper) return;
|
||||
this.closeWrapper.style.display = 'none';
|
||||
this.currentIndex = -1;
|
||||
};
|
||||
|
||||
private readonly _handleDelete = () => {
|
||||
if (this.currentIndex >= 0 && this.currentIndex < this.images.length) {
|
||||
const file = this.images[this.currentIndex];
|
||||
const url = this._getObjectUrl(file);
|
||||
this._releaseObjectUrl(url);
|
||||
|
||||
this.onImageRemove?.(this.currentIndex);
|
||||
this.currentIndex = -1;
|
||||
if (!this.closeWrapper) return;
|
||||
this.closeWrapper.style.display = 'none';
|
||||
}
|
||||
private readonly _handleDelete = (index: number) => {
|
||||
const file = this.images[index];
|
||||
const url = this._getObjectUrl(file);
|
||||
this._releaseObjectUrl(url);
|
||||
this.onImageRemove?.(index);
|
||||
};
|
||||
|
||||
override disconnectedCallback() {
|
||||
@@ -169,7 +148,7 @@ export class ImagePreviewGrid extends LitElement {
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="image-preview-wrapper" @mouseleave=${this._handleMouseLeave}>
|
||||
<div class="image-preview-wrapper">
|
||||
<div class="images-container">
|
||||
${repeat(
|
||||
this.images,
|
||||
@@ -180,18 +159,21 @@ export class ImagePreviewGrid extends LitElement {
|
||||
<div
|
||||
class="image-container"
|
||||
@error=${() => this._releaseObjectUrl(url)}
|
||||
@mouseenter=${(evt: MouseEvent) =>
|
||||
this._handleMouseEnter(evt, index)}
|
||||
style=${styleMap({
|
||||
backgroundImage: `url(${url})`,
|
||||
})}
|
||||
>
|
||||
<img src="${url}" alt="${image.name}" />
|
||||
<div
|
||||
class="close-wrapper"
|
||||
@click=${() => this._handleDelete(index)}
|
||||
>
|
||||
${CloseIcon()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<div class="close-wrapper" @click=${this._handleDelete}>
|
||||
${CloseIcon()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -201,12 +183,6 @@ export class ImagePreviewGrid extends LitElement {
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onImageRemove: ((index: number) => void) | null = null;
|
||||
|
||||
@query('.close-wrapper')
|
||||
accessor closeWrapper: HTMLDivElement | null = null;
|
||||
|
||||
@state()
|
||||
accessor currentIndex = -1;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import type { ContextEmbedStatus } from '@affine/graphql';
|
||||
import {
|
||||
@@ -593,6 +594,7 @@ export class AIChatBlockPeekView extends LitElement {
|
||||
.networkSearchConfig=${networkSearchConfig}
|
||||
.docDisplayConfig=${this.docDisplayConfig}
|
||||
.searchMenuConfig=${this.searchMenuConfig}
|
||||
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
|
||||
.onChatSuccess=${this._onChatSuccess}
|
||||
.trackOptions=${{
|
||||
where: 'ai-chat-block',
|
||||
@@ -628,6 +630,9 @@ export class AIChatBlockPeekView extends LitElement {
|
||||
@property({ attribute: false })
|
||||
accessor affineFeatureFlagService!: FeatureFlagService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor affineWorkspaceDialogService!: WorkspaceDialogService;
|
||||
|
||||
@state()
|
||||
accessor _historyMessages: ChatMessage[] = [];
|
||||
|
||||
@@ -657,7 +662,8 @@ export const AIChatBlockPeekViewTemplate = (
|
||||
searchMenuConfig: SearchMenuConfig,
|
||||
networkSearchConfig: AINetworkSearchConfig,
|
||||
reasoningConfig: AIReasoningConfig,
|
||||
affineFeatureFlagService: FeatureFlagService
|
||||
affineFeatureFlagService: FeatureFlagService,
|
||||
affineWorkspaceDialogService: WorkspaceDialogService
|
||||
) => {
|
||||
return html`<ai-chat-block-peek-view
|
||||
.blockModel=${blockModel}
|
||||
@@ -667,5 +673,6 @@ export const AIChatBlockPeekViewTemplate = (
|
||||
.searchMenuConfig=${searchMenuConfig}
|
||||
.reasoningConfig=${reasoningConfig}
|
||||
.affineFeatureFlagService=${affineFeatureFlagService}
|
||||
.affineWorkspaceDialogService=${affineWorkspaceDialogService}
|
||||
></ai-chat-block-peek-view>`;
|
||||
};
|
||||
|
||||
@@ -110,7 +110,10 @@ interface OauthProviderProps {
|
||||
}
|
||||
|
||||
function OAuthProvider({ onContinue, provider }: OauthProviderProps) {
|
||||
const { icon } = OAuthProviderMap[provider];
|
||||
const { icon } =
|
||||
provider in OAuthProviderMap
|
||||
? OAuthProviderMap[provider]
|
||||
: { icon: undefined };
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
onContinue(provider);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChatPanel } from '@affine/core/blocksuite/ai';
|
||||
import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-editor';
|
||||
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { ViewExtensionManagerIdentifier } from '@blocksuite/affine/ext-loader';
|
||||
@@ -80,6 +81,9 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
|
||||
.get('preview-page');
|
||||
chatPanelRef.current.affineFeatureFlagService =
|
||||
framework.get(FeatureFlagService);
|
||||
chatPanelRef.current.affineWorkspaceDialogService = framework.get(
|
||||
WorkspaceDialogService
|
||||
);
|
||||
|
||||
containerRef.current?.append(chatPanelRef.current);
|
||||
} else {
|
||||
|
||||
@@ -41,9 +41,12 @@ export class FetchService extends Service {
|
||||
});
|
||||
|
||||
const timeout = init?.timeout ?? 15000;
|
||||
const timeoutId = setTimeout(() => {
|
||||
abortController.abort('timeout');
|
||||
}, timeout);
|
||||
const timeoutId =
|
||||
timeout > 0
|
||||
? setTimeout(() => {
|
||||
abortController.abort(new Error('timeout after ' + timeout + 'ms'));
|
||||
}, timeout)
|
||||
: undefined;
|
||||
|
||||
let res: Response;
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export class AudioTranscriptionJobStore extends Entity<{
|
||||
}
|
||||
const files = await this.props.getAudioFiles();
|
||||
const response = await graphqlService.gql({
|
||||
timeout: 600_000, // default 15s is too short for audio transcription
|
||||
timeout: 0, // default 15s is too short for audio transcription
|
||||
query: submitAudioTranscriptionMutation,
|
||||
variables: {
|
||||
workspaceId: this.currentWorkspaceId,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { toReactNode } from '@affine/component';
|
||||
import { AIChatBlockPeekViewTemplate } from '@affine/core/blocksuite/ai';
|
||||
import type { AIChatBlockModel } from '@affine/core/blocksuite/ai/blocks/ai-chat-block/model/ai-chat-model';
|
||||
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import type { EditorHost } from '@blocksuite/affine/std';
|
||||
import { useFramework } from '@toeverything/infra';
|
||||
@@ -25,6 +26,7 @@ export const AIChatBlockPeekView = ({
|
||||
|
||||
const framework = useFramework();
|
||||
const affineFeatureFlagService = framework.get(FeatureFlagService);
|
||||
const affineWorkspaceDialogService = framework.get(WorkspaceDialogService);
|
||||
|
||||
return useMemo(() => {
|
||||
const template = AIChatBlockPeekViewTemplate(
|
||||
@@ -34,7 +36,8 @@ export const AIChatBlockPeekView = ({
|
||||
searchMenuConfig,
|
||||
networkSearchConfig,
|
||||
reasoningConfig,
|
||||
affineFeatureFlagService
|
||||
affineFeatureFlagService,
|
||||
affineWorkspaceDialogService
|
||||
);
|
||||
return toReactNode(template);
|
||||
}, [
|
||||
@@ -45,5 +48,6 @@ export const AIChatBlockPeekView = ({
|
||||
networkSearchConfig,
|
||||
reasoningConfig,
|
||||
affineFeatureFlagService,
|
||||
affineWorkspaceDialogService,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ test.describe('AIBasic/Chat', () => {
|
||||
await expect(page.getByTestId('ai-onboarding')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display embedding status tooltip', async ({
|
||||
test('should open embedding settings when clicking check status button', async ({
|
||||
loggedInPage: page,
|
||||
utils,
|
||||
}) => {
|
||||
@@ -32,12 +32,8 @@ test.describe('AIBasic/Chat', () => {
|
||||
);
|
||||
await expect(check).toBeVisible({ timeout: 50 * 1000 });
|
||||
|
||||
await check.hover();
|
||||
const tooltip = await page.getByTestId('ai-chat-embedding-status-tooltip');
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(tooltip).toHaveText(
|
||||
/Results will improve after embedding|Embedding finished/
|
||||
);
|
||||
await check.click();
|
||||
await expect(page.getByTestId('workspace-setting:embedding')).toBeVisible();
|
||||
});
|
||||
|
||||
test(`should send message and receive AI response:
|
||||
|
||||
@@ -261,7 +261,7 @@ export class ChatPanelUtils {
|
||||
) {
|
||||
await this.uploadImages(page, images);
|
||||
|
||||
await page.waitForSelector('ai-chat-input img');
|
||||
await page.waitForSelector('ai-chat-input .image-container');
|
||||
await this.makeChat(page, text);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user